captaincore-manager/templates/core-v2.php
Austin Ginder 922adf67e5 👌 IMPROVE: v2 UI
2026-06-02 07:04:34 -04:00

11878 lines
459 KiB
PHP

<?php
if ( ! function_exists( 'is_plugin_active' ) ) {
include_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_url = plugin_dir_url( __DIR__ );
$user = ( new CaptainCore\User )->profile();
$configurations = ( new CaptainCore\Configurations )->get();
$colors = CaptainCore\Configurations::colors();
$footer = json_decode( captaincore_footer_content_extracted(), true );
$socket = captaincore_fetch_socket_address() . '/ws';
$site_filters = ( new CaptainCore\Environments )->filters();
$site_filters_core = ( new CaptainCore\Environments )->filters_for_core();
$modules = [
'billing' => ! defined( 'CAPTAINCORE_CUSTOM_DOMAIN' ),
'dns' => defined( 'CONSTELLIX_API_KEY' ) && defined( 'CONSTELLIX_SECRET_KEY' ),
];
if ( ! is_array( $footer ) ) {
$footer = [
'switch_to_link' => '',
'switch_to_text' => '',
];
}
$color_value = static function ( $key, $fallback ) use ( $colors ) {
if ( is_object( $colors ) && isset( $colors->{$key} ) ) {
return $colors->{$key};
}
if ( is_array( $colors ) && isset( $colors[ $key ] ) ) {
return $colors[ $key ];
}
return $fallback;
};
$json_options = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP;
?><!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<title><?php echo esc_html( $configurations->name ); ?> - Account</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Manage your sites, billing, and account details.">
<meta charset="utf-8">
<?php captaincore_header_content_extracted(); ?>
<link href="<?php echo esc_url( home_url( $configurations->path ?? '/account/' ) ); ?>" rel="canonical">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
[v-cloak] { display: none !important; }
:root {
--cc-primary: <?php echo esc_attr( $color_value( 'primary', '#2c3e50' ) ); ?>;
--cc-accent: <?php echo esc_attr( $color_value( 'accent', '#f8f9fb' ) ); ?>;
--cc-success: <?php echo esc_attr( $color_value( 'success', '#2e7d32' ) ); ?>;
--cc-warning: <?php echo esc_attr( $color_value( 'warning', '#f59e0b' ) ); ?>;
--cc-error: <?php echo esc_attr( $color_value( 'error', '#c2410c' ) ); ?>;
--paper: oklch(1 0 0);
--paper-2: oklch(0.975 0.003 240);
--paper-3: oklch(0.955 0.005 240);
--rule: oklch(0.88 0.008 240);
--ink: oklch(0.30 0.014 250);
--ink-2: oklch(0.48 0.012 250);
--ink-3: oklch(0.64 0.010 250);
--brand: oklch(0.72 0.14 235);
--brand-ink: oklch(0.54 0.16 235);
--ok: oklch(0.68 0.16 150);
--warn: oklch(0.78 0.16 80);
--bad: oklch(0.62 0.18 25);
--radius-sm: 6px;
--radius: 8px;
--shadow: 0 16px 40px -24px rgb(20 28 45 / 0.28), 0 1px 0 rgb(20 28 45 / 0.04);
--app-maxw: 1380px;
--gutter: 32px;
--sans: "Space Grotesk", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
:root[data-theme="dark"] {
--paper: oklch(0.19 0.018 240);
--paper-2: oklch(0.23 0.020 240);
--paper-3: oklch(0.28 0.022 240);
--rule: oklch(0.36 0.022 240);
--ink: oklch(0.96 0.005 240);
--ink-2: oklch(0.80 0.008 240);
--ink-3: oklch(0.64 0.010 240);
--brand: oklch(0.78 0.14 235);
--brand-ink: oklch(0.84 0.12 235);
--shadow: 0 24px 48px -28px rgb(0 0 0 / 0.72);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--paper-2);
color: var(--ink);
font-family: var(--sans);
font-size: 15px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body.cc-v2-lock { overflow: hidden; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
img, svg { display: block; max-width: 100%; }
.cc-v2 { min-height: 100vh; }
.cc-v2.is-login .app-topbar { display: none; }
.app-topbar {
position: sticky;
top: 0;
z-index: 60;
background: color-mix(in oklab, var(--paper) 92%, transparent);
border-bottom: 1px solid var(--rule);
backdrop-filter: saturate(140%) blur(12px);
-webkit-backdrop-filter: saturate(140%) blur(12px);
}
.topbar__inner {
max-width: var(--app-maxw);
height: 64px;
margin: 0 auto;
padding: 0 var(--gutter);
display: flex;
align-items: center;
gap: 24px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brand__logo {
width: auto;
max-width: 150px;
max-height: 32px;
object-fit: contain;
}
.brand__name {
font-size: 17px;
font-weight: 700;
color: var(--ink);
white-space: nowrap;
}
.app-nav {
display: flex;
align-items: center;
flex: 1;
gap: 4px;
margin-left: 8px;
overflow-x: auto;
}
.app-nav a {
border-radius: var(--radius-sm);
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
padding: 8px 11px;
text-transform: uppercase;
transition: background .15s ease, color .15s ease;
white-space: nowrap;
}
.app-nav a:hover,
.app-nav a.active {
background: var(--paper-2);
color: var(--ink);
}
.app-tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: 0;
}
.icon-btn,
.segmented button,
.row-action,
.drawer__close {
width: 34px;
height: 34px;
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink-2);
display: inline-grid;
place-items: center;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.icon-btn:hover,
.segmented button:hover,
.row-action:hover,
.drawer__close:hover {
background: var(--paper-3);
color: var(--ink);
border-color: color-mix(in oklab, var(--ink) 24%, var(--rule));
}
.user-chip {
border: 1px solid var(--rule);
border-radius: 999px;
background: var(--paper);
color: var(--ink);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 10px 4px 4px;
}
.user-chip:hover,
.user-chip.active {
background: var(--paper-2);
border-color: color-mix(in oklab, var(--ink) 24%, var(--rule));
}
.avatar {
width: 26px;
height: 26px;
border-radius: 999px;
background: linear-gradient(135deg, var(--brand), color-mix(in oklab, var(--brand) 45%, var(--ink)));
color: white;
display: grid;
place-items: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
}
.user-chip span {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-menu {
position: relative;
}
.account-dropdown {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: none;
min-width: 236px;
padding: 8px;
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 80;
}
.account-dropdown.open {
display: grid;
gap: 6px;
}
.account-dropdown__section {
display: grid;
gap: 2px;
}
.account-dropdown__section + .account-dropdown__section {
border-top: 1px solid var(--rule);
padding-top: 6px;
}
.account-dropdown__label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0;
padding: 5px 8px 2px;
text-transform: uppercase;
}
.account-dropdown a,
.account-dropdown button {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--ink-2);
cursor: pointer;
display: flex;
font: inherit;
justify-content: space-between;
min-height: 32px;
padding: 7px 8px;
text-align: left;
width: 100%;
}
.account-dropdown a:hover,
.account-dropdown button:hover,
.account-dropdown a.active {
background: var(--paper-2);
color: var(--ink);
}
.app-shell {
max-width: var(--app-maxw);
margin: 0 auto;
padding: 28px var(--gutter) 96px;
}
.summary {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.summary__title {
display: flex;
align-items: baseline;
gap: 14px;
min-width: 0;
}
h1 {
font-size: 28px;
font-weight: 650;
line-height: 1.1;
margin: 0;
color: var(--ink);
}
.summary__sub,
.table-caption,
.meta-label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
text-transform: uppercase;
}
.summary__pills,
.chips,
.badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.pill,
.chip-filter,
.badge {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--rule);
border-radius: 999px;
background: var(--paper);
color: var(--ink-2);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
line-height: 1;
min-height: 28px;
padding: 7px 11px;
text-transform: uppercase;
white-space: nowrap;
}
.pill b { color: var(--ink); font-weight: 700; }
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--ink-3);
flex: none;
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
margin-bottom: 14px;
padding: 8px 8px 8px 14px;
}
.search-input {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.search-input svg { color: var(--ink-3); flex: none; }
.search-input input,
.filter-search input,
.form-field input,
.form-field select,
.form-field textarea {
width: 100%;
border: 0;
background: transparent;
color: var(--ink);
outline: 0;
}
.search-input input { min-width: 80px; font-size: 15px; }
.search-input input::placeholder,
.filter-search input::placeholder { color: var(--ink-3); }
.scope { color: var(--ink-3); font-family: var(--mono); font-size: 11px; white-space: nowrap; }
.segmented {
display: inline-flex;
gap: 2px;
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper-2);
padding: 2px;
}
.segmented button {
width: 30px;
height: 28px;
border: 0;
background: transparent;
}
.segmented button.active {
background: var(--paper);
color: var(--ink);
box-shadow: 0 1px 0 rgb(0 0 0 / 0.05);
}
.primary-btn,
.secondary-btn,
.danger-btn {
border: 1px solid var(--ink);
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 8px 13px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.primary-btn { background: var(--ink); color: var(--paper); }
.primary-btn:hover { background: color-mix(in oklab, var(--ink) 90%, white); }
.secondary-btn {
background: var(--paper);
border-color: var(--rule);
color: var(--ink);
}
.secondary-btn:hover { background: var(--paper-3); }
.danger-btn {
background: color-mix(in oklab, var(--bad) 10%, var(--paper));
border-color: color-mix(in oklab, var(--bad) 34%, var(--rule));
color: var(--bad);
}
.chips {
margin-bottom: 18px;
position: relative;
z-index: 25;
}
.chip-filter { cursor: pointer; }
.chip-filter:hover {
border-color: color-mix(in oklab, var(--ink) 24%, var(--rule));
color: var(--ink);
}
.chip-filter.active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.count {
border-radius: 999px;
background: var(--paper-2);
color: var(--ink-2);
padding: 2px 6px;
}
.chip-filter.active .count {
background: color-mix(in oklab, white 20%, transparent);
color: var(--paper);
}
.chip-wrap { position: relative; }
.filter-pop {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 340px;
max-width: calc(100vw - 28px);
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: none;
overflow: hidden;
}
.filter-pop.open { display: block; }
.filter-pop__head {
border-bottom: 1px solid var(--rule);
display: grid;
gap: 8px;
padding: 12px;
}
.filter-search {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper-2);
padding: 7px 9px;
}
.filter-list {
max-height: 300px;
overflow-y: auto;
padding: 6px;
}
.filter-option {
width: 100%;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
padding: 8px 9px;
text-align: left;
}
.filter-option:hover,
.filter-option.active { background: var(--paper-2); }
.filter-option .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-option .muted { color: var(--ink-3); font-family: var(--mono); font-size: 11px; }
.filter-logic-bar,
.filter-secondary {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -8px 0 16px;
}
.filter-secondary {
align-items: stretch;
margin-top: -10px;
}
.filter-control {
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.filter-control__label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.filter-toggle {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: 999px;
display: inline-flex;
gap: 2px;
padding: 2px;
}
.filter-toggle button {
background: transparent;
border: 0;
border-radius: 999px;
color: var(--ink-2);
font-family: var(--mono);
font-size: 10px;
padding: 4px 8px;
text-transform: uppercase;
}
.filter-toggle button.active {
background: var(--ink);
color: var(--paper);
}
.filter-drill {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
gap: 8px;
min-width: min(100%, 300px);
padding: 10px;
}
.filter-drill__head {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.filter-drill__title {
min-width: 0;
}
.filter-drill__title strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-drill__title span {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.filter-drill__group {
display: grid;
gap: 6px;
}
.filter-drill__options {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.filter-mini {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink-2);
font-family: var(--mono);
font-size: 10px;
padding: 4px 8px;
}
.filter-mini.active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.sites {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
overflow: hidden;
}
.sites__head,
.site-row {
display: grid;
grid-template-columns: 32px minmax(260px, 1.7fr) 110px 110px 110px 116px 116px 112px;
gap: 12px;
align-items: center;
}
.sites__head {
background: var(--paper-2);
border-bottom: 1px solid var(--rule);
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
padding: 11px 14px;
text-transform: uppercase;
}
.sort-button {
align-items: center;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 5px;
letter-spacing: inherit;
min-height: 20px;
padding: 0;
text-align: left;
text-transform: inherit;
}
.sort-button:hover,
.sort-button.active {
color: var(--ink);
}
.sort-button__icon {
color: var(--ink-3);
font-size: 10px;
line-height: 1;
opacity: .7;
width: 10px;
}
.sort-button.active .sort-button__icon {
color: var(--brand-ink);
opacity: 1;
}
.site-group + .site-group { border-top: 1px solid var(--rule); }
.site-group__bar {
background: color-mix(in oklab, var(--paper-2) 80%, var(--paper));
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
min-height: 38px;
padding: 8px 14px 8px 58px;
}
.site-group__bar .domain {
color: var(--ink);
font-weight: 650;
text-decoration: none;
}
.site-group__bar .domain:hover { color: var(--brand); }
.site-group__bar .meta {
color: var(--ink-3);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-row {
border: 0;
border-bottom: 1px solid var(--rule);
background: var(--paper);
color: var(--ink);
cursor: pointer;
min-height: 64px;
padding: 10px 14px;
text-align: left;
width: 100%;
}
.site-row:last-child { border-bottom: 0; }
.site-row:hover { background: color-mix(in oklab, var(--brand) 5%, var(--paper)); }
.site-row:focus-visible {
outline: 2px solid color-mix(in oklab, var(--brand) 55%, transparent);
outline-offset: -2px;
}
.site-row.selected { background: color-mix(in oklab, var(--brand) 10%, var(--paper)); }
.site-row input[type="checkbox"],
.sites__head input[type="checkbox"] { margin: 0; accent-color: var(--brand); }
.site-row__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.site-thumb {
width: 42px;
height: 42px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: linear-gradient(135deg, color-mix(in oklab, var(--brand) 45%, var(--paper-3)), var(--paper-3));
flex: none;
object-fit: cover;
}
.site-thumb.placeholder {
display: grid;
place-items: center;
color: var(--ink-3);
}
.site-info { min-width: 0; }
.site-info .url {
color: var(--ink);
font-size: 14px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badges {
color: var(--ink-3);
font-size: 12px;
gap: 6px;
margin-top: 4px;
}
.badge {
background: var(--paper-2);
border-color: var(--rule);
color: var(--ink-2);
font-size: 10px;
min-height: 20px;
padding: 4px 7px;
}
.badge.production {
background: color-mix(in oklab, var(--ok) 11%, var(--paper));
color: color-mix(in oklab, var(--ok) 65%, var(--ink));
}
.badge.staging {
background: color-mix(in oklab, var(--warn) 13%, var(--paper));
color: color-mix(in oklab, var(--warn) 58%, var(--ink));
}
.metric {
display: grid;
gap: 2px;
min-width: 0;
}
.metric .label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0;
text-transform: uppercase;
}
.metric .val {
color: var(--ink);
font-size: 13px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-cell {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--ink-2);
font-size: 13px;
white-space: nowrap;
}
.row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.row-action {
width: 30px;
height: 30px;
background: transparent;
text-decoration: none;
}
.sites__foot {
background: var(--paper-2);
border-top: 1px solid var(--rule);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
}
.pagination-foot {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 10px 12px;
}
.pager {
display: inline-flex;
gap: 6px;
}
.pager button {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink-2);
min-width: 34px;
height: 30px;
}
.pager button:disabled { opacity: .45; cursor: not-allowed; }
.grid-sites {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
}
.grid-card {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
overflow: hidden;
text-align: left;
cursor: pointer;
transition: border-color .15s ease, transform .15s ease;
}
.grid-card:hover {
border-color: color-mix(in oklab, var(--brand) 45%, var(--rule));
transform: translateY(-1px);
}
.grid-preview {
aspect-ratio: 16 / 10;
background: var(--paper-3);
position: relative;
}
.grid-preview img { width: 100%; height: 100%; object-fit: cover; }
.grid-preview .placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--ink-3);
}
.grid-card__body {
padding: 12px;
}
.grid-card__title {
color: var(--ink);
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.directory {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
overflow: hidden;
}
.directory-row {
display: grid;
grid-template-columns: minmax(180px, 1.4fr) minmax(150px, 1fr) minmax(120px, .8fr) 42px;
gap: 14px;
align-items: center;
border-bottom: 1px solid var(--rule);
padding: 13px 14px;
}
.directory-row:last-child { border-bottom: 0; }
.directory-row.header {
background: var(--paper-2);
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
}
.empty-state,
.loading-state,
.error-state {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
color: var(--ink-2);
display: grid;
gap: 12px;
justify-items: center;
padding: 44px 22px;
text-align: center;
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
linear-gradient(180deg, color-mix(in oklab, var(--brand) 8%, var(--paper-2)), var(--paper-2) 45%),
var(--paper-2);
}
.login-panel,
.modal-card {
width: min(100%, 420px);
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 22px;
}
.login-panel h1 {
font-size: 24px;
margin-bottom: 4px;
}
.login-panel .sub {
color: var(--ink-2);
margin: 0 0 18px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
}
.form-field input,
.form-field select,
.form-field textarea {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper-2);
min-height: 38px;
padding: 8px 10px;
}
.form-field textarea { min-height: 88px; resize: vertical; }
.alert {
border-radius: var(--radius-sm);
border: 1px solid var(--rule);
padding: 10px 12px;
}
.alert.error {
background: color-mix(in oklab, var(--bad) 10%, var(--paper));
border-color: color-mix(in oklab, var(--bad) 30%, var(--rule));
color: var(--bad);
}
.alert.info {
background: color-mix(in oklab, var(--brand) 10%, var(--paper));
border-color: color-mix(in oklab, var(--brand) 30%, var(--rule));
color: var(--brand-ink);
}
.bulk-bar {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 70;
transform: translateX(-50%) translateY(120%);
opacity: 0;
pointer-events: none;
background: var(--ink);
color: var(--paper);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
transition: transform .18s ease, opacity .18s ease;
}
.bulk-bar.visible {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.bulk-bar .count { background: color-mix(in oklab, white 14%, transparent); color: var(--paper); }
.bulk-bar button {
border: 1px solid color-mix(in oklab, white 18%, transparent);
border-radius: var(--radius-sm);
background: transparent;
color: var(--paper);
min-height: 30px;
padding: 6px 10px;
}
.drawer-backdrop,
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(15 23 42 / .35);
z-index: 80;
opacity: 0;
pointer-events: none;
transition: opacity .16s ease;
}
.drawer-backdrop.open,
.modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
right: 0;
z-index: 90;
width: min(540px, 100vw);
height: 100vh;
background: var(--paper);
border-left: 1px solid var(--rule);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform .18s ease;
}
.drawer.open { transform: translateX(0); }
.drawer__head {
border-bottom: 1px solid var(--rule);
display: grid;
grid-template-columns: 84px minmax(0, 1fr) 34px;
gap: 14px;
align-items: center;
padding: 16px;
}
.drawer__preview {
width: 84px;
height: 60px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-3);
object-fit: cover;
}
.user-drawer__avatar {
align-items: center;
color: var(--ink);
display: grid;
font-size: 22px;
font-weight: 700;
justify-items: center;
}
.drawer h2 {
font-size: 20px;
line-height: 1.15;
margin: 0 0 5px;
overflow-wrap: anywhere;
}
.drawer__tabs {
border-bottom: 1px solid var(--rule);
display: flex;
gap: 4px;
overflow-x: auto;
padding: 8px 14px 0;
}
.drawer__tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ink-2);
font-size: 13px;
padding: 9px 10px;
white-space: nowrap;
}
.drawer__tabs button.active {
border-bottom-color: var(--brand);
color: var(--ink);
}
.drawer__body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.drawer__body .logs-grid,
.drawer__body .stack-row {
grid-template-columns: 1fr;
}
.drawer__body .feature-bar {
align-items: flex-start;
flex-direction: column;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 18px;
}
.kv {
border: 1px solid var(--rule);
border-radius: var(--radius);
padding: 12px;
}
.kv .k {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.kv .v {
color: var(--ink);
font-size: 20px;
font-weight: 650;
margin-top: 4px;
overflow-wrap: anywhere;
}
.section-title {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
margin: 18px 0 8px;
text-transform: uppercase;
}
.activity {
border: 1px solid var(--rule);
border-radius: var(--radius);
overflow: hidden;
}
.activity__row {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
border-bottom: 1px solid var(--rule);
padding: 10px 12px;
}
.activity__row:last-child { border-bottom: 0; }
.activity__row span:nth-child(2) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity__row time {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
}
.drawer__cta {
border-top: 1px solid var(--rule);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 14px 16px;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 95;
display: none;
place-items: center;
padding: 18px;
}
.modal-shell.open { display: grid; }
.modal-card {
width: min(100%, 640px);
max-height: 88vh;
overflow-y: auto;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.modal-head h2 { margin: 0; font-size: 20px; }
.search-modal {
width: min(100%, 860px);
padding: 0;
overflow: hidden;
}
.search-modal__bar {
border-bottom: 1px solid var(--rule);
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 16px;
}
.search-modal__bar svg {
color: var(--ink-3);
}
.search-modal__bar input {
border: 0;
background: transparent;
color: var(--ink);
font-size: 19px;
outline: none;
width: 100%;
}
.search-modal__bar kbd {
border: 1px solid var(--rule);
border-radius: 5px;
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
padding: 4px 6px;
}
.search-modal__scopes {
border-bottom: 1px solid var(--rule);
display: flex;
gap: 6px;
overflow-x: auto;
padding: 10px 16px;
}
.search-modal__scopes button {
border: 1px solid var(--rule);
border-radius: 999px;
background: var(--paper);
color: var(--ink-2);
font-size: 12px;
min-height: 30px;
padding: 6px 10px;
white-space: nowrap;
}
.search-modal__scopes button.active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.search-modal__body {
max-height: min(66vh, 620px);
overflow: auto;
padding: 10px;
}
.search-group {
display: grid;
gap: 5px;
margin-bottom: 12px;
}
.search-group__title {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
padding: 4px 7px;
text-transform: uppercase;
}
.search-result {
border: 1px solid transparent;
border-radius: var(--radius);
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
color: inherit;
padding: 9px 10px;
text-decoration: none;
}
.search-result:hover,
.search-result.active {
background: var(--paper-2);
border-color: var(--rule);
}
.search-result__icon {
border: 1px solid var(--rule);
border-radius: 8px;
display: grid;
place-items: center;
height: 34px;
width: 34px;
color: var(--ink-2);
}
.search-result strong {
color: var(--ink);
display: block;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result small {
color: var(--ink-3);
display: block;
font-size: 12px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result__type {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.search-modal__foot {
border-top: 1px solid var(--rule);
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: 12px;
padding: 10px 16px;
}
.captures-modal {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: min(92vh, 900px);
overflow: hidden;
padding: 0;
width: min(100%, 1180px);
}
.captures-modal .modal-head {
border-bottom: 1px solid var(--rule);
margin: 0;
padding: 16px 18px;
}
.captures-content {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.captures-toolbar {
align-items: end;
border-bottom: 1px solid var(--rule);
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
padding: 12px 18px;
}
.captures-controls,
.captures-actions {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.captures-control {
display: grid;
gap: 4px;
min-width: 180px;
}
.captures-control label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.captures-control select,
.capture-page-row input,
.capture-auth-grid input {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
min-height: 34px;
padding: 7px 9px;
}
.captures-body {
background: var(--paper-2);
display: grid;
gap: 14px;
overflow: auto;
padding: 16px 18px 22px;
}
.capture-config-panel {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
gap: 12px;
padding: 14px;
}
.capture-pages-list {
display: grid;
gap: 8px;
}
.capture-page-row {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
}
.capture-auth-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.capture-auth-grid label {
color: var(--ink-3);
display: grid;
font-family: var(--mono);
font-size: 10px;
gap: 4px;
text-transform: uppercase;
}
.capture-viewer {
display: grid;
gap: 10px;
justify-items: center;
min-width: 0;
}
.capture-viewer__meta {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
overflow-wrap: anywhere;
text-align: center;
}
.capture-image-frame {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
box-shadow: var(--shadow);
max-width: 100%;
overflow: auto;
padding: 8px;
}
.capture-image-frame img {
display: block;
height: auto;
max-width: 100%;
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
z-index: 120;
transform: translateX(-50%) translateY(20px);
opacity: 0;
pointer-events: none;
border-radius: var(--radius);
background: var(--ink);
color: var(--paper);
box-shadow: var(--shadow);
padding: 11px 14px;
transition: opacity .16s ease, transform .16s ease;
}
.toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.detail-shell {
display: grid;
gap: 14px;
}
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.detail-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
border-bottom: 1px solid var(--rule);
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius) var(--radius) 0 0;
padding: 0 10px;
}
.detail-tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ink-2);
font-size: 13px;
min-height: 42px;
padding: 10px 11px 8px;
white-space: nowrap;
}
.detail-tabs button.active {
border-bottom-color: var(--brand);
color: var(--ink);
font-weight: 650;
}
.detail-panel {
background: var(--paper);
border: 1px solid var(--rule);
border-top: 0;
border-radius: 0 0 var(--radius) var(--radius);
padding: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
}
.detail-card {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
padding: 13px;
min-width: 0;
}
.detail-card .k {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.detail-card .v {
color: var(--ink);
font-size: 17px;
font-weight: 650;
margin-top: 5px;
overflow-wrap: anywhere;
}
.detail-card .sub {
color: var(--ink-3);
font-size: 12px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.site-detail-shell {
display: grid;
gap: 18px;
}
.site-crumb {
align-items: center;
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 11px;
gap: 8px;
}
.site-crumb button {
background: transparent;
border: 0;
color: var(--brand-ink);
font: inherit;
padding: 0;
}
.site-crumb__sep { opacity: .45; }
.site-hero {
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
gap: 18px;
grid-template-columns: 92px minmax(0, 1fr) auto;
padding: 18px;
}
.site-hero__thumb {
aspect-ratio: 4 / 3;
background: linear-gradient(135deg, var(--paper-3), var(--paper-2));
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
display: grid;
overflow: hidden;
place-items: center;
}
.site-hero__thumb img {
height: 100%;
object-fit: cover;
width: 100%;
}
.site-hero__thumb svg {
color: var(--ink-3);
height: 30px;
width: 30px;
}
.site-hero__main {
display: grid;
gap: 5px;
min-width: 0;
}
.site-hero__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
min-width: 0;
}
.site-hero h1 {
color: var(--ink);
font-size: 26px;
font-weight: 650;
line-height: 1.15;
margin: 0;
overflow-wrap: anywhere;
}
.site-hero__meta {
align-items: center;
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 12px;
gap: 7px;
overflow-wrap: anywhere;
}
.site-hero__meta svg {
flex: none;
height: 13px;
width: 13px;
}
.site-hero__meta button {
background: transparent;
border: 0;
color: inherit;
font: inherit;
padding: 0;
text-align: left;
}
.site-hero__meta button:hover { color: var(--brand-ink); }
.site-env-badge {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink-2);
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
padding: 4px 8px;
text-transform: uppercase;
}
.site-env-badge.production {
background: color-mix(in oklab, var(--brand) 13%, var(--paper));
border-color: color-mix(in oklab, var(--brand) 42%, var(--rule));
color: var(--brand-ink);
}
.site-env-badge.staging {
background: color-mix(in oklab, var(--warn) 14%, var(--paper));
border-color: color-mix(in oklab, var(--warn) 44%, var(--rule));
color: oklch(0.46 0.12 70);
}
.site-hero__actions,
.site-detail-toolbar,
.site-toolbar-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.site-action-btn {
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
color: var(--ink);
display: inline-flex;
font-size: 13px;
font-weight: 550;
gap: 7px;
min-height: 36px;
padding: 8px 12px;
}
.site-action-btn:hover {
border-color: color-mix(in oklab, var(--ink) 26%, var(--rule));
}
.site-action-btn.primary {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.site-action-btn.danger {
border-color: color-mix(in oklab, var(--bad) 32%, var(--rule));
color: var(--bad);
}
.site-action-btn svg {
flex: none;
height: 14px;
width: 14px;
}
.site-detail-toolbar {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
justify-content: space-between;
padding: 12px 14px;
}
.site-env-switch {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
display: inline-flex;
gap: 3px;
padding: 3px;
}
.site-env-switch button {
background: transparent;
border: 0;
border-radius: 5px;
color: var(--ink-2);
font-family: var(--mono);
font-size: 11px;
min-height: 30px;
padding: 6px 12px;
text-transform: uppercase;
}
.site-env-switch button.active {
background: var(--paper);
box-shadow: 0 1px 0 rgb(20 28 45 / .06);
color: var(--ink);
}
.site-detail-body {
align-items: start;
display: grid;
gap: 22px;
grid-template-columns: 208px minmax(0, 1fr);
}
.site-detail-tabs {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
gap: 2px;
padding: 8px;
position: sticky;
top: 82px;
}
.site-detail-tabs button {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--ink-2);
display: grid;
font-size: 13px;
font-weight: 550;
gap: 10px;
grid-template-columns: 16px minmax(0, 1fr) auto;
min-height: 38px;
padding: 9px 10px;
text-align: left;
width: 100%;
}
.site-detail-tabs button:hover {
background: var(--paper-2);
color: var(--ink);
}
.site-detail-tabs button.active {
background: color-mix(in oklab, var(--brand) 12%, var(--paper));
color: var(--brand-ink);
}
.site-detail-tabs svg {
height: 16px;
width: 16px;
}
.site-tab-count {
background: var(--paper-2);
border-radius: 4px;
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
padding: 2px 6px;
}
.site-detail-tabs button.active .site-tab-count {
background: color-mix(in oklab, var(--brand) 20%, var(--paper));
color: var(--brand-ink);
}
.site-detail-panel {
display: grid;
gap: 18px;
min-width: 0;
}
.site-section {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
padding: 22px;
}
.site-section__head {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 20px;
}
.site-section__sub,
.site-subhead {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
}
.site-section h2 {
font-size: 20px;
line-height: 1.2;
margin: 0;
}
.site-overview-grid {
display: grid;
gap: 28px;
grid-template-columns: minmax(0, 1fr) minmax(340px, .9fr);
}
.site-overview-col {
display: grid;
gap: 18px;
min-width: 0;
}
.site-preview-frame {
aspect-ratio: 16 / 10;
background: linear-gradient(145deg, var(--paper-3), var(--paper-2));
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
overflow: hidden;
place-items: center;
position: relative;
}
button.site-preview-frame {
color: inherit;
cursor: pointer;
padding: 0;
text-align: inherit;
width: 100%;
}
button.site-preview-frame:hover {
border-color: color-mix(in oklab, var(--brand) 38%, var(--rule));
}
.site-preview-frame img {
height: 100%;
object-fit: cover;
width: 100%;
}
.site-preview-frame__overlay {
align-items: center;
background: linear-gradient(180deg, transparent, rgb(15 23 42 / .72));
color: white;
display: flex;
font-size: 13px;
font-weight: 650;
gap: 7px;
inset: auto 0 0;
justify-content: space-between;
opacity: 0;
padding: 12px;
position: absolute;
transition: opacity .14s ease;
}
button.site-preview-frame:hover .site-preview-frame__overlay,
button.site-preview-frame:focus-visible .site-preview-frame__overlay {
opacity: 1;
}
.site-preview-frame__empty {
color: var(--ink-3);
display: grid;
gap: 8px;
justify-items: center;
padding: 24px;
text-align: center;
}
.site-preview-frame__empty svg {
height: 30px;
width: 30px;
}
.site-kv-list {
display: grid;
}
.site-kv {
align-items: center;
border-bottom: 1px solid var(--rule);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 11px 0;
}
.site-kv:last-child { border-bottom: 0; }
.site-kv__label {
color: var(--ink);
font-size: 13px;
font-weight: 550;
}
.site-kv__value {
color: var(--ink-3);
font-family: var(--mono);
font-size: 12px;
margin-top: 2px;
overflow-wrap: anywhere;
}
.site-icon-btn {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--ink-3);
display: inline-grid;
height: 30px;
justify-items: center;
place-items: center;
width: 30px;
}
.site-icon-btn:hover {
background: var(--paper-2);
color: var(--ink);
}
.site-icon-btn svg {
height: 14px;
width: 14px;
}
.site-secret-section {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
padding: 16px 18px;
}
.site-secret-section + .site-secret-section {
margin-top: 14px;
}
.site-secret-section__head {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 10px;
gap: 12px;
}
.site-secret-title {
align-items: center;
color: var(--ink);
display: flex;
font-size: 14px;
font-weight: 650;
gap: 8px;
}
.site-secret-title svg {
color: var(--ink-3);
height: 14px;
width: 14px;
}
.site-secret-toggle {
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
color: var(--ink-2);
display: inline-flex;
font-family: var(--mono);
font-size: 10px;
gap: 6px;
min-height: 28px;
padding: 5px 9px;
text-transform: uppercase;
}
.site-secret-row {
align-items: center;
border-top: 1px solid var(--rule);
display: grid;
gap: 12px;
grid-template-columns: 112px minmax(0, 1fr) auto;
padding: 8px 0;
}
.site-secret-row:first-of-type { border-top: 0; }
.site-secret-row__label {
color: var(--ink-2);
font-size: 12px;
}
.site-secret-row__value {
color: var(--ink);
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.site-secret-row__value.masked {
color: var(--ink-3);
letter-spacing: 0;
}
.site-shared-grid,
.site-action-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.site-shared-pill {
align-items: center;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: flex;
gap: 12px;
justify-content: space-between;
min-width: min(100%, 220px);
padding: 12px 14px;
}
.site-shared-pill strong {
display: block;
font-size: 14px;
}
.site-shared-pill span {
color: var(--ink-3);
display: block;
font-family: var(--mono);
font-size: 11px;
margin-top: 2px;
}
.feature-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.feature-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.feature-controls input,
.feature-controls select {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
min-height: 32px;
padding: 6px 9px;
}
.stats-chart {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
margin-top: 14px;
padding: 12px;
}
.stats-chart svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.stats-legend {
color: var(--ink-3);
display: flex;
gap: 12px;
font-size: 12px;
margin-top: 8px;
}
.stats-key {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stats-key::before {
content: "";
border-radius: 999px;
display: inline-block;
height: 7px;
width: 7px;
background: var(--brand);
}
.stats-key.pageviews::before { background: var(--accent); }
.stats-toolbar {
align-items: flex-end;
margin-bottom: 16px;
}
.stats-controls {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.stats-control {
display: grid;
gap: 4px;
min-width: 132px;
}
.stats-control label {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.stats-control select,
.stats-control input {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
min-height: 34px;
padding: 7px 9px;
}
.stats-timeframes {
align-items: center;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
display: inline-flex;
gap: 2px;
padding: 2px;
}
.stats-timeframes button {
border: 0;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--ink-2);
font-size: 12px;
font-weight: 650;
min-height: 30px;
padding: 5px 9px;
}
.stats-timeframes button.active {
background: var(--paper);
box-shadow: 0 1px 0 rgb(0 0 0 / 0.05);
color: var(--ink);
}
.stats-kpi-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 14px 0 8px;
}
.stats-kpi {
min-width: 0;
text-align: center;
}
.stats-kpi .k {
color: var(--ink-2);
font-size: 12px;
font-weight: 650;
text-transform: uppercase;
}
.stats-kpi .v {
color: var(--ink);
font-size: clamp(28px, 4vw, 52px);
font-weight: 250;
letter-spacing: 0;
line-height: 1.08;
margin-top: 8px;
overflow-wrap: anywhere;
}
.stats-chart {
border: 0;
background: transparent;
margin: 18px 0 8px;
padding: 0;
}
.stats-chart__legend {
align-items: center;
color: var(--ink-3);
display: flex;
gap: 16px;
font-size: 12px;
justify-content: center;
margin-bottom: 6px;
}
.stats-chart__plot {
position: relative;
}
.stats-chart svg {
display: block;
height: auto;
overflow: visible;
width: 100%;
}
.stats-axis-label {
fill: var(--ink-3);
font-size: 11px;
}
.stats-hover-point {
background: transparent;
border: 0;
bottom: 0;
cursor: crosshair;
padding: 0;
position: absolute;
top: 0;
}
.stats-hover-point::before {
background: var(--paper);
border: 2px solid var(--brand);
border-radius: 999px;
content: "";
height: 11px;
left: var(--point-left, 50%);
opacity: 0;
position: absolute;
top: var(--point-top, 50%);
transform: translate(-50%, -50%);
transition: opacity .12s ease;
width: 11px;
}
.stats-hover-point:hover::before,
.stats-hover-point:focus::before,
.stats-hover-point:focus-visible::before {
opacity: 1;
}
.stats-tooltip {
background: color-mix(in oklab, var(--ink) 92%, black);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
color: var(--paper);
display: grid;
font-size: 13px;
gap: 6px;
left: var(--point-left, 50%);
min-width: 190px;
opacity: 0;
padding: 10px 11px;
pointer-events: none;
position: absolute;
text-align: left;
top: var(--point-top, 50%);
transform: translateX(-50%) translateY(calc(-100% - 4px));
transition: opacity .12s ease, transform .12s ease;
z-index: 5;
}
.stats-tooltip strong {
color: var(--paper);
font-size: 14px;
}
.stats-tooltip span {
align-items: center;
display: flex;
gap: 7px;
white-space: nowrap;
}
.stats-tooltip span::before {
border: 2px solid currentColor;
content: "";
display: inline-block;
height: 10px;
width: 10px;
}
.stats-tooltip span.pageviews::before {
color: var(--ink-3);
}
.stats-tooltip span.visits::before {
color: var(--brand);
}
.stats-hover-point.edge-left .stats-tooltip {
transform: translateX(-12%) translateY(calc(-100% - 4px));
}
.stats-hover-point.edge-right .stats-tooltip {
left: auto;
right: calc(100% - var(--point-left, 50%));
transform: translateX(12%) translateY(calc(-100% - 4px));
}
.stats-hover-point:hover .stats-tooltip,
.stats-hover-point:focus .stats-tooltip,
.stats-hover-point:focus-visible .stats-tooltip {
opacity: 1;
transform: translateX(-50%) translateY(calc(-100% - 8px));
}
.stats-hover-point.edge-left:hover .stats-tooltip,
.stats-hover-point.edge-left:focus .stats-tooltip,
.stats-hover-point.edge-left:focus-visible .stats-tooltip {
transform: translateX(-12%) translateY(calc(-100% - 8px));
}
.stats-hover-point.edge-right:hover .stats-tooltip,
.stats-hover-point.edge-right:focus .stats-tooltip,
.stats-hover-point.edge-right:focus-visible .stats-tooltip {
transform: translateX(12%) translateY(calc(-100% - 8px));
}
.stats-breakdown-head {
align-items: baseline;
display: flex;
gap: 10px;
justify-content: space-between;
margin-top: 22px;
}
.stats-breakdown-head .section-title {
margin: 0 0 8px;
}
.stats-breakdown-head span {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
}
.stats-sharing {
border-top: 1px solid var(--rule);
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, .7fr);
margin-top: 24px;
padding-top: 20px;
}
.stats-sharing h3 {
font-size: 14px;
margin: 0 0 10px;
text-transform: uppercase;
}
.stats-sharing p {
color: var(--ink-2);
font-size: 14px;
line-height: 1.55;
margin: 0;
}
.stats-share-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.stats-share-chip {
align-items: center;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink-2);
display: inline-flex;
gap: 7px;
min-height: 34px;
padding: 7px 13px;
}
.stats-share-chip.active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.stats-share-panel {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
display: grid;
gap: 10px;
min-width: 0;
padding: 12px;
}
.stats-share-panel a {
color: var(--ink);
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.stats-share-password {
align-items: end;
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
}
.stats-share-password label {
color: var(--ink-3);
display: grid;
font-family: var(--mono);
font-size: 10px;
gap: 4px;
text-transform: uppercase;
}
.stats-share-password input {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
min-height: 34px;
padding: 7px 9px;
}
.stats-share-error {
color: var(--bad);
font-size: 12px;
}
.row-button-group {
display: flex;
gap: 6px;
justify-content: flex-end;
flex-wrap: wrap;
}
.row-button-group .secondary-btn,
.row-button-group .danger-btn {
min-height: 28px;
padding: 5px 8px;
}
.row-button-group select {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper-2);
color: var(--ink);
min-height: 28px;
padding: 4px 8px;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.admin-section {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px;
}
.admin-section + .admin-section { margin-top: 16px; }
.admin-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.admin-section__head h2 {
font-size: 15px;
margin: 0;
}
.admin-form {
display: grid;
gap: 14px;
}
.admin-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.checkbox-line {
align-items: center;
display: flex;
gap: 8px;
}
.inline-code {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 11px;
padding: 2px 5px;
}
.changed-files {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
display: grid;
gap: 6px;
margin: -3px 0 8px;
padding: 10px;
}
.changed-file {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 7px 9px;
}
.changed-file code {
color: var(--ink-2);
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.change-detail {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
display: grid;
gap: 12px;
margin: -3px 0 8px;
padding: 12px;
}
.change-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.change-summary__item {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
padding: 10px;
}
.change-summary__item span {
color: var(--ink-3);
display: block;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.change-summary__item strong {
color: var(--ink);
display: block;
font-size: 20px;
line-height: 1.2;
margin-top: 2px;
}
.change-component-list {
display: grid;
gap: 8px;
}
.change-component {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
display: grid;
gap: 8px;
padding: 10px;
}
.change-component__head {
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.change-component__title {
color: var(--ink);
font-weight: 700;
overflow-wrap: anywhere;
}
.change-component__meta {
color: var(--ink-3);
display: flex;
flex-wrap: wrap;
font-size: 12px;
gap: 7px;
margin-top: 3px;
}
.site-users-toolbar,
.site-logs-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
margin-bottom: 14px;
}
.site-users-filters,
.site-log-controls {
align-items: center;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 8px;
min-width: min(100%, 280px);
}
.site-users-filters .search-input,
.site-log-controls .search-input {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
max-width: 380px;
padding: 7px 10px;
width: min(100%, 380px);
}
.site-users-filters select,
.site-log-controls select,
.site-log-controls input {
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
min-height: 34px;
padding: 7px 9px;
}
.user-summary-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-bottom: 14px;
}
.user-pill {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
padding: 12px;
}
.user-pill span {
color: var(--ink-3);
display: block;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
}
.user-pill strong {
color: var(--ink);
display: block;
font-size: 22px;
margin-top: 4px;
}
.user-name-cell strong {
color: var(--ink);
display: block;
}
.user-name-cell small {
color: var(--ink-3);
display: block;
margin-top: 2px;
}
.logs-grid {
align-items: start;
display: grid;
gap: 16px;
grid-template-columns: minmax(240px, 360px) minmax(0, 1fr);
}
.log-file-list {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
display: grid;
gap: 4px;
max-height: 520px;
overflow: auto;
padding: 8px;
}
.log-file {
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-2);
display: grid;
gap: 2px;
padding: 9px 10px;
text-align: left;
width: 100%;
}
.log-file:hover,
.log-file.active {
background: var(--paper-2);
border-color: var(--rule);
color: var(--ink);
}
.log-file strong {
color: inherit;
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.log-file small {
color: var(--ink-3);
font-size: 11px;
}
.log-output {
background: oklch(0.18 0.016 255);
border: 1px solid oklch(0.26 0.018 255);
border-radius: var(--radius);
color: oklch(0.86 0.012 255);
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
margin: 0;
max-height: 620px;
min-height: 360px;
overflow: auto;
padding: 14px;
white-space: pre-wrap;
}
.log-panel {
align-content: start;
align-self: start;
display: grid;
gap: 12px;
grid-auto-rows: max-content;
min-width: 0;
}
.log-meta {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
}
.audit-list {
display: grid;
gap: 8px;
}
.audit-card {
align-items: center;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 12px;
}
.audit-card strong {
color: var(--ink);
display: block;
}
.audit-card small {
color: var(--ink-3);
display: block;
margin-top: 2px;
}
.diff-output {
max-height: 62vh;
}
.diff-line {
display: block;
min-height: 18px;
}
.diff-line.added { color: var(--ok); }
.diff-line.removed { color: var(--bad); }
.detail-table {
width: 100%;
border-collapse: collapse;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--radius);
overflow: hidden;
}
.detail-table th,
.detail-table td {
border-bottom: 1px solid var(--rule);
padding: 10px 12px;
text-align: left;
vertical-align: top;
}
.detail-table th {
background: var(--paper-2);
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
}
.detail-table th .sort-button {
width: 100%;
}
.detail-table tr:last-child td { border-bottom: 0; }
.detail-table td {
color: var(--ink-2);
font-size: 13px;
overflow-wrap: anywhere;
}
.env-switcher {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.env-switcher button {
border: 1px solid var(--rule);
border-radius: 999px;
background: var(--paper);
color: var(--ink-2);
font-family: var(--mono);
font-size: 11px;
min-height: 30px;
padding: 7px 11px;
text-transform: uppercase;
}
.env-switcher button.active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.code-block {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
color: var(--ink-2);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
margin: 0;
max-height: 360px;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
}
.inline-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.stack-list {
display: grid;
gap: 8px;
}
.stack-row {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-width: 0;
padding: 14px;
transition: background .14s ease, border-color .14s ease, box-shadow .14s ease, transform .14s ease;
}
.stack-row.clickable {
cursor: pointer;
}
.stack-row.clickable:hover {
background: var(--paper-2);
border-color: color-mix(in oklab, var(--ink) 22%, var(--rule));
box-shadow: 0 10px 24px rgb(15 23 42 / 0.07);
transform: translateY(-1px);
}
.stack-row.clickable:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.stack-row.expanded {
border-color: color-mix(in oklab, var(--brand) 36%, var(--rule));
box-shadow: 0 10px 22px rgb(15 23 42 / 0.06);
}
.stack-row__primary {
min-width: 0;
}
.stack-row__title {
align-items: center;
color: var(--ink);
display: flex;
gap: 8px;
min-width: 0;
overflow-wrap: anywhere;
}
.stack-row__title strong {
min-width: 0;
overflow-wrap: anywhere;
}
.stack-row small {
color: var(--ink-3);
display: block;
margin-top: 3px;
}
.stack-row__hint {
color: var(--ink-3);
font-family: var(--mono);
font-size: 10px;
margin-top: 8px;
text-transform: uppercase;
}
.timeline-markdown {
color: var(--ink);
overflow-wrap: anywhere;
}
.timeline-markdown p {
margin: 0;
}
.timeline-markdown p + p,
.timeline-markdown p + ul,
.timeline-markdown p + ol,
.timeline-markdown ul + p,
.timeline-markdown ol + p {
margin-top: 8px;
}
.timeline-markdown ul,
.timeline-markdown ol {
margin: 6px 0 0;
padding-left: 18px;
}
.timeline-markdown li + li {
margin-top: 3px;
}
.timeline-markdown a {
color: var(--brand-ink);
text-decoration: underline;
text-underline-offset: 2px;
}
.timeline-markdown code {
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: 4px;
color: var(--ink-2);
font-family: var(--mono);
font-size: .92em;
padding: 1px 4px;
}
.script-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.script-card {
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper);
color: var(--ink);
display: grid;
gap: 5px;
min-height: 92px;
padding: 13px;
text-align: left;
width: 100%;
}
.script-card:hover {
background: var(--paper-2);
border-color: color-mix(in oklab, var(--brand) 35%, var(--rule));
}
.script-card strong {
font-size: 14px;
overflow-wrap: anywhere;
}
.script-card small {
color: var(--ink-3);
line-height: 1.35;
}
.terminal-region {
position: fixed;
inset: auto 18px 18px auto;
z-index: 95;
pointer-events: none;
}
.terminal-island {
border: 1px solid var(--rule);
border-radius: 999px;
background: var(--paper);
box-shadow: var(--shadow);
color: var(--ink);
display: flex;
align-items: center;
gap: 10px;
min-width: min(420px, calc(100vw - 36px));
max-width: min(620px, calc(100vw - 36px));
padding: 9px 10px 9px 14px;
pointer-events: auto;
}
.terminal-island__status {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--ok);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--ok) 16%, transparent);
flex: 0 0 auto;
}
.terminal-island__status.running {
background: var(--warn);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--warn) 18%, transparent);
}
.terminal-island__text {
min-width: 0;
flex: 1;
}
.terminal-island__text strong,
.terminal-island__text small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-island__text small {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
}
.terminal-window {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 96;
width: min(1040px, calc(100vw - 36px));
height: min(720px, calc(100vh - 36px));
border: 1px solid color-mix(in oklab, white 12%, transparent);
border-radius: var(--radius);
background: oklch(0.17 0.018 245);
box-shadow: var(--shadow);
color: oklch(0.93 0.01 245);
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
pointer-events: auto;
}
.terminal-window.fullscreen {
inset: 12px;
width: auto;
height: auto;
}
.terminal-head {
background: oklch(0.22 0.018 245);
border-bottom: 1px solid color-mix(in oklab, white 10%, transparent);
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 9px 12px;
}
.terminal-dots {
display: inline-flex;
gap: 7px;
}
.terminal-dot {
width: 11px;
height: 11px;
border-radius: 999px;
border: 0;
padding: 0;
}
.terminal-dot.close { background: #ff5f56; }
.terminal-dot.hide { background: #ffbd2e; }
.terminal-dot.full { background: #27c93f; }
.terminal-title {
color: oklch(0.72 0.015 245);
font-family: var(--mono);
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: center;
}
.terminal-body {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
min-height: 0;
}
.terminal-sidebar {
background: oklch(0.20 0.018 245);
border-right: 1px solid color-mix(in oklab, white 10%, transparent);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px;
min-height: 0;
padding: 12px;
}
.terminal-search,
.terminal-command {
border: 1px solid color-mix(in oklab, white 12%, transparent);
border-radius: var(--radius-sm);
background: oklch(0.15 0.016 245);
color: oklch(0.92 0.012 245);
width: 100%;
}
.terminal-search {
min-height: 34px;
padding: 7px 9px;
}
.terminal-section-title {
color: oklch(0.67 0.014 245);
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
margin: 8px 0 6px;
}
.terminal-targets,
.terminal-tools {
overflow-y: auto;
min-height: 0;
}
.terminal-target,
.terminal-tool {
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: oklch(0.89 0.012 245);
display: grid;
gap: 2px;
margin-bottom: 4px;
padding: 8px;
text-align: left;
width: 100%;
}
.terminal-target.active,
.terminal-tool:hover,
.terminal-target:hover {
background: color-mix(in oklab, white 7%, transparent);
border-color: color-mix(in oklab, white 12%, transparent);
}
.terminal-target strong,
.terminal-tool strong {
font-size: 12px;
overflow-wrap: anywhere;
}
.terminal-target small,
.terminal-tool small {
color: oklch(0.62 0.014 245);
font-size: 11px;
overflow-wrap: anywhere;
}
.terminal-main {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
.terminal-output {
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
overflow-y: auto;
padding: 16px;
white-space: pre-wrap;
}
.terminal-job {
margin-bottom: 18px;
}
.terminal-job__head {
color: oklch(0.78 0.16 150);
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.terminal-job__head strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-job__head time {
color: oklch(0.62 0.014 245);
margin-left: auto;
white-space: nowrap;
}
.terminal-line {
color: oklch(0.78 0.012 245);
display: block;
overflow-wrap: anywhere;
}
.terminal-line.error { color: oklch(0.72 0.17 25); }
.terminal-input {
border-top: 1px solid color-mix(in oklab, white 10%, transparent);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
}
.terminal-prompt {
color: oklch(0.78 0.16 150);
font-family: var(--mono);
font-weight: 700;
padding-bottom: 8px;
}
.terminal-command {
font-family: var(--mono);
line-height: 1.45;
max-height: 150px;
min-height: 38px;
padding: 9px 10px;
resize: vertical;
}
.terminal-actions {
display: flex;
gap: 7px;
flex-wrap: wrap;
justify-content: flex-end;
}
.terminal-actions button,
.terminal-job__head button,
.terminal-head button:not(.terminal-dot) {
border: 1px solid color-mix(in oklab, white 12%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in oklab, white 7%, transparent);
color: oklch(0.92 0.012 245);
min-height: 30px;
padding: 6px 9px;
}
.terminal-actions button.primary {
background: oklch(0.62 0.14 235);
border-color: oklch(0.62 0.14 235);
color: white;
}
.terminal-empty {
color: oklch(0.58 0.014 245);
display: grid;
place-items: center;
height: 100%;
text-align: center;
}
@media (max-width: 1100px) {
.sites__head,
.site-row {
grid-template-columns: 32px minmax(220px, 1.7fr) 90px 90px 90px 112px;
}
.col-storage,
.col-backup { display: none; }
.site-detail-body,
.site-overview-grid {
grid-template-columns: 1fr;
}
.site-detail-tabs {
position: static;
display: flex;
overflow-x: auto;
}
.site-detail-tabs button {
min-width: 150px;
}
}
@media (max-width: 820px) {
:root { --gutter: 18px; }
.topbar__inner { gap: 12px; }
.app-nav {
order: 3;
width: 100%;
margin-left: 0;
overflow-x: auto;
padding-bottom: 8px;
}
.topbar__inner {
height: auto;
min-height: 64px;
flex-wrap: wrap;
padding-top: 12px;
}
.user-chip span,
.scope,
.summary__pills { display: none; }
.summary { align-items: flex-start; }
.summary__title {
display: grid;
gap: 5px;
}
.toolbar {
grid-template-columns: minmax(0, 1fr) auto;
}
.toolbar .primary-btn {
grid-column: 1 / -1;
width: 100%;
}
.sites__head { display: none; }
.site-group__bar { padding-left: 14px; }
.site-row {
grid-template-columns: 28px minmax(0, 1fr) 112px;
gap: 10px;
}
.col-wp,
.col-visits,
.col-storage,
.col-backup,
.status-cell { display: none; }
.row-actions { justify-content: flex-end; }
.sites__foot {
align-items: flex-start;
flex-direction: column;
}
.pagination-foot {
align-items: flex-start;
flex-direction: column;
}
.bulk-bar {
left: 12px;
right: 12px;
transform: translateY(120%);
flex-wrap: wrap;
}
.bulk-bar.visible { transform: translateY(0); }
.detail-toolbar {
align-items: stretch;
flex-direction: column;
}
.detail-actions > * { flex: 1; }
.site-hero {
align-items: start;
grid-template-columns: 72px minmax(0, 1fr);
}
.site-hero__actions {
grid-column: 1 / -1;
width: 100%;
}
.site-hero__actions > *,
.site-toolbar-actions > * {
flex: 1;
justify-content: center;
}
.site-detail-toolbar {
align-items: stretch;
flex-direction: column;
}
.site-env-switch {
overflow-x: auto;
}
.site-section {
padding: 18px;
}
.site-secret-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.site-secret-row__label {
grid-column: 1 / -1;
}
.stats-toolbar {
align-items: stretch;
flex-direction: column;
}
.stats-controls,
.stats-control,
.stats-timeframes {
width: 100%;
}
.stats-kpi-grid {
grid-template-columns: 1fr 1fr;
}
.stats-sharing {
grid-template-columns: 1fr;
}
.stats-kpi .v {
font-size: 34px;
}
.stats-share-password {
grid-template-columns: 1fr;
}
.captures-modal {
height: calc(100vh - 24px);
}
.captures-toolbar,
.captures-controls,
.captures-actions,
.captures-control {
align-items: stretch;
width: 100%;
}
.captures-actions > * {
flex: 1;
justify-content: center;
}
.capture-auth-grid,
.logs-grid {
grid-template-columns: 1fr;
}
.audit-card {
grid-template-columns: 1fr;
}
.stack-row { grid-template-columns: 1fr; }
.terminal-window {
inset: 8px;
width: auto;
height: auto;
}
.terminal-body {
grid-template-columns: 1fr;
grid-template-rows: 220px minmax(0, 1fr);
}
.terminal-sidebar {
border-right: 0;
border-bottom: 1px solid color-mix(in oklab, white 10%, transparent);
}
.terminal-input {
grid-template-columns: auto minmax(0, 1fr);
}
.terminal-actions {
grid-column: 1 / -1;
}
}
</style>
</head>
<body>
<div id="cc-v2-app" class="cc-v2" v-cloak>
<header class="app-topbar">
<div class="topbar__inner">
<a class="brand" href="#" data-route="/sites" aria-label="CaptainCore Manager">
<img class="brand__logo" data-brand-logo alt="" hidden>
<span class="brand__name" data-brand-name><?php echo esc_html( $configurations->name ); ?></span>
</a>
<nav class="app-nav" data-region="nav"></nav>
<div class="app-tools">
<button class="icon-btn" type="button" data-action="focus-search" title="Search" aria-label="Search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<button class="icon-btn" type="button" data-action="toggle-theme" title="Theme" aria-label="Theme">
<svg data-theme-icon="sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
</button>
<div class="account-menu chip-wrap">
<button class="user-chip" type="button" data-action="toggle-popover" data-popover="account-menu" aria-haspopup="menu" aria-expanded="false">
<div class="avatar" data-user-avatar></div>
<span data-user-name></span>
</button>
<div class="account-dropdown" data-region="account-menu" role="menu"></div>
</div>
</div>
</div>
</header>
<main id="cc-v2-main" class="app-shell"></main>
<div class="bulk-bar" data-region="bulk"></div>
<div class="drawer-backdrop" data-action="close-drawer"></div>
<aside class="drawer" data-region="drawer" aria-hidden="true"></aside>
<div class="modal-backdrop" data-action="close-modal"></div>
<div class="modal-shell" data-region="modal"></div>
<div class="toast" data-region="toast"></div>
<div class="terminal-region" data-region="terminal"></div>
</div>
<script>
window.__CAPTAINCORE_V2__ = {
configurations: <?php echo wp_json_encode( $configurations, $json_options ); ?>,
colors: <?php echo wp_json_encode( $colors, $json_options ); ?>,
modules: <?php echo wp_json_encode( $modules, $json_options ); ?>,
user: {
id: <?php echo (int) get_current_user_id(); ?>,
email: <?php echo wp_json_encode( $user->email, $json_options ); ?>,
login: <?php echo wp_json_encode( $user->login, $json_options ); ?>,
registered: <?php echo wp_json_encode( $user->registered, $json_options ); ?>,
hash: <?php echo wp_json_encode( $user->hash, $json_options ); ?>,
display_name: <?php echo wp_json_encode( $user->display_name, $json_options ); ?>,
first_name: <?php echo wp_json_encode( $user->first_name, $json_options ); ?>,
last_name: <?php echo wp_json_encode( $user->last_name, $json_options ); ?>,
role: <?php echo wp_json_encode( $user->role, $json_options ); ?>,
},
footer: <?php echo wp_json_encode( $footer, $json_options ); ?>,
socket: <?php echo wp_json_encode( $socket, $json_options ); ?>,
home_link: <?php echo wp_json_encode( home_url(), $json_options ); ?>,
plugin_url: <?php echo wp_json_encode( $plugin_url, $json_options ); ?>,
remote_upload_uri: <?php echo wp_json_encode( get_option( 'options_remote_upload_uri' ), $json_options ); ?>,
site_filters: <?php echo wp_json_encode( $site_filters, $json_options ); ?>,
site_filters_core: <?php echo wp_json_encode( $site_filters_core, $json_options ); ?>,
rest_root: <?php echo wp_json_encode( esc_url_raw( rest_url() ), $json_options ); ?>,
wp_nonce: ( typeof wpApiSettings !== 'undefined' && wpApiSettings.nonce ) ? wpApiSettings.nonce : '<?php echo is_user_logged_in() ? esc_js( wp_create_nonce( 'wp_rest' ) ) : ''; ?>',
};
(function () {
'use strict';
const CC = window.__CAPTAINCORE_V2__ || {};
const app = document.getElementById('cc-v2-app');
const main = document.getElementById('cc-v2-main');
const navRegion = document.querySelector('[data-region="nav"]');
const accountMenuRegion = document.querySelector('[data-region="account-menu"]');
const bulkRegion = document.querySelector('[data-region="bulk"]');
const drawerRegion = document.querySelector('[data-region="drawer"]');
const modalRegion = document.querySelector('[data-region="modal"]');
const toastRegion = document.querySelector('[data-region="toast"]');
const terminalRegion = document.querySelector('[data-region="terminal"]');
const drawerBackdrop = document.querySelector('.drawer-backdrop');
const modalBackdrop = document.querySelector('.modal-backdrop');
app.removeAttribute('v-cloak');
const icons = {
search: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>',
list: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
grid: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
plus: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
external: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
more: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="6" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="18" r="1"/></svg>',
monitor: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/><path d="M12 16v4"/></svg>',
login: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>',
close: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
back: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>',
globe: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>',
users: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
credit: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>',
terminal: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
book: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4v15.5A2.5 2.5 0 0 1 6.5 22H20V6a2 2 0 0 0-2-2H6.5A2.5 2.5 0 0 0 4 6.5"/></svg>',
save: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
clock: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
info: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
chart: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
file: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
plug: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v6"/><path d="M15 4v6"/><path d="M12 14v8"/><path d="M5 10h14"/><path d="M6 10v2a6 6 0 0 0 12 0v-2"/></svg>',
database: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/></svg>',
lock: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
eye: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
image: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
refresh: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 1-15.36 6.36L3 16"/><path d="M3 12a9 9 0 0 1 15.36-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M3 21v-5h5"/></svg>',
settings: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.3.31.48.73.51 1.17H21a2 2 0 0 1 0 4h-.09c-.03.44-.21.86-.51 1.17z"/></svg>',
};
const state = {
nonce: CC.wp_nonce || '',
route: routeFromLocation(),
viewMode: localStorage.getItem('captaincore-v2-view') || 'list',
theme: localStorage.getItem('captaincore-v2-theme') || 'light',
search: '',
envFilter: 'all',
popover: '',
filterSearch: { core: '', themes: '', plugins: '' },
selectedCore: [],
selectedThemes: [],
selectedPlugins: [],
filterLogic: 'and',
filterVersionLogic: 'and',
filterStatusLogic: 'and',
filterVersionMode: 'include',
filterStatusMode: 'include',
siteFilterVersions: [],
siteFilterStatuses: [],
filterOptionsLoading: false,
filterOptionsError: '',
filterRequestId: 0,
filteredSiteIds: null,
filteredEnvIds: null,
sites: [],
domains: [],
accounts: [],
subscriptions: [],
providers: [],
keys: [],
users: [],
processes: [],
archives: [],
scheduledReports: [],
siteAudits: [],
siteDetails: {},
domainDetails: {},
accountDetails: {},
userDetails: {},
billingDetails: null,
defaults: null,
configurationDetails: null,
activityLogs: { items: [], total: 0, page: 1, perPage: 50, filters: { action: '', entity_type: '', date_from: '', date_to: '' } },
security: { tab: 'vulnerabilities', threats: [], patches: [], coverage: null, webRiskLogs: [], checksumFailures: [], pluginChecksumFailures: [] },
invite: { account: '', token: '', details: null, password: '', loading: false, error: '', accepting: false },
siteFeatures: {},
detailTabs: { site: 'overview', domain: 'dns', account: 'overview', configurations: 'branding' },
detailEnvironments: {},
siteStats: { from_at: dateOffsetIso(-365), to_at: dateOffsetIso(0), grouping: 'Month', fathom_id: '', timeframe: '12m', sharePassword: '', shareSaving: false, shareError: '' },
userSearch: '',
userRoleFilter: 'all',
logSearch: '',
quicksaveSearch: '',
loading: {},
error: {},
fetched: {},
sorts: {},
selection: new Set(),
drawer: null,
drawerTab: 'overview',
modal: null,
captures: {
siteId: '',
envId: '',
envName: '',
siteName: '',
homeUrl: '',
captures: [],
selectedCaptureId: '',
selectedPageKey: '',
pages: [{ page: '/' }],
auth: { username: '', password: '' },
loading: false,
saving: false,
requesting: false,
error: '',
showConfig: false,
},
globalSearch: { query: '', scope: 'all', activeIndex: 0 },
fileDiff: { hash: '', file: '', content: '', loading: false, error: '' },
actionLoading: {},
revealedSecrets: {},
jobs: [],
recipes: [],
terminal: {
open: false,
show: false,
fullscreen: localStorage.getItem('captaincore-v2-terminal-fullscreen') === 'true',
command: '',
selectedTargets: [],
targetSearch: '',
toolSearch: '',
toolTab: 'system',
saveRecipe: { title: '', public: 1, loading: false, error: '' },
schedule: { date: '', time: '', loading: false, error: '' },
},
systemTools: [
{ key: 'sync-data', label: 'Manual Sync Details', description: 'Refresh environment metadata.' },
{ key: 'deploy-defaults', label: 'Deploy Defaults', description: 'Apply account defaults and recipes.' },
{ key: 'apply-https', label: 'Apply HTTPS URLs', description: 'Replace http:// references.' },
{ key: 'backup', label: 'Run Backup', description: 'Create a fresh backup.' },
{ key: 'snapshot', label: 'Generate Snapshot', description: 'Create a restorable snapshot.' },
{ key: 'scan-errors', label: 'Scan Errors', description: 'Collect recent site errors.' },
],
login: { user_login: '', user_password: '', tfa_code: '', lost: false, errors: '', info: '', loading: false },
newSite: { name: '', domain: '', site: '', address: '', username: '', password: '', loading: false, errors: '' },
newDomain: { name: '', account_id: '', site_id: '', create_dns_zone: true, loading: false, errors: [] },
newAccount: { name: '', loading: false, error: '' },
recipeForm: { mode: 'create', recipeId: '', title: '', content: '', public: true, loading: false, error: '' },
providerForm: { mode: 'create', providerId: '', name: '', provider: '', credentials: '', loading: false, error: '' },
providerImport: { providerId: '', accountId: '', remoteSites: [], selected: new Set(), loading: false, importing: false, error: '' },
keyForm: { mode: 'create', keyId: '', title: '', key: '', loading: false, error: '' },
userForm: { mode: 'create', userId: '', first_name: '', last_name: '', email: '', login: '', account_ids: [], loading: false, errors: [] },
processForm: { mode: 'create', processId: '', name: '', time_estimate: '', repeat_interval: 'as-needed', repeat_quantity: '', roles: '', description: '', loading: false, error: '' },
processView: { processId: '', process: null, loading: false, error: '' },
archiveStoreForm: { url: '', loading: false, error: '' },
reportForm: { mode: 'create', reportId: '', recipient: '', interval: 'monthly', account_id: '', site_ids: '', loading: false, error: '' },
siteAuditRequest: { site_id: '', environment_id: '', report_type: 'security_audit', notes: '', environments: [], loading: false, submitting: false, error: '' },
profileForm: { display_name: CC.user?.display_name || '', email: CC.user?.email || '', new_password: '', loading: false, errors: [], success: '', sessions: [], sessionsLoading: false },
domainRecordForm: { mode: 'create', domainId: '', recordId: '', type: 'A', name: '', value: '', ttl: '3600', loading: false, error: '' },
accountForm: { accountId: '', name: '', billing_user_id: '', loading: false, error: '' },
accountInviteForm: { accountId: '', email: '', level: 'full', loading: false, error: '' },
page: 1,
pages: {},
perPage: 100,
};
document.documentElement.dataset.theme = state.theme;
function basePath() {
let path = (CC.configurations && CC.configurations.path) ? CC.configurations.path : '/account/';
if (!path.startsWith('/')) path = '/' + path;
if (!path.endsWith('/')) path += '/';
return path;
}
function routeFromLocation() {
const base = basePath();
const baseRoot = base.replace(/\/+$/, '');
const params = new URLSearchParams(window.location.search);
if (params.get('account') && params.get('token')) return '/invite';
let path = window.location.pathname;
if (base !== '/' && (path === baseRoot || path === base)) {
path = '';
} else if (base !== '/' && path.startsWith(base)) {
path = path.slice(base.length);
} else {
path = path.replace(/^\/+/, '');
}
path = path.replace(/\/+$/, '');
return path ? '/' + path : '/sites';
}
function routeUrl(route) {
const clean = route.replace(/^\/+/, '');
return basePath() + clean + '?ui=v2';
}
function navTo(route) {
state.route = route || '/sites';
state.popover = '';
state.page = 1;
window.history.pushState({}, '', routeUrl(state.route));
loadForRoute();
render();
}
function esc(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function attr(value) {
return esc(value).replace(/`/g, '&#096;');
}
function safeMarkdownUrl(value) {
const url = String(value || '').trim().replace(/[\u0000-\u001f\u007f]/g, '');
if (/^(https?:|mailto:)/i.test(url)) return url;
return '';
}
function renderInlineMarkdown(value) {
const tokens = [];
const stash = html => {
const token = '\uE000MD' + tokens.length + '\uE001';
tokens.push(html);
return token;
};
let source = String(value == null ? '' : value);
source = source.replace(/`([^`\n]+)`/g, (match, code) => stash('<code>' + esc(code) + '</code>'));
source = source.replace(/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g, (match, label, url) => {
const safeUrl = safeMarkdownUrl(url);
if (!safeUrl) return label;
const target = /^https?:/i.test(safeUrl) ? ' target="_blank" rel="noopener noreferrer"' : '';
return stash('<a href="' + attr(safeUrl) + '"' + target + '>' + esc(label) + '</a>');
});
let html = esc(source);
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(^|[\s([{])__([^_\n]+)__(?=$|[\s)\].,!?;:}])/g, '$1<strong>$2</strong>');
html = html.replace(/(^|[\s([{])\*([^*\n]+)\*(?=$|[\s)\].,!?;:}])/g, '$1<em>$2</em>');
html = html.replace(/(^|[\s([{])_([^_\n]+)_(?=$|[\s)\].,!?;:}])/g, '$1<em>$2</em>');
return html.replace(/\uE000MD(\d+)\uE001/g, (match, index) => tokens[Number(index)] || '');
}
function renderMarkdown(value) {
const source = String(value == null ? '' : value).replace(/\r\n?/g, '\n').trim();
if (!source) return '';
const lines = source.split('\n');
const html = [];
let paragraph = [];
let list = null;
const flushParagraph = () => {
if (!paragraph.length) return;
html.push('<p>' + renderInlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
paragraph = [];
};
const flushList = () => {
if (!list) return;
html.push('<' + list.type + '>' + list.items.map(item => '<li>' + renderInlineMarkdown(item) + '</li>').join('') + '</' + list.type + '>');
list = null;
};
lines.forEach(line => {
if (!line.trim()) {
flushParagraph();
flushList();
return;
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
if (unordered || ordered) {
flushParagraph();
const type = ordered ? 'ol' : 'ul';
if (list && list.type !== type) flushList();
if (!list) list = { type, items: [] };
list.items.push((unordered || ordered)[1]);
return;
}
flushList();
paragraph.push(line);
});
flushParagraph();
flushList();
return html.join('');
}
function sanitizeRenderedHtml(value) {
const template = document.createElement('template');
template.innerHTML = String(value == null ? '' : value);
const allowedTags = new Set(['A', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'EM', 'I', 'LI', 'OL', 'P', 'PRE', 'STRONG', 'UL']);
const elements = [];
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) elements.push(walker.currentNode);
elements.forEach(element => {
if (!allowedTags.has(element.tagName)) {
element.replaceWith(document.createTextNode(element.textContent || ''));
return;
}
Array.from(element.attributes).forEach(attribute => {
const name = attribute.name.toLowerCase();
if (element.tagName === 'A' && name === 'href') {
const safeUrl = safeMarkdownUrl(attribute.value);
if (safeUrl) {
element.setAttribute('href', safeUrl);
if (/^https?:/i.test(safeUrl)) {
element.setAttribute('target', '_blank');
element.setAttribute('rel', 'noopener noreferrer');
}
} else {
element.removeAttribute(attribute.name);
}
return;
}
element.removeAttribute(attribute.name);
});
});
return template.innerHTML;
}
function renderTimelineMarkdown(value) {
const source = String(value == null ? '' : value).replace(/\r\n?/g, '\n').trim();
if (!source) return '';
if (/<\/?[a-z][\s\S]*>/i.test(source)) return sanitizeRenderedHtml(source);
return renderMarkdown(source);
}
function normalizeText(value) {
return String(value == null ? '' : value).toLowerCase();
}
function initials(name) {
const source = String(name || CC.user?.display_name || CC.user?.login || 'CC').trim();
return source.split(/\s+/).slice(0, 2).map(part => part.charAt(0).toUpperCase()).join('') || 'CC';
}
function formatNumber(value) {
if (value == null || value === '' || Number.isNaN(Number(value))) return '-';
return Number(value).toLocaleString();
}
function formatCompactNumber(value) {
if (value == null || value === '' || Number.isNaN(Number(value))) return '-';
return new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value)).toUpperCase();
}
function formatStorage(bytes) {
const n = Number(bytes);
if (!n || Number.isNaN(n)) return '-';
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(0) + ' MB';
return n + ' B';
}
function formatUrl(url) {
return String(url || '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '-';
}
function safeOpen(url) {
if (typeof url !== 'string') return;
const trimmed = url.trim();
if (/^https?:\/\//i.test(trimmed)) window.open(trimmed, '_blank', 'noopener');
}
function cleanAssetUrl(url) {
return String(url || '').replace(/([^:]\/)\/+/g, '$1');
}
function dateOffsetIso(days) {
const date = new Date();
date.setDate(date.getDate() + Number(days || 0));
return date.toISOString().slice(0, 10);
}
function routeId(type) {
const parts = state.route.split('/').filter(Boolean);
return parts[0] === type ? (parts[1] || '') : '';
}
function detailKey(type, id) {
return type + 'Detail:' + id;
}
function firstDefined() {
for (let i = 0; i < arguments.length; i++) {
if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '') return arguments[i];
}
return '';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (!value || value === 'Loading') return [];
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
}
function asObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return value;
}
function formatMoney(value) {
if (value == null || value === '' || Number.isNaN(Number(value))) return '-';
return '$' + Number(value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatDate(value) {
if (!value) return '-';
const numeric = Number(value);
const date = Number.isFinite(numeric) && String(value).length <= 10 ? new Date(numeric * 1000) : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function formatTime(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) return '-';
const minutes = Math.floor(seconds / 60);
const rest = Math.round(seconds % 60);
if (minutes <= 0) return rest + 's';
return minutes + 'm ' + rest + 's';
}
function formatPercent(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '-';
const pct = n <= 1 ? n * 100 : n;
return pct.toFixed(1) + '%';
}
function statusBadge(value) {
const text = value == null || value === '' ? '-' : String(value);
const normalized = text.toLowerCase();
const positive = ['active', 'valid', 'healthy', 'paid', 'success', 'primary', 'dns active'].includes(normalized);
const klass = positive ? 'production' : '';
return '<span class="badge ' + klass + '">' + esc(text) + '</span>';
}
function recordValue(record) {
const value = firstDefined(record?.value, record?.record_value, record?.content, record?.data);
if (Array.isArray(value)) {
return value.map(item => {
if (item == null) return '';
if (typeof item !== 'object') return String(item);
if (item.server) return [item.priority, item.server].filter(Boolean).join(' ');
if (item.host) return [item.priority, item.weight, item.port, item.host].filter(Boolean).join(' ');
return item.value || item.url || JSON.stringify(item);
}).filter(Boolean).join(', ');
}
if (value && typeof value === 'object') return value.url || value.value || JSON.stringify(value);
return String(value == null ? '' : value);
}
function dnsRecordId(record) {
return firstDefined(record?.id, record?.record_id, record?.recordId, '');
}
function dnsRecordType(record) {
return String(firstDefined(record?.type, record?.record_type, 'A')).toUpperCase();
}
function dnsRecordName(record) {
return firstDefined(record?.name, record?.record_name, '');
}
function dnsRecordTtl(record) {
return firstDefined(record?.ttl, record?.record_ttl, 3600);
}
function dnsValueToLines(type, value) {
const normalized = String(type || '').toLowerCase();
if (Array.isArray(value)) {
return value.map(item => {
if (typeof item !== 'object' || !item) return String(item || '');
if (normalized === 'mx') return [item.priority, item.server].filter(Boolean).join(' ');
if (normalized === 'srv') return [item.priority, item.weight, item.port, item.host].filter(Boolean).join(' ');
return firstDefined(item.value, item.server, item.host, JSON.stringify(item));
}).filter(Boolean).join('\n');
}
if (value && typeof value === 'object') {
if (normalized === 'http') return value.url || '';
return JSON.stringify(value);
}
return String(value == null ? '' : value);
}
function dnsRecordFormValue(record) {
const type = dnsRecordType(record);
const value = firstDefined(record?.value, record?.record_value, record?.content, record?.data, '');
return dnsValueToLines(type, value);
}
function dnsValueHelp(type) {
const normalized = String(type || '').toLowerCase();
if (normalized === 'mx') return 'One record per line: priority server, for example "10 mail.example.com."';
if (normalized === 'srv') return 'One record per line: priority weight port host.';
if (normalized === 'http') return 'Enter the redirect URL.';
return 'One value per line.';
}
function parseDnsRecordValue(type, value) {
const normalized = String(type || '').toLowerCase();
const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (normalized === 'mx') {
return lines.map(line => {
const parts = line.split(/\s+/);
const priority = parts.shift() || '10';
return { priority, server: parts.join(' '), enabled: true };
});
}
if (normalized === 'srv') {
return lines.map(line => {
const parts = line.split(/\s+/);
return { priority: parts[0] || '10', weight: parts[1] || '10', port: parts[2] || '443', host: parts.slice(3).join(' '), enabled: true };
});
}
if (normalized === 'http') return String(value || '').trim();
return lines.map(line => ({ value: line, enabled: true }));
}
function normalizeErrors(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map(item => String(item || '')).filter(Boolean);
if (value.errors) return normalizeErrors(value.errors);
if (value.message) return [String(value.message)];
if (typeof value === 'object') return Object.values(value).flatMap(normalizeErrors);
return [String(value)];
}
function renderErrorList(value) {
const errors = normalizeErrors(value);
return errors.map(error => '<div class="alert error">' + esc(error) + '</div>').join('');
}
function isAdministrator() {
return CC.user?.role === 'administrator';
}
function accountAccessLevel(data) {
return firstDefined(data?.level, data?.account?.level, '');
}
function accountCanInvite(data) {
return isAdministrator() || ['full-billing', 'full'].includes(accountAccessLevel(data));
}
function accountCanManageUsers(data) {
return isAdministrator() || accountAccessLevel(data) === 'full-billing';
}
function accountCanEditSettings(data) {
return isAdministrator() || accountAccessLevel(data) === 'full-billing';
}
function accountUserId(user) {
return firstDefined(user?.user_id, user?.id, user?.ID, '');
}
function accountBillingUserId(account) {
return firstDefined(account?.billing_user_id, account?.plan?.billing_user_id, account?.plan?.billing_user?.user_id, account?.plan?.billing_user?.id, '');
}
function levelLabel(level) {
return ({
'full-billing': 'Owner',
full: 'Full',
'sites-only': 'Sites Only',
'domains-only': 'Domains Only',
})[level] || level || '-';
}
function accessLevelOptions(selected) {
return [
['full', 'Full'],
['sites-only', 'Sites Only'],
['domains-only', 'Domains Only'],
].map(([value, label]) => '<option value="' + attr(value) + '"' + (String(selected || '') === value ? ' selected' : '') + '>' + esc(label) + '</option>').join('');
}
function hasOwnerAccess() {
return isAdministrator() || CC.user?.role === 'owner';
}
function adminNavItems() {
return [
['/cookbook', 'Cookbook', true],
['/reports', 'Reports', true],
['/site-audits', 'Site Audits', true],
['/activity-logs', 'Activity Logs', isAdministrator()],
['/archives', 'Archives', hasOwnerAccess()],
['/configurations', 'Configurations', hasOwnerAccess()],
['/providers', 'Providers', hasOwnerAccess()],
['/defaults', 'Defaults', hasOwnerAccess()],
['/security', 'Security', isAdministrator()],
['/handbook', 'Handbook', hasOwnerAccess()],
['/keys', 'Keys', hasOwnerAccess()],
['/users', 'Users', hasOwnerAccess()],
].filter(item => item[2]);
}
function accountNavItems() {
return [
['/profile', 'Profile', true],
].filter(item => item[2]);
}
function renderDetailTabs(type, tabs) {
if (!tabs.length) return '';
const active = tabs.some(tab => tab.key === state.detailTabs[type]) ? state.detailTabs[type] : tabs[0].key;
state.detailTabs[type] = active;
return `
<nav class="detail-tabs" aria-label="${attr(type)} sections">
${tabs.map(tab => '<button type="button" class="' + (tab.key === active ? 'active' : '') + '" data-action="detail-tab" data-detail-type="' + attr(type) + '" data-tab="' + attr(tab.key) + '">' + esc(tab.label) + '</button>').join('')}
</nav>
`;
}
const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const sortScratch = document.createElement('div');
function sortText(value) {
if (value == null) return '';
sortScratch.innerHTML = String(value);
return (sortScratch.textContent || sortScratch.innerText || '').replace(/\s+/g, ' ').trim();
}
function parseSortNumber(value) {
const text = sortText(value).replace(/,/g, '').replace(/[$%]/g, '').trim();
if (!/^[-+]?(?:\d+|\d*\.\d+)$/.test(text)) return null;
const number = Number(text);
return Number.isFinite(number) ? number : null;
}
function parseSortStorage(value) {
const match = sortText(value).match(/^([-+]?[\d,.]+)\s*(B|KB|MB|GB|TB)$/i);
if (!match) return null;
const number = Number(match[1].replace(/,/g, ''));
if (!Number.isFinite(number)) return null;
const units = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
return number * units[match[2].toUpperCase()];
}
function parseSortDate(value) {
const text = sortText(value);
if (!/\d{4}-\d{1,2}-\d{1,2}|[a-z]{3,}/i.test(text)) return null;
const time = Date.parse(text);
return Number.isFinite(time) ? time : null;
}
function compareSortValues(a, b, type = 'auto') {
const aText = sortText(a);
const bText = sortText(b);
const aEmpty = !aText || aText === '-';
const bEmpty = !bText || bText === '-';
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const strategies = type === 'auto' ? ['storage', 'number', 'date', 'text'] : [type, 'text'];
for (const strategy of strategies) {
let aValue = aText;
let bValue = bText;
if (strategy === 'number') {
aValue = parseSortNumber(aText);
bValue = parseSortNumber(bText);
} else if (strategy === 'storage') {
aValue = parseSortStorage(aText);
bValue = parseSortStorage(bText);
} else if (strategy === 'date') {
aValue = parseSortDate(aText);
bValue = parseSortDate(bText);
}
if (strategy !== 'text' && strategy !== 'version' && aValue !== null && bValue !== null) {
return aValue === bValue ? 0 : (aValue > bValue ? 1 : -1);
}
if (strategy === 'text' || strategy === 'version') {
return sortCollator.compare(aText, bText);
}
}
return sortCollator.compare(aText, bText);
}
function currentSort(pageKey) {
return pageKey ? state.sorts[pageKey] : null;
}
function renderSortButton(pageKey, sortKey, label, initialDir = 'asc') {
const activeSort = currentSort(pageKey);
const active = activeSort && activeSort.key === sortKey;
const direction = active ? activeSort.dir : initialDir;
const icon = active ? (direction === 'asc' ? '^' : 'v') : '-';
const aria = active ? (direction === 'asc' ? 'ascending' : 'descending') : 'none';
return '<button class="sort-button ' + (active ? 'active' : '') + '" type="button" data-action="set-sort" data-page-key="' + attr(pageKey) + '" data-sort-key="' + attr(sortKey) + '" data-sort-initial-dir="' + attr(initialDir) + '" aria-sort="' + aria + '">' + esc(label) + '<span class="sort-button__icon" aria-hidden="true">' + icon + '</span></button>';
}
function setSort(pageKey, sortKey, initialDir = 'asc') {
if (!pageKey || !sortKey) return;
const current = currentSort(pageKey);
const nextDir = current && current.key === sortKey ? (current.dir === 'asc' ? 'desc' : 'asc') : initialDir;
state.sorts[pageKey] = { key: sortKey, dir: nextDir };
if (pageKey === 'sites') state.page = 1;
else state.pages[pageKey] = 1;
render();
}
function sortRows(pageKey, rows, sorters) {
const activeSort = currentSort(pageKey);
if (!activeSort || !sorters || !sorters[activeSort.key]) return rows;
const sorter = sorters[activeSort.key];
const direction = activeSort.dir === 'desc' ? -1 : 1;
return rows.slice().map((row, index) => ({ row, index })).sort((a, b) => {
const result = compareSortValues(sorter.get(a.row), sorter.get(b.row), sorter.type || 'auto');
return result ? result * direction : a.index - b.index;
}).map(item => item.row);
}
function paginateRows(pageKey, rows, perPage) {
const total = rows.length;
const pages = Math.max(1, Math.ceil(total / perPage));
let page = Number(state.pages[pageKey] || 1);
if (!Number.isFinite(page) || page < 1) page = 1;
if (page > pages) page = pages;
state.pages[pageKey] = page;
const start = (page - 1) * perPage;
const end = Math.min(start + perPage, total);
return { rows: rows.slice(start, end), total, page, pages, start, end, perPage };
}
function renderPagination(pageKey, pageInfo, label) {
if (!pageInfo || pageInfo.total <= pageInfo.perPage) return '';
return `
<div class="pagination-foot">
<span>Showing ${formatNumber(pageInfo.start + 1)}-${formatNumber(pageInfo.end)} of ${formatNumber(pageInfo.total)} ${esc(label || 'records')}</span>
<div class="pager">
<button type="button" data-action="set-page" data-page-key="${attr(pageKey)}" data-page="${pageInfo.page - 1}" ${pageInfo.page <= 1 ? 'disabled' : ''} aria-label="Previous">Prev</button>
<button type="button" disabled>${pageInfo.page} / ${pageInfo.pages}</button>
<button type="button" data-action="set-page" data-page-key="${attr(pageKey)}" data-page="${pageInfo.page + 1}" ${pageInfo.page >= pageInfo.pages ? 'disabled' : ''} aria-label="Next">Next</button>
</div>
</div>
`;
}
function renderDetailTable(headers, rows, emptyLabel, options = {}) {
if (!rows || !rows.length) return renderEmpty(emptyLabel || 'No data available.');
const perPage = options.perPage || 25;
const pageKey = options.pageKey || '';
const headerConfigs = headers.map((header, index) => {
const config = typeof header === 'object' ? header : { label: header };
return Object.assign({ index, key: String(index), sortable: !!config.label, type: 'auto', initialDir: 'asc' }, config);
});
const sorters = {};
headerConfigs.forEach(header => {
if (!pageKey || !header.sortable || !header.label) return;
let override = {};
if (options.sorters && Object.prototype.hasOwnProperty.call(options.sorters, header.key)) {
override = options.sorters[header.key];
} else if (options.sorters && Object.prototype.hasOwnProperty.call(options.sorters, header.index)) {
override = options.sorters[header.index];
}
if (override === false) return;
sorters[header.key] = Object.assign({
type: header.type,
initialDir: header.initialDir,
get: row => row[header.index],
}, override);
});
const sortedRows = sortRows(pageKey, rows, sorters);
const pageInfo = pageKey ? paginateRows(pageKey, sortedRows, perPage) : null;
const visibleRows = pageInfo ? pageInfo.rows : rows;
return `
<div style="overflow:auto">
<table class="detail-table">
<thead><tr>${headerConfigs.map(header => {
const sorter = sorters[header.key];
const label = header.label || '';
return '<th>' + (sorter ? renderSortButton(pageKey, header.key, label, sorter.initialDir || header.initialDir || 'asc') : esc(label)) + '</th>';
}).join('')}</tr></thead>
<tbody>
${visibleRows.map(row => '<tr>' + row.map(cell => '<td>' + cell + '</td>').join('') + '</tr>').join('')}
</tbody>
</table>
</div>
${pageKey ? renderPagination(pageKey, pageInfo, options.label || 'records') : ''}
`;
}
function renderDataAttrs(dataset) {
return Object.entries(dataset || {}).map(([key, value]) => {
if (value == null || value === '' || !/^[a-z0-9_-]+$/i.test(key)) return '';
return ' data-' + key + '="' + attr(value) + '"';
}).join('');
}
function renderStackRows(rows, emptyLabel, options = {}) {
if (!rows || !rows.length) return renderEmpty(emptyLabel || 'No data available.');
const perPage = options.perPage || 25;
const pageKey = options.pageKey || '';
const pageInfo = pageKey ? paginateRows(pageKey, rows, perPage) : null;
const visibleRows = pageInfo ? pageInfo.rows : rows;
return '<div class="stack-list">' + visibleRows.map(row => `
<div class="stack-row ${row.action || row.route ? 'clickable' : ''} ${row.className || ''}"${row.route ? ' role="link" tabindex="0" data-route="' + attr(row.route) + '" aria-label="' + attr(row.ariaLabel || row.title || 'Open item') + '"' : (row.action ? ' role="button" tabindex="0" data-action="' + attr(row.action) + '"' + renderDataAttrs(row.dataset) + ' aria-label="' + attr(row.ariaLabel || row.title || 'Open item') + '"' : '')}>
<div class="stack-row__primary">${row.html || '<div class="stack-row__title"><strong>' + row.title + '</strong></div>'}${row.meta ? '<small>' + row.meta + '</small>' : ''}${row.hint ? '<div class="stack-row__hint">' + row.hint + '</div>' : ''}</div>
${row.aside ? '<div>' + row.aside + '</div>' : ''}
</div>
`).join('') + '</div>' + (pageKey ? renderPagination(pageKey, pageInfo, options.label || 'records') : '');
}
async function apiFetch(path, options = {}) {
const headers = Object.assign({
'Accept': 'application/json',
'X-WP-Nonce': state.nonce,
}, options.headers || {});
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(path, Object.assign({}, options, { headers }));
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) {
if (response.status === 403 && state.route !== '/login') {
state.nonce = '';
state.route = '/login';
window.history.replaceState({}, '', routeUrl('/login'));
render();
}
const err = new Error((payload && payload.message) || 'Request failed');
err.response = response;
err.payload = payload;
throw err;
}
return payload;
}
async function loadForRoute() {
if (!state.nonce && state.route !== '/login') {
state.route = '/login';
window.history.replaceState({}, '', routeUrl('/login'));
return;
}
const siteId = routeId('sites');
const domainId = routeId('domains');
const accountId = routeId('accounts');
if (siteId) return loadSiteDetail(siteId);
if (domainId) return loadDomainDetail(domainId);
if (accountId) return loadAccountDetail(accountId);
if (state.route.startsWith('/cookbook')) return loadCollection('recipes', '/wp-json/captaincore/v1/recipes');
if (state.route.startsWith('/configurations')) return loadConfigurationsScreen();
if (state.route.startsWith('/providers') || state.route.startsWith('/connect')) return loadProvidersScreen();
if (state.route.startsWith('/defaults')) return loadDefaultsScreen();
if (state.route.startsWith('/users')) return loadUsersScreen();
if (state.route.startsWith('/keys')) return loadCollection('keys', '/wp-json/captaincore/v1/keys');
if (state.route.startsWith('/handbook')) return loadCollection('processes', '/wp-json/captaincore/v1/processes');
if (state.route.startsWith('/reports')) return loadReportsScreen();
if (state.route.startsWith('/archives')) return loadCollection('archives', '/wp-json/captaincore/v1/archive');
if (state.route.startsWith('/activity-logs')) return loadActivityLogs();
if (state.route.startsWith('/site-audits')) return loadSiteAuditsScreen();
if (state.route.startsWith('/security')) return loadSecurityScreen();
if (state.route.startsWith('/profile')) return loadProfileScreen();
if (state.route.startsWith('/invite')) return loadInviteScreen();
if (state.route.startsWith('/sites')) return loadCollection('sites', '/wp-json/captaincore/v1/sites');
if (state.route.startsWith('/domains')) return loadCollection('domains', '/wp-json/captaincore/v1/domains/');
if (state.route.startsWith('/accounts')) return loadCollection('accounts', '/wp-json/captaincore/v1/accounts/');
if (state.route.startsWith('/billing') || state.route.startsWith('/subscriptions')) return loadBillingScreen();
}
async function loadCollection(key, endpoint, force = false) {
if (state.loading[key] || (state.fetched[key] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
const data = await apiFetch(endpoint);
state[key] = Array.isArray(data) ? data : [];
state.fetched[key] = true;
} catch (error) {
state.error[key] = error.message || 'Unable to load data.';
} finally {
state.loading[key] = false;
render();
}
}
async function loadResource(key, endpoint, force = false, mapper = value => value) {
if (state.loading[key] || (state.fetched[key] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
state[key] = mapper(await apiFetch(endpoint));
state.fetched[key] = true;
} catch (error) {
state.error[key] = error.message || 'Unable to load data.';
} finally {
state.loading[key] = false;
render();
}
}
async function loadMany(loaders) {
await Promise.all(loaders.map(loader => loader()));
}
async function loadProvidersScreen() {
return loadMany([
() => loadCollection('providers', '/wp-json/captaincore/v1/providers'),
() => loadCollection('accounts', '/wp-json/captaincore/v1/accounts/'),
]);
}
async function loadDefaultsScreen() {
return loadMany([
() => loadResource('defaults', '/wp-json/captaincore/v1/defaults', false, value => value || {}),
() => loadCollection('recipes', '/wp-json/captaincore/v1/recipes'),
]);
}
async function loadConfigurationsScreen() {
return loadMany([
() => loadResource('configurationDetails', '/wp-json/captaincore/v1/configurations', false, value => value || {}),
() => loadProvidersScreen(),
() => loadDefaultsScreen(),
]);
}
async function loadUsersScreen() {
return loadMany([
() => loadCollection('users', '/wp-json/captaincore/v1/users'),
() => loadCollection('accounts', '/wp-json/captaincore/v1/accounts/'),
]);
}
async function loadReportsScreen() {
return loadMany([
() => loadCollection('scheduledReports', '/wp-json/captaincore/v1/scheduled-reports'),
() => loadCollection('sites', '/wp-json/captaincore/v1/sites'),
() => loadCollection('accounts', '/wp-json/captaincore/v1/accounts/'),
]);
}
async function loadActivityLogs(force = false) {
const key = 'activityLogs';
if (state.loading[key] || (state.fetched[key] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
const params = new URLSearchParams({
page: state.activityLogs.page,
per_page: state.activityLogs.perPage,
});
Object.entries(state.activityLogs.filters).forEach(([filter, value]) => {
if (value) params.set(filter, value);
});
const payload = await apiFetch('/wp-json/captaincore/v1/activity-logs?' + params.toString());
state.activityLogs.items = Array.isArray(payload) ? payload : asArray(payload?.items || payload?.data);
state.activityLogs.total = Number(payload?.total || state.activityLogs.items.length || 0);
state.fetched[key] = true;
} catch (error) {
state.error[key] = error.message || 'Unable to load activity logs.';
} finally {
state.loading[key] = false;
render();
}
}
async function loadSiteAuditsScreen() {
return loadMany([
() => loadCollection('siteAudits', '/wp-json/captaincore/v1/site-audits'),
() => loadCollection('sites', '/wp-json/captaincore/v1/sites'),
]);
}
async function loadSecurityScreen() {
return loadMany([
() => loadResource('securityThreatsPayload', '/wp-json/captaincore/v1/security-threats', false, payload => {
state.security.threats = asArray(payload?.threats || payload);
return payload || {};
}),
() => loadResource('securityPatchesPayload', '/wp-json/captaincore/v1/security-patches', false, payload => {
state.security.patches = asArray(payload);
return payload || [];
}),
() => loadResource('securityCoveragePayload', '/wp-json/captaincore/v1/security-coverage', false, payload => {
state.security.coverage = payload || null;
return payload || {};
}),
]);
}
async function loadBillingScreen() {
return loadMany([
() => loadCollection('subscriptions', '/wp-json/captaincore/v1/subscriptions/'),
() => loadResource('billingDetails', '/wp-json/captaincore/v1/billing', false, value => value || {}),
]);
}
async function loadProfileScreen() {
return loadResource('profileSessionsPayload', '/wp-json/captaincore/v1/sessions', false, payload => {
state.profileForm.sessions = asArray(payload?.sessions || payload);
return payload || {};
});
}
async function loadInviteScreen() {
const params = new URLSearchParams(window.location.search);
state.invite.account = params.get('account') || state.invite.account;
state.invite.token = params.get('token') || state.invite.token;
if (!state.invite.account || !state.invite.token || state.invite.loading || state.invite.details) return;
state.invite.loading = true;
state.invite.error = '';
render();
try {
state.invite.details = await apiFetch('/wp-json/captaincore/v1/invites?account=' + encodeURIComponent(state.invite.account) + '&token=' + encodeURIComponent(state.invite.token));
} catch (error) {
state.invite.error = error.message || 'Unable to load invite.';
} finally {
state.invite.loading = false;
render();
}
}
async function loadSiteDetail(id, force = false) {
const key = detailKey('site', id);
if (state.loading[key] || (state.siteDetails[id] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
const [envs, details] = await Promise.all([
apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(id) + '/environments'),
apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(id) + '/details'),
]);
const environments = Array.isArray(envs) ? envs : [];
const payload = details || {};
state.siteDetails[id] = {
environments,
details: payload,
site: payload.site || state.sites.find(item => String(item.site_id) === String(id)) || {},
};
if (!state.detailEnvironments[id] && environments.length) {
const primary = environments.find(env => env.environment === 'Production') || environments[0];
state.detailEnvironments[id] = primary.environment_id || primary.environment || 'Production';
}
} catch (error) {
state.error[key] = error.message || 'Unable to load site.';
} finally {
state.loading[key] = false;
render();
}
}
async function loadDomainDetail(id, force = false) {
const key = detailKey('domain', id);
if (state.loading[key] || (state.domainDetails[id] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
let listDomain = state.domains.find(item => String(item.domain_id) === String(id));
if (!listDomain && !state.fetched.domains) {
const domains = await apiFetch('/wp-json/captaincore/v1/domains/');
state.domains = Array.isArray(domains) ? domains : [];
state.fetched.domains = true;
listDomain = state.domains.find(item => String(item.domain_id) === String(id));
}
const domainPayload = await apiFetch('/wp-json/captaincore/v1/domain/' + encodeURIComponent(id));
const domain = Object.assign({}, listDomain || {}, domainPayload?.domain || {}, domainPayload || {});
let records = [];
if (domain.remote_id || listDomain?.remote_id) {
try {
const dnsPayload = await apiFetch('/wp-json/captaincore/v1/dns/' + encodeURIComponent(id));
records = Array.isArray(dnsPayload) ? dnsPayload : asArray(dnsPayload?.records);
} catch (error) {
records = [];
}
}
state.domainDetails[id] = {
domain,
provider: domainPayload?.provider || {},
details: domainPayload?.details || {},
records,
};
} catch (error) {
state.error[key] = error.message || 'Unable to load domain.';
} finally {
state.loading[key] = false;
render();
}
}
async function loadAccountDetail(id, force = false) {
const key = detailKey('account', id);
if (state.loading[key] || (state.accountDetails[id] && !force)) return;
state.loading[key] = true;
state.error[key] = '';
render();
try {
state.accountDetails[id] = await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id));
} catch (error) {
state.error[key] = error.message || 'Unable to load account.';
} finally {
state.loading[key] = false;
render();
}
}
function environmentSlug(env) {
return String(env?.environment || 'production').toLowerCase();
}
function siteFeatureKey(siteId, env) {
return [siteId || 'site', env?.environment_id || env?.environment || 'primary'].join(':');
}
function siteFeature(siteId, env) {
const key = siteFeatureKey(siteId, env);
if (!state.siteFeatures[key]) {
state.siteFeatures[key] = {
stats: null,
statsLoaded: false,
statsLoading: false,
statsError: '',
backups: [],
backupsLoaded: false,
backupsLoading: false,
backupsError: '',
quicksaves: [],
quicksavesLoaded: false,
quicksavesLoading: false,
quicksavesError: '',
expandedQuicksave: '',
expandedQuicksaveFiles: '',
quicksaveFiles: {},
quicksaveFilesLoading: {},
quicksaveFilesError: {},
quicksaveDetails: {},
quicksaveDetailsLoading: {},
quicksaveDetailsError: {},
expandedComponents: {},
expandedFileGroups: {},
componentFiles: {},
componentFilesLoading: {},
componentFilesError: {},
updates: [],
updatesLoaded: false,
updatesLoading: false,
updatesError: '',
expandedUpdate: '',
timeline: [],
timelineLoaded: false,
timelineLoading: false,
timelineError: '',
users: [],
usersLoaded: false,
usersLoading: false,
usersError: '',
logFiles: [],
logsLoaded: false,
logsLoading: false,
logsError: '',
selectedLog: '',
logLimit: '1000',
logContent: '',
logContentLoaded: false,
logContentLoading: false,
logContentError: '',
};
}
return state.siteFeatures[key];
}
function isSiteFeatureTab(tab) {
return ['stats', 'backups', 'quicksaves', 'updates', 'timeline', 'users', 'logs'].includes(tab);
}
function extractItems(payload, siteId) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.items)) return payload.items;
if (Array.isArray(payload?.results)) return payload.results;
if (siteId && Array.isArray(payload?.[siteId])) return payload[siteId];
return [];
}
function arrayFromMaybe(value) {
if (Array.isArray(value)) return value;
if (value == null || value === '' || value === 'Loading') return [];
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch (error) {}
return value.trim() ? [value] : [];
}
return [value];
}
function isIgnoredConsoleError(item) {
const data = typeof item === 'string' ? { description: item } : asObject(item);
const location = asObject(firstDefined(data.sourceLocation, data.source_location, data.location));
const description = String(firstDefined(data.description, data.message, data.text, ''));
const source = String(data.source || '').toLowerCase();
const url = String(firstDefined(location.url, data.url, data.href, ''));
const cleanUrl = url.split('?')[0].replace(/\/+$/, '');
const mentionsFavicon = /(^|\/)favicon\.ico$/i.test(cleanUrl) || /favicon\.ico/i.test(description);
const isMissingResource = /failed to load resource/i.test(description) || /status of 404/i.test(description) || Number(data.status) === 404;
return mentionsFavicon && (isMissingResource || source === 'network');
}
function visibleConsoleErrorsForEnvironment(env) {
const envDetails = asObject(env?.details);
return arrayFromMaybe(firstDefined(envDetails.console_errors, env?.console_errors)).filter(item => !isIgnoredConsoleError(item));
}
function usersForEnvironment(payload, env) {
const data = asObject(payload);
const keys = [
env?.environment,
String(env?.environment || '').toLowerCase(),
environmentSlug(env),
env?.environment_id,
].filter(Boolean);
for (const key of keys) {
if (Array.isArray(data[key])) return data[key];
}
return extractItems(payload);
}
function normalizeLogFiles(payload) {
const files = Array.isArray(payload?.files) ? payload.files : extractItems(payload);
return files.map(file => {
if (typeof file === 'string') {
return { path: file, label: file.split('/').pop() || file, meta: '' };
}
const path = firstDefined(file?.path, file?.file, file?.name, file?.filename, file?.value);
return {
path,
label: firstDefined(file?.label, file?.name, file?.filename, String(path || '').split('/').pop(), path),
meta: [file?.size ? (Number.isNaN(Number(file.size)) ? file.size : formatStorage(file.size)) : '', file?.modified || file?.updated_at || file?.date || ''].filter(Boolean).join(' - '),
};
}).filter(file => file.path);
}
function backupId(backup) {
return firstDefined(backup?.backup_id, backup?.id, backup?.snapshot_id, backup?.name);
}
function quicksaveHash(quicksave) {
return firstDefined(quicksave?.git_hash, quicksave?.hash, quicksave?.hash_after, quicksave?.id);
}
function quicksaveDetailKey(hash) {
return 'quicksave:' + String(hash || '');
}
function updateLogHashes(item) {
return {
hashBefore: firstDefined(item?.hash_before, item?.before_hash, item?.hash_from, item?.from, item?.previous_hash),
hashAfter: firstDefined(item?.hash_after, item?.after_hash, item?.hash_to, item?.to, item?.hash, item?.git_hash),
};
}
function updateLogDetailKey(hashBefore, hashAfter) {
return 'update:' + String(hashBefore || '') + ':' + String(hashAfter || '');
}
function cleanFileName(fileName) {
const value = String(fileName || '');
return value.includes('\t') ? value.split('\t').pop() : value;
}
function parseChangedFiles(payload) {
const files = Array.isArray(payload) ? payload : String(payload || '').trim().split('\n');
return files.map(file => String(file || '').trim()).filter(Boolean);
}
function componentSlug(component) {
if (typeof component === 'string') return component;
return String(firstDefined(component?.name, component?.slug, component?.plugin, component?.theme, component?.path, component?.file, ''));
}
function componentTitle(component, fallback = 'Item') {
if (typeof component === 'string') return component;
return firstDefined(component?.title, component?.label, component?.name, component?.slug, fallback);
}
function componentState(component, deleted = false) {
if (deleted || component?.deleted) return 'Deleted';
if (component?.new || component?.added || component?.installed) return 'New';
if (component?.changed || component?.changed_version || component?.changed_status || component?.version_before || component?.status_before) return 'Changed';
return 'Tracked';
}
function componentVersionMeta(component, deleted = false) {
const item = typeof component === 'string' ? {} : asObject(component);
const beforeVersion = firstDefined(item.changed_version, item.previous_version, item.version_before, item.old_version);
const afterVersion = firstDefined(item.version, item.current_version, item.version_after, item.new_version, item.update_version);
const beforeStatus = firstDefined(item.changed_status, item.previous_status, item.status_before);
const afterStatus = firstDefined(item.status, item.current_status, item.status_after);
const pieces = [];
if (deleted) pieces.push('Removed' + (afterVersion ? ' at ' + afterVersion : ''));
else if (beforeVersion && afterVersion && String(beforeVersion) !== String(afterVersion)) pieces.push(beforeVersion + ' -> ' + afterVersion);
else if (afterVersion) pieces.push('Version ' + afterVersion);
if (beforeStatus && afterStatus && String(beforeStatus) !== String(afterStatus)) pieces.push(beforeStatus + ' -> ' + afterStatus);
else if (afterStatus) pieces.push(afterStatus);
return pieces;
}
function componentMatchPath(type, component) {
const slug = componentSlug(component).replace(/^\/+|\/+$/g, '');
if (!slug || (type !== 'plugins' && type !== 'themes')) return '';
return type + '/' + slug + '/';
}
function collectChangeComponents(detail) {
const data = asObject(detail);
const items = [];
const add = (type, component, deleted = false) => {
const stateLabel = componentState(component, deleted);
const changed = ['Changed', 'New', 'Deleted'].includes(stateLabel);
items.push({ type, component, deleted, stateLabel, changed });
};
asArray(data.plugins).forEach(component => add('plugins', component));
asArray(data.plugins_deleted).forEach(component => add('plugins', component, true));
asArray(data.themes).forEach(component => add('themes', component));
asArray(data.themes_deleted).forEach(component => add('themes', component, true));
return items.filter(item => item.changed);
}
function changeDetailCounts(detail) {
const data = asObject(detail);
const components = collectChangeComponents(data);
return {
plugins: components.filter(item => item.type === 'plugins').length,
themes: components.filter(item => item.type === 'themes').length,
deleted: asArray(data.plugins_deleted).length + asArray(data.themes_deleted).length,
};
}
function changedFileGroup(file) {
const path = cleanFileName(file).replace(/^\/+/, '');
const plugin = path.match(/(?:^|\/)(?:wp-content\/)?plugins\/([^/]+)/);
if (plugin) return { key: 'plugins:' + plugin[1], label: plugin[1], type: 'Plugin', priority: 1 };
const theme = path.match(/(?:^|\/)(?:wp-content\/)?themes\/([^/]+)/);
if (theme) return { key: 'themes:' + theme[1], label: theme[1], type: 'Theme', priority: 2 };
if (/^(wp-admin|wp-includes)\//.test(path) || /^wp-[^/]+\.php$/.test(path) || path === 'index.php') {
return { key: 'core', label: 'WordPress core', type: 'Core', priority: 3 };
}
if (/(?:^|\/)wp-content\/uploads\//.test(path) || /(?:^|\/)uploads\//.test(path)) {
return { key: 'uploads', label: 'Uploads', type: 'Media', priority: 4 };
}
return { key: 'other', label: 'Other files', type: 'Files', priority: 5 };
}
function groupChangedFiles(files) {
const groups = new Map();
files.forEach(file => {
const group = changedFileGroup(file);
if (!groups.has(group.key)) groups.set(group.key, Object.assign({ files: [] }, group));
groups.get(group.key).files.push(file);
});
return Array.from(groups.values()).sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label));
}
function updateLogSummary(item) {
const pluginCount = Number(firstDefined(item?.plugin_count, item?.plugins_count, item?.plugins));
const pluginChanged = Number(firstDefined(item?.plugins_changed, item?.plugin_changed_count, item?.plugin_changed));
const themeCount = Number(firstDefined(item?.theme_count, item?.themes_count, item?.themes));
const themeChanged = Number(firstDefined(item?.themes_changed, item?.theme_changed_count, item?.theme_changed));
return {
pluginCount: Number.isFinite(pluginCount) ? pluginCount : 0,
pluginChanged: Number.isFinite(pluginChanged) ? pluginChanged : 0,
themeCount: Number.isFinite(themeCount) ? themeCount : 0,
themeChanged: Number.isFinite(themeChanged) ? themeChanged : 0,
};
}
function renderDiffContent(content) {
if (!content) return '';
return String(content).split('\n').map(line => {
const klass = line.startsWith('+') && !line.startsWith('+++') ? ' added' : (line.startsWith('-') && !line.startsWith('---') ? ' removed' : '');
return '<span class="diff-line' + klass + '">' + esc(line || ' ') + '</span>';
}).join('');
}
function currentSiteContext() {
const id = routeId('sites');
if (!id && state.drawer?.siteId) {
const { site, env } = findSiteAndEnv(state.drawer.siteId, state.drawer.envId);
return { id: state.drawer.siteId, site, env };
}
const data = state.siteDetails[id];
if (!id || !data) return {};
const site = Object.assign({}, state.sites.find(item => String(item.site_id) === String(id)) || {}, data.site || {});
const envs = getEnvironments({ environments: data.environments || site.environments || [] });
const selected = state.detailEnvironments[id];
const env = envs.find(item => String(item.environment_id) === String(selected)) || envs.find(item => String(item.environment) === String(selected)) || envs.find(item => item.environment === 'Production') || envs[0];
return { id, site, env };
}
function siteTabFromRoute() {
const parts = state.route.split('/').filter(Boolean);
if (parts[0] !== 'sites') return '';
const slug = parts[2] || '';
const map = {
stats: 'stats',
logs: 'logs',
addons: 'addons',
users: 'users',
updates: 'updates',
scripts: 'scripts',
backups: 'backups',
'backup-overview': 'backups',
quicksaves: 'quicksaves',
timeline: 'timeline',
domains: 'domains',
};
return map[slug] || '';
}
function targetKey(target) {
return String(target?.environment_id || target?.envId || target?.site_id + ':' + target?.environment || '');
}
function normalizeTarget(site, env) {
if (!site || !env) return null;
const envId = env.environment_id || env.enviroment_id || env.id;
if (!envId) return null;
return {
site_id: site.site_id || env.site_id,
name: site.name || site.site || env.name || 'Site',
site: site.site || '',
environment_id: envId,
environment: env.environment || 'Production',
home_url: env.home_url || env.link || site.name || '',
};
}
function terminalTargets() {
const byKey = new Map();
state.sites.forEach(site => {
getEnvironments(site).forEach(env => {
const target = normalizeTarget(site, env);
if (target) byKey.set(targetKey(target), target);
});
});
Object.values(state.siteDetails).forEach(data => {
const site = data.site || {};
asArray(data.environments || site.environments).forEach(env => {
const target = normalizeTarget(site, env);
if (target) byKey.set(targetKey(target), target);
});
});
return Array.from(byKey.values()).sort((a, b) => {
const labelA = [a.name, a.environment, a.home_url].join(' ');
const labelB = [b.name, b.environment, b.home_url].join(' ');
return labelA.localeCompare(labelB);
});
}
function filteredTerminalTargets() {
const q = normalizeText(state.terminal.targetSearch);
return terminalTargets().filter(target => {
if (!q) return true;
return normalizeText([target.name, target.site, target.environment, target.home_url].join(' ')).includes(q);
});
}
function terminalTargetSelected(target) {
const key = targetKey(target);
return state.terminal.selectedTargets.some(item => targetKey(item) === key);
}
function toggleTerminalTarget(target) {
const key = targetKey(target);
const index = state.terminal.selectedTargets.findIndex(item => targetKey(item) === key);
if (index >= 0) state.terminal.selectedTargets.splice(index, 1);
else state.terminal.selectedTargets.push(target);
}
function openTerminal(targets = [], focus = true) {
if (targets.length) {
const byKey = new Map(state.terminal.selectedTargets.map(target => [targetKey(target), target]));
targets.forEach(target => {
if (target) byKey.set(targetKey(target), target);
});
state.terminal.selectedTargets = Array.from(byKey.values());
}
state.terminal.open = true;
state.terminal.show = true;
loadRecipes();
renderTerminal();
if (focus) {
requestAnimationFrame(() => {
const input = document.querySelector('[data-input="terminal-command"]');
if (input) input.focus();
});
}
}
function openTerminalForCurrentEnv(focus = true) {
const { site, env } = currentSiteContext();
const target = normalizeTarget(site, env);
state.terminal.selectedTargets = target ? [target] : [];
openTerminal([], focus);
}
function selectFilteredTargetsForTerminal() {
const visible = visibleSites().flatMap(site => visibleEnvironments(site).map(env => normalizeTarget(site, env))).filter(Boolean);
openTerminal(visible);
if (!visible.length) showToast('No filtered environments available.');
}
async function loadRecipes(force = false) {
if (state.loading.recipes || (state.fetched.recipes && !force)) return;
state.loading.recipes = true;
state.error.recipes = '';
renderTerminal();
try {
const payload = await apiFetch('/wp-json/captaincore/v1/recipes/');
state.recipes = Array.isArray(payload) ? payload : [];
state.fetched.recipes = true;
} catch (error) {
state.error.recipes = error.message || 'Unable to load recipes.';
} finally {
state.loading.recipes = false;
renderTerminal();
render();
}
}
function filteredTerminalTools() {
const q = normalizeText(state.terminal.toolSearch);
if (state.terminal.toolTab === 'cookbook') {
return state.recipes.filter(recipe => !q || normalizeText([recipe.title, recipe.content].join(' ')).includes(q));
}
return state.systemTools.filter(tool => !q || normalizeText([tool.label, tool.description, tool.key].join(' ')).includes(q));
}
function createJob(description) {
const job = {
clientId: 'job-' + Date.now() + '-' + Math.round(Math.random() * 100000),
job_id: '',
description,
status: 'queued',
stream: [],
created_at: new Date().toISOString(),
conn: null,
};
state.jobs.push(job);
state.terminal.show = true;
renderTerminal();
return job;
}
function jobTokenFromResponse(response) {
if (typeof response === 'string' || typeof response === 'number') return String(response);
if (response?.token) return String(response.token);
if (response?.job_id) return String(response.job_id);
if (response?.task_id) return String(response.task_id);
return '';
}
function markJobCompleteFromResponse(job, response) {
job.status = response?.status === 'error' ? 'error' : 'done';
if (response?.response) job.stream.push(typeof response.response === 'string' ? response.response : JSON.stringify(response.response, null, 2));
if (response?.message) job.stream.push(response.message);
renderTerminal();
}
function streamJob(job, token) {
job.job_id = token;
job.status = 'running';
if (!CC.socket || CC.socket === '/ws') {
job.stream.push('Started job ' + token + '.');
job.stream.push('Realtime websocket address is not configured for this portal.');
job.status = 'queued';
renderTerminal();
return;
}
try {
const conn = new WebSocket(CC.socket);
job.conn = conn;
conn.onopen = () => {
if (conn.readyState === WebSocket.OPEN) {
conn.send(JSON.stringify({ token, action: 'start' }));
}
};
conn.onmessage = event => {
job.stream.push(event.data);
renderTerminal();
scrollTerminalToBottom();
};
conn.onerror = () => {
job.stream.push('Websocket connection failed.');
};
conn.onclose = () => {
const last = job.stream.length ? String(job.stream[job.stream.length - 1]).trim() : '';
if (job.status !== 'cancelled') job.status = last === 'Finished.' ? 'done' : (job.stream.length ? 'error' : 'error');
renderTerminal();
};
} catch (error) {
job.status = 'error';
job.stream.push(error.message || 'Unable to open websocket.');
}
renderTerminal();
}
async function startTerminalRequest(description, endpoint, payload) {
const job = createJob(description);
try {
const response = await apiFetch(endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
const token = jobTokenFromResponse(response);
if (token) streamJob(job, token);
else markJobCompleteFromResponse(job, response);
} catch (error) {
job.status = 'error';
job.stream.push(error.message || 'Unable to start job.');
renderTerminal();
}
return job;
}
function selectedTerminalEnvironmentIds() {
return state.terminal.selectedTargets.map(target => target.environment_id).filter(Boolean);
}
function executeTerminalCommand(code = state.terminal.command, targets = state.terminal.selectedTargets) {
const command = String(code || '').trim();
if (!command) return;
if (!targets.length) {
showToast('Select at least one target environment.');
return;
}
const description = targets.length === 1 ? 'Running command on ' + (targets[0].home_url || targets[0].name) : 'Running command on ' + targets.length + ' environments';
state.terminal.command = '';
startTerminalRequest(description, '/wp-json/captaincore/v1/run/code', {
environments: targets.map(target => target.environment_id),
code: command,
});
renderTerminal();
}
function runTerminalSystemTool(toolKey) {
const tool = state.systemTools.find(item => item.key === toolKey);
const environments = selectedTerminalEnvironmentIds();
if (!tool || !environments.length) {
showToast('Select at least one target environment.');
return;
}
const description = tool.label + ' on ' + (environments.length === 1 ? state.terminal.selectedTargets[0].home_url : environments.length + ' environments');
startTerminalRequest(description, '/wp-json/captaincore/v1/sites/bulk-tools', {
tool: tool.key,
environments,
params: {},
});
}
function cancelTerminalJob(clientId) {
const job = state.jobs.find(item => item.clientId === clientId);
if (!job) return;
if (job.conn && job.conn.readyState === WebSocket.OPEN) {
job.conn.send(JSON.stringify({ token: job.job_id, action: 'kill' }));
}
if (job.job_id) {
apiFetch('/wp-json/captaincore/v1/my-jobs/' + encodeURIComponent(job.job_id), { method: 'DELETE' }).catch(() => {});
}
job.status = 'cancelled';
job.stream.push('Process cancelled by user.');
renderTerminal();
}
function copyJobStream(clientId) {
const job = state.jobs.find(item => item.clientId === clientId);
if (!job) return;
const text = job.stream.join('\n');
if (navigator.clipboard) navigator.clipboard.writeText(text).then(() => showToast('Output copied.'));
}
function terminalActiveJob() {
return [...state.jobs].reverse().find(job => job.status === 'running' || job.status === 'queued') || state.jobs[state.jobs.length - 1] || null;
}
function runningJobCount() {
return state.jobs.filter(job => job.status === 'running' || job.status === 'queued').length;
}
function lastJobLine(job) {
if (!job || !job.stream.length) return '';
return String(job.stream[job.stream.length - 1]).trim();
}
function scrollTerminalToBottom() {
requestAnimationFrame(() => {
const output = document.querySelector('[data-terminal-output]');
if (output) output.scrollTop = output.scrollHeight;
});
}
async function loadSiteFeature(feature, siteId, env, force = false) {
if (!siteId || !env || !isSiteFeatureTab(feature)) return;
const bucket = siteFeature(siteId, env);
const loadedKey = feature + 'Loaded';
const loadingKey = feature + 'Loading';
const errorKey = feature + 'Error';
if (bucket[loadingKey] || (bucket[loadedKey] && !force)) return;
bucket[loadingKey] = true;
bucket[errorKey] = '';
render();
try {
if (feature === 'stats') {
const params = new URLSearchParams({
from_at: state.siteStats.from_at,
to_at: state.siteStats.to_at,
grouping: state.siteStats.grouping,
environment: env.environment || 'Production',
});
const trackerValues = asArray(env.fathom_analytics).map(statsTrackerValue).filter(Boolean);
if (state.siteStats.fathom_id && trackerValues.includes(state.siteStats.fathom_id)) {
params.set('fathom_id', state.siteStats.fathom_id);
}
const payload = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/stats?' + params.toString());
if (typeof payload === 'string') bucket.statsError = payload;
else if (payload?.Error) bucket.statsError = String(payload.Error);
else if (payload?.errors) bucket.statsError = typeof payload.errors === 'string' ? payload.errors : JSON.stringify(payload.errors);
else bucket.stats = payload;
bucket.statsLoaded = true;
}
if (feature === 'backups') {
const payload = await apiFetch('/wp-json/captaincore/v1/site/' + encodeURIComponent(siteId) + '/' + encodeURIComponent(environmentSlug(env)) + '/backups');
bucket.backups = extractItems(payload, siteId);
bucket.backupsLoaded = true;
}
if (feature === 'quicksaves') {
const params = new URLSearchParams({ site_id: siteId, environment: environmentSlug(env) });
const payload = await apiFetch('/wp-json/captaincore/v1/quicksaves?' + params.toString());
bucket.quicksaves = extractItems(payload, siteId);
bucket.quicksavesLoaded = true;
}
if (feature === 'updates') {
const params = new URLSearchParams({ site_id: siteId, environment: environmentSlug(env) });
const payload = await apiFetch('/wp-json/captaincore/v1/update-logs?' + params.toString());
bucket.updates = extractItems(payload, siteId);
bucket.updatesLoaded = true;
}
if (feature === 'timeline') {
let payload;
try {
payload = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/timeline');
} catch (error) {
payload = await apiFetch('/wp-json/captaincore/v1/activity-logs?site_id=' + encodeURIComponent(siteId) + '&per_page=250');
}
bucket.timeline = extractItems(payload, siteId);
bucket.timelineLoaded = true;
}
if (feature === 'users') {
const payload = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/users');
bucket.users = usersForEnvironment(payload, env);
env.users = bucket.users;
bucket.usersLoaded = true;
}
if (feature === 'logs') {
const payload = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/' + encodeURIComponent(environmentSlug(env)) + '/logs');
bucket.logFiles = normalizeLogFiles(payload);
if (!bucket.selectedLog || !bucket.logFiles.some(file => file.path === bucket.selectedLog)) {
bucket.selectedLog = bucket.logFiles[0]?.path || '';
}
bucket.logsLoaded = true;
if (bucket.selectedLog) {
await fetchSiteLogFile(siteId, env, bucket.selectedLog, false);
}
}
} catch (error) {
bucket[errorKey] = error.message || 'Unable to load ' + feature + '.';
} finally {
bucket[loadingKey] = false;
render();
}
}
function ensureSiteFeature(feature, siteId, env) {
if (!isSiteFeatureTab(feature) || !siteId || !env) return;
const bucket = siteFeature(siteId, env);
if (bucket[feature + 'Loaded'] || bucket[feature + 'Loading']) return;
setTimeout(() => loadSiteFeature(feature, siteId, env), 0);
}
async function fetchSiteLogFile(siteId, env, file, shouldRender = true) {
if (!siteId || !env || !file) return;
const bucket = siteFeature(siteId, env);
bucket.selectedLog = file;
bucket.logContentLoading = true;
bucket.logContentError = '';
if (shouldRender) render();
try {
const payload = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/' + encodeURIComponent(environmentSlug(env)) + '/logs/fetch', {
method: 'POST',
body: JSON.stringify({
file,
limit: bucket.logLimit || '1000',
}),
});
bucket.logContent = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
bucket.logContentLoaded = true;
} catch (error) {
bucket.logContentError = error.message || 'Unable to fetch log file.';
} finally {
bucket.logContentLoading = false;
if (shouldRender) render();
}
}
function captureBaseUrl(site, env) {
if (!site || !env || !CC.remote_upload_uri) return '';
const slug = firstDefined(site.site, site.slug, site.name);
const id = site.site_id || site.id;
if (!slug || !id) return '';
const base = String(CC.remote_upload_uri || '').replace(/\/+$/, '');
return cleanAssetUrl(base + '/' + slug + '_' + id + '/' + environmentSlug(env) + '/captures/');
}
function captureConfigPages(env) {
const pages = asArray(env?.capture_pages).map(item => {
if (typeof item === 'string') return { page: item || '/' };
return { page: firstDefined(item?.page, item?.name, item?.path, '/') };
}).filter(item => item.page);
return pages.length ? pages : [{ page: '/' }];
}
function captureAuth(env) {
const auth = asObject(asObject(env?.details).auth);
return {
username: auth.username || '',
password: auth.password || '',
};
}
function captureId(capture, index) {
return String(firstDefined(capture?.capture_id, capture?.id, capture?.created_at, index));
}
function captureLabel(capture, index) {
return firstDefined(capture?.created_at_friendly, capture?.created_at, capture?.date, 'Capture ' + (index + 1));
}
function normalizedCapturePages(capture) {
return asArray(capture?.pages).map((page, index) => {
if (typeof page === 'string') {
return { name: page || '/', page: page || '/', image: '' };
}
return Object.assign({ name: firstDefined(page?.name, page?.page, page?.path, 'Page ' + (index + 1)) }, page || {});
});
}
function capturePageKey(page, index) {
return String(firstDefined(page?.name, page?.page, page?.path, page?.image, page?.image_url, index));
}
function selectedCapture() {
const selected = state.captures.selectedCaptureId;
return state.captures.captures.find((capture, index) => captureId(capture, index) === selected) || state.captures.captures[0] || null;
}
function selectedCapturePage(capture) {
const pages = normalizedCapturePages(capture);
return pages.find((page, index) => capturePageKey(page, index) === state.captures.selectedPageKey) || pages[0] || null;
}
function capturePageImageUrl(capture, page) {
if (!capture || !page) return '';
const direct = firstDefined(page.image_url, page.url, page.href);
if (/^https?:\/\//i.test(String(direct))) return direct;
const image = firstDefined(page.image, direct);
if (!image) return '';
if (/^https?:\/\//i.test(String(image))) return image;
const base = firstDefined(capture.image_base_url, state.captures.imageBaseUrl);
if (!base) return '';
return base + String(image).replace(/^\/+/, '').replace(/#/g, '%23');
}
function setDefaultCaptureSelection() {
const capture = selectedCapture();
if (!capture) {
state.captures.selectedCaptureId = '';
state.captures.selectedPageKey = '';
return;
}
const captureIndex = state.captures.captures.indexOf(capture);
state.captures.selectedCaptureId = captureId(capture, captureIndex);
const pages = normalizedCapturePages(capture);
if (!pages.some((page, index) => capturePageKey(page, index) === state.captures.selectedPageKey)) {
state.captures.selectedPageKey = pages.length ? capturePageKey(pages[0], 0) : '';
}
}
function syncCaptureFormState(form) {
const source = form || modalRegion.querySelector('[data-capture-config-form]');
if (!source) return;
const pages = Array.from(source.querySelectorAll('[name="capture_pages[]"]'))
.map(input => ({ page: input.value.trim() }))
.filter(item => item.page);
state.captures.pages = pages.length ? pages : [{ page: '/' }];
state.captures.auth = {
username: source.querySelector('[name="auth_username"]')?.value || '',
password: source.querySelector('[name="auth_password"]')?.value || '',
};
}
async function openCaptures(siteId, envId) {
const context = siteId ? findSiteAndEnv(siteId, envId) : currentSiteContext();
const site = context.site;
const env = context.env;
if (!site || !env) return;
const resolvedSiteId = site.site_id || siteId;
const resolvedEnvId = env.environment_id || env.environment || envId;
const envName = environmentSlug(env);
state.drawer = null;
state.modal = 'captures';
state.captures = Object.assign({}, state.captures, {
siteId: resolvedSiteId,
envId: resolvedEnvId,
envName,
siteName: site.name || site.site || 'Site',
homeUrl: env.home_url || env.link || '',
captures: [],
selectedCaptureId: '',
selectedPageKey: '',
pages: captureConfigPages(env),
auth: captureAuth(env),
imageBaseUrl: captureBaseUrl(site, env),
loading: true,
saving: false,
requesting: false,
error: '',
showConfig: false,
});
render();
try {
const payload = await apiFetch('/wp-json/captaincore/v1/site/' + encodeURIComponent(resolvedSiteId) + '/' + encodeURIComponent(envName) + '/captures');
state.captures.captures = extractItems(payload);
const firstBase = state.captures.captures.find(capture => capture?.image_base_url)?.image_base_url;
if (firstBase) state.captures.imageBaseUrl = firstBase;
setDefaultCaptureSelection();
} catch (error) {
state.captures.error = error.message || 'Unable to load visual captures.';
} finally {
state.captures.loading = false;
render();
}
}
async function requestNewCapture() {
if (!state.captures.siteId || !state.captures.envName) return;
state.captures.requesting = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(state.captures.siteId) + '/' + encodeURIComponent(state.captures.envName) + '/captures/new');
showToast('Capture check started.');
} catch (error) {
showToast('Capture request failed.');
} finally {
state.captures.requesting = false;
render();
}
}
async function saveCaptureConfig(form) {
if (!state.captures.siteId || !state.captures.envName) return;
syncCaptureFormState(form);
state.captures.saving = true;
state.captures.error = '';
render();
try {
await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(state.captures.siteId) + '/' + encodeURIComponent(state.captures.envName) + '/captures', {
method: 'POST',
body: JSON.stringify({
pages: state.captures.pages,
auth: state.captures.auth,
}),
});
state.captures.showConfig = false;
showToast('Capture configuration saved.');
} catch (error) {
state.captures.error = error.message || 'Unable to save capture configuration.';
} finally {
state.captures.saving = false;
render();
}
}
async function runBackupAction(command, backupIdValue) {
const { id, env } = currentSiteContext();
if (!id || !env || !backupIdValue) return;
if (command === 'backup_restore' && !window.confirm('Restore this backup? This will overwrite the current site data.')) return;
const loadingKey = command + ':' + backupIdValue;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/sites/cli', {
method: 'POST',
body: JSON.stringify({
post_id: id,
command,
value: { backup_id: backupIdValue },
environment: environmentSlug(env),
}),
});
showToast(command === 'backup_restore' ? 'Restore started.' : 'Backup download started.');
} catch (error) {
showToast(command === 'backup_restore' ? 'Restore failed.' : 'Download failed.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
async function rollbackQuicksave(hash) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash) return;
if (!window.confirm('Rollback to this quicksave? Changes will be applied to the live site.')) return;
const loadingKey = 'quicksave-rollback:' + hash;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/quicksaves/' + encodeURIComponent(hash) + '/rollback', {
method: 'POST',
body: JSON.stringify({ site_id: id, environment: environmentSlug(env) }),
});
showToast('Rollback started.');
} catch (error) {
showToast('Rollback failed.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
async function loadQuicksaveDetail(hash) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash) return;
const bucket = siteFeature(id, env);
const key = quicksaveDetailKey(hash);
if (bucket.quicksaveDetails[key] || bucket.quicksaveDetailsLoading[key]) return;
bucket.quicksaveDetailsLoading[key] = true;
bucket.quicksaveDetailsError[key] = '';
render();
try {
const params = new URLSearchParams({ site_id: id, environment: environmentSlug(env) });
bucket.quicksaveDetails[key] = await apiFetch('/wp-json/captaincore/v1/quicksaves/' + encodeURIComponent(hash) + '?' + params.toString()) || {};
} catch (error) {
bucket.quicksaveDetailsError[key] = error.message || 'Unable to load quicksave details.';
bucket.quicksaveDetails[key] = null;
} finally {
bucket.quicksaveDetailsLoading[key] = false;
render();
}
}
async function toggleQuicksaveDetail(hash) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash) return;
const bucket = siteFeature(id, env);
if (bucket.expandedQuicksave === hash) {
bucket.expandedQuicksave = '';
bucket.expandedQuicksaveFiles = '';
render();
return;
}
bucket.expandedQuicksave = hash;
loadQuicksaveDetail(hash);
loadQuicksaveFiles(hash);
render();
}
async function loadUpdateLogDetail(hashBefore, hashAfter) {
const { id, env } = currentSiteContext();
if (!id || !env || !hashBefore || !hashAfter) return;
const bucket = siteFeature(id, env);
const key = updateLogDetailKey(hashBefore, hashAfter);
if (bucket.quicksaveDetails[key] || bucket.quicksaveDetailsLoading[key]) return;
bucket.quicksaveDetailsLoading[key] = true;
bucket.quicksaveDetailsError[key] = '';
render();
try {
const params = new URLSearchParams({ site_id: id, environment: environmentSlug(env) });
bucket.quicksaveDetails[key] = await apiFetch('/wp-json/captaincore/v1/update-logs/' + encodeURIComponent(hashBefore + '_' + hashAfter) + '?' + params.toString()) || {};
} catch (error) {
bucket.quicksaveDetailsError[key] = error.message || 'Unable to load update details.';
bucket.quicksaveDetails[key] = null;
} finally {
bucket.quicksaveDetailsLoading[key] = false;
render();
}
}
async function toggleUpdateLog(hashBefore, hashAfter) {
if (!hashBefore || !hashAfter) {
showToast('Update log hashes are missing.');
return;
}
const { id, env } = currentSiteContext();
if (!id || !env) return;
const bucket = siteFeature(id, env);
const key = updateLogDetailKey(hashBefore, hashAfter);
if (bucket.expandedUpdate === key) {
bucket.expandedUpdate = '';
render();
return;
}
bucket.expandedUpdate = key;
loadUpdateLogDetail(hashBefore, hashAfter);
loadQuicksaveFiles(hashAfter);
render();
}
async function loadQuicksaveFiles(hash, force = false) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash) return;
const bucket = siteFeature(id, env);
if (bucket.quicksaveFilesLoading[hash] || (bucket.quicksaveFiles[hash] && !force)) return;
bucket.quicksaveFilesLoading[hash] = true;
bucket.quicksaveFilesError[hash] = '';
render();
try {
const params = new URLSearchParams({ site_id: id, environment: environmentSlug(env) });
const payload = await apiFetch('/wp-json/captaincore/v1/quicksaves/' + encodeURIComponent(hash) + '/changed?' + params.toString());
bucket.quicksaveFiles[hash] = parseChangedFiles(payload);
} catch (error) {
bucket.quicksaveFilesError[hash] = error.message || 'Unable to load changed files.';
bucket.quicksaveFiles[hash] = [];
} finally {
bucket.quicksaveFilesLoading[hash] = false;
render();
}
}
async function toggleQuicksaveFiles(hash) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash) return;
const bucket = siteFeature(id, env);
if (bucket.expandedQuicksaveFiles === hash) {
bucket.expandedQuicksaveFiles = '';
render();
return;
}
bucket.expandedQuicksaveFiles = hash;
bucket.expandedQuicksave = hash;
loadQuicksaveDetail(hash);
loadQuicksaveFiles(hash);
render();
}
async function loadComponentFiles(hash, match, detailKey, force = false) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash || !match || !detailKey) return;
const bucket = siteFeature(id, env);
const key = detailKey + ':' + match;
if (bucket.componentFilesLoading[key] || (bucket.componentFiles[key] && !force)) return;
bucket.componentFilesLoading[key] = true;
bucket.componentFilesError[key] = '';
render();
try {
const params = new URLSearchParams({ site_id: id, environment: environmentSlug(env), match });
const payload = await apiFetch('/wp-json/captaincore/v1/quicksaves/' + encodeURIComponent(hash) + '/changed?' + params.toString());
bucket.componentFiles[key] = parseChangedFiles(payload);
} catch (error) {
bucket.componentFilesError[key] = error.message || 'Unable to load component files.';
bucket.componentFiles[key] = [];
} finally {
bucket.componentFilesLoading[key] = false;
render();
}
}
async function toggleComponentFiles(hash, match, detailKey) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash || !match || !detailKey) return;
const bucket = siteFeature(id, env);
const key = detailKey + ':' + match;
if (bucket.expandedComponents[key]) {
delete bucket.expandedComponents[key];
render();
return;
}
bucket.expandedComponents[key] = true;
loadComponentFiles(hash, match, detailKey);
render();
}
function toggleFileGroup(hash, groupKey) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash || !groupKey) return;
const bucket = siteFeature(id, env);
const key = hash + ':' + groupKey;
if (bucket.expandedFileGroups[key]) delete bucket.expandedFileGroups[key];
else bucket.expandedFileGroups[key] = true;
render();
}
async function openQuicksaveDiff(hash, fileName) {
const { id, env } = currentSiteContext();
if (!id || !env || !hash || !fileName) return;
const cleanName = cleanFileName(fileName);
state.modal = 'quicksave-diff';
state.fileDiff = { hash, file: cleanName, content: '', loading: true, error: '' };
render();
try {
const params = new URLSearchParams({ site_id: id, environment: environmentSlug(env), file: cleanName });
const payload = await apiFetch('/wp-json/captaincore/v1/quicksaves/' + encodeURIComponent(hash) + '/filediff?' + params.toString());
state.fileDiff.content = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
} catch (error) {
state.fileDiff.error = error.message || 'Unable to load file diff.';
} finally {
state.fileDiff.loading = false;
render();
}
}
function getEnvironments(site) {
const envs = Array.isArray(site?.environments) ? site.environments.slice() : [];
return envs.sort((a, b) => {
if (a.environment === 'Production') return -1;
if (b.environment === 'Production') return 1;
return String(a.environment || '').localeCompare(String(b.environment || ''));
});
}
function primaryEnv(site) {
const envs = getEnvironments(site);
return envs.find(env => env.environment === 'Production') || envs[0] || {};
}
function envKey(site, env) {
return [site?.site_id || site?.id || site?.site || '', env?.environment_id || env?.environment || 'primary'].join(':');
}
function screenshotUrl(site, env, size = 100) {
if (size >= 800 && env?.screenshots?.large) return cleanAssetUrl(env.screenshots.large);
if (size <= 100 && env?.screenshots?.small) return cleanAssetUrl(env.screenshots.small);
if (!site || !env || !env.screenshot_base || !CC.remote_upload_uri) return '';
const envName = String(env.environment || 'production').toLowerCase();
const base = String(CC.remote_upload_uri || '').replace(/\/+$/, '');
return cleanAssetUrl(base + '/' + site.site + '_' + site.site_id + '/' + envName + '/screenshots/' + env.screenshot_base + '_thumb-' + size + '.jpg');
}
function activeFilters() {
return state.selectedCore.length || state.selectedThemes.length || state.selectedPlugins.length;
}
function visibleEnvironments(site) {
let envs = getEnvironments(site);
if (state.envFilter === 'production') envs = envs.filter(env => env.environment === 'Production');
if (state.envFilter === 'staging') envs = envs.filter(env => env.environment !== 'Production');
if (state.filteredEnvIds) {
envs = envs.filter(env => state.filteredEnvIds.has(Number(env.environment_id)) || state.filteredEnvIds.has(String(env.environment_id)));
}
return envs;
}
function siteSortEnvironment(site) {
const envs = visibleEnvironments(site);
return envs.find(env => env.environment === 'Production') || envs[0] || {};
}
function sortVisibleSites(sites) {
const sorters = {
site: {
type: 'text',
get: site => site.name || site.site || '',
},
core: {
type: 'version',
get: site => siteSortEnvironment(site).core || '',
},
visits: {
type: 'number',
get: site => Number(siteSortEnvironment(site).visits) || 0,
},
storage: {
type: 'number',
get: site => Number(siteSortEnvironment(site).storage) || 0,
},
subsites: {
type: 'number',
get: site => Number(siteSortEnvironment(site).subsite_count) || 0,
},
status: {
type: 'text',
get: site => environmentStatus(siteSortEnvironment(site)).label,
},
};
return sortRows('sites', sites, sorters);
}
function visibleSites() {
const q = normalizeText(state.search);
let result = state.sites.slice();
if (state.filteredSiteIds) {
result = result.filter(site => state.filteredSiteIds.has(Number(site.site_id)) || state.filteredSiteIds.has(String(site.site_id)));
}
if (q) {
result = result.filter(site => {
const haystack = [
site.name, site.site, site.account, site.customer, site.customer_name,
...getEnvironments(site).flatMap(env => [env.home_url, env.environment, env.core, env.php_version]),
].map(normalizeText).join(' ');
return haystack.includes(q);
});
}
result = result.filter(site => visibleEnvironments(site).length > 0);
return sortVisibleSites(result);
}
function summaryStats() {
const sites = state.sites;
const environments = sites.flatMap(getEnvironments);
const production = environments.filter(env => env.environment === 'Production').length;
const staging = environments.length - production;
const visits = environments.reduce((sum, env) => sum + (Number(env.visits) || 0), 0);
const storage = environments.reduce((sum, env) => sum + (Number(env.storage) || 0), 0);
return { sites: sites.length, environments: environments.length, production, staging, visits, storage };
}
function renderChrome() {
const name = CC.configurations?.name || 'CaptainCore';
const logo = CC.configurations?.logo || '';
const logoWidth = CC.configurations?.logo_width || '';
const logoOnly = CC.configurations?.logo_only === true;
const logoEl = document.querySelector('[data-brand-logo]');
const nameEl = document.querySelector('[data-brand-name]');
if (logo) {
logoEl.hidden = false;
logoEl.src = logo;
logoEl.style.maxWidth = logoWidth ? logoWidth + 'px' : '';
logoEl.alt = name;
} else {
logoEl.hidden = true;
}
nameEl.hidden = !!logo && logoOnly;
nameEl.textContent = name;
document.querySelector('[data-user-avatar]').textContent = initials(CC.user?.display_name || CC.user?.login);
document.querySelector('[data-user-name]').textContent = CC.user?.display_name || CC.user?.login || '';
const accountMenuButton = document.querySelector('.user-chip[data-popover="account-menu"]');
if (accountMenuButton) {
const isOpen = state.popover === 'account-menu';
accountMenuButton.classList.toggle('active', isOpen);
accountMenuButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
const links = [
['/sites', 'Sites', true],
['/domains', 'Domains', true],
['/accounts', 'Accounts', true],
['/billing', 'Billing', !!CC.modules?.billing],
].filter(item => item[2]);
navRegion.innerHTML = links.map(([route, label]) => {
const active = state.route === route || state.route.startsWith(route + '/');
return '<a href="' + attr(routeUrl(route)) + '" data-route="' + attr(route) + '" class="' + (active ? 'active' : '') + '">' + esc(label) + '</a>';
}).join('');
const menuLink = ([route, label]) => {
const active = state.route === route || state.route.startsWith(route + '/');
return '<a href="' + attr(routeUrl(route)) + '" data-route="' + attr(route) + '" role="menuitem" class="' + (active ? 'active' : '') + '">' + esc(label) + '</a>';
};
const adminLinks = adminNavItems();
accountMenuRegion.classList.toggle('open', state.popover === 'account-menu');
accountMenuRegion.innerHTML = `
<div class="account-dropdown__section">
<div class="account-dropdown__label">Account</div>
${accountNavItems().map(menuLink).join('')}
<button type="button" data-action="sign-out" role="menuitem">Logout</button>
</div>
${adminLinks.length ? `
<div class="account-dropdown__section">
<div class="account-dropdown__label">Admin</div>
${adminLinks.map(menuLink).join('')}
</div>
` : ''}
`;
}
function render() {
renderChrome();
app.classList.toggle('is-login', state.route === '/login' || !state.nonce);
document.body.classList.toggle('cc-v2-lock', !!state.drawer || !!state.modal || (state.terminal.open && state.terminal.fullscreen));
if (state.route === '/login' || !state.nonce) {
main.className = '';
main.innerHTML = renderLogin();
} else {
main.className = 'app-shell';
if (state.route.startsWith('/sites/')) main.innerHTML = renderSiteDetail();
else if (state.route.startsWith('/sites')) main.innerHTML = renderSites();
else if (state.route.startsWith('/domains/')) main.innerHTML = renderDomainDetail();
else if (state.route.startsWith('/domains')) main.innerHTML = renderDirectory('Domains', 'domains');
else if (state.route.startsWith('/accounts/')) main.innerHTML = renderAccountDetail();
else if (state.route.startsWith('/accounts')) main.innerHTML = renderDirectory('Accounts', 'accounts');
else if (state.route.startsWith('/cookbook')) main.innerHTML = renderCookbook();
else if (state.route.startsWith('/configurations')) main.innerHTML = renderConfigurations();
else if (state.route.startsWith('/providers') || state.route.startsWith('/connect')) main.innerHTML = renderProvidersConnect();
else if (state.route.startsWith('/defaults')) main.innerHTML = renderDefaults();
else if (state.route.startsWith('/users')) main.innerHTML = renderUsers();
else if (state.route.startsWith('/keys')) main.innerHTML = renderKeys();
else if (state.route.startsWith('/handbook')) main.innerHTML = renderHandbook();
else if (state.route.startsWith('/reports')) main.innerHTML = renderReports();
else if (state.route.startsWith('/archives')) main.innerHTML = renderArchives();
else if (state.route.startsWith('/activity-logs')) main.innerHTML = renderActivityLogs();
else if (state.route.startsWith('/site-audits')) main.innerHTML = renderSiteAudits();
else if (state.route.startsWith('/security')) main.innerHTML = renderSecurity();
else if (state.route.startsWith('/profile')) main.innerHTML = renderProfile();
else if (state.route.startsWith('/invite')) main.innerHTML = renderInviteAcceptance();
else if (state.route.startsWith('/billing') || state.route.startsWith('/subscriptions')) main.innerHTML = renderBilling();
else main.innerHTML = renderSites();
}
renderBulkBar();
renderDrawer();
renderModal();
renderTerminal();
}
function renderLogin() {
return `
<div class="login-shell">
<form class="login-panel" data-action="sign-in">
<h1>${esc(CC.configurations?.name || 'CaptainCore')}</h1>
<p class="sub">Account access</p>
<div class="form-grid">
${state.login.errors ? '<div class="alert error">' + esc(state.login.errors) + '</div>' : ''}
${state.login.info ? '<div class="alert info">' + esc(state.login.info) + '</div>' : ''}
<div class="form-field">
<label for="cc-login-user">Username</label>
<input id="cc-login-user" name="user_login" autocomplete="username" value="${attr(state.login.user_login)}">
</div>
<div class="form-field">
<label for="cc-login-password">Password</label>
<input id="cc-login-password" name="user_password" type="password" autocomplete="current-password" value="${attr(state.login.user_password)}">
</div>
${state.login.info ? '<div class="form-field"><label for="cc-login-tfa">Two-factor code</label><input id="cc-login-tfa" name="tfa_code" inputmode="numeric" value="' + attr(state.login.tfa_code) + '"></div>' : ''}
<button class="primary-btn" type="submit">${state.login.loading ? 'Signing in' : 'Sign In'}</button>
</div>
</form>
</div>
`;
}
function renderPageHeader(title, sub, pills = '') {
return `
<div class="summary">
<div class="summary__title">
<h1>${esc(title)}</h1>
<span class="summary__sub">${esc(sub)}</span>
</div>
<div class="summary__pills">${pills}</div>
</div>
`;
}
function tabCount(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? formatNumber(n) : '';
}
function renderSiteTabs(tabs, active) {
return `
<nav class="site-detail-tabs" aria-label="Site sections">
${tabs.map(tab => `
<button type="button" class="${tab.key === active ? 'active' : ''}" data-action="detail-tab" data-detail-type="site" data-tab="${attr(tab.key)}">
${tab.icon || ''}
<span>${esc(tab.label)}</span>
${tab.count ? '<span class="site-tab-count">' + esc(tab.count) + '</span>' : ''}
</button>
`).join('')}
</nav>
`;
}
function renderSiteKv(label, value, action = '') {
const display = value == null || value === '' ? '-' : String(value);
return `
<div class="site-kv">
<div>
<div class="site-kv__label">${esc(label)}</div>
<div class="site-kv__value">${esc(display)}</div>
</div>
${action}
</div>
`;
}
function maskedSecret(value) {
const length = Math.min(16, Math.max(8, String(value || '').length));
return '*'.repeat(length);
}
function renderSiteSecretSection(key, title, icon, rows) {
const cleanRows = rows.filter(row => row && row.value !== undefined && row.value !== null && row.value !== '');
if (!cleanRows.length) return '';
const revealed = !!state.revealedSecrets[key];
return `
<div class="site-secret-section">
<div class="site-secret-section__head">
<div class="site-secret-title">${icon || ''}<span>${esc(title)}</span></div>
<button class="site-secret-toggle" type="button" data-action="toggle-site-secret" data-secret-key="${attr(key)}">${icons.eye}<span>${revealed ? 'Hide' : 'Reveal'}</span></button>
</div>
${cleanRows.map(row => {
const value = String(row.value);
const display = row.secret && !revealed ? maskedSecret(value) : value;
const copy = row.copy === false ? '' : '<button class="site-icon-btn" type="button" data-action="copy-value" data-value="' + attr(value) + '" title="Copy" aria-label="Copy">' + icons.copy + '</button>';
return `
<div class="site-secret-row">
<div class="site-secret-row__label">${esc(row.label)}</div>
<div class="site-secret-row__value ${row.secret && !revealed ? 'masked' : ''}">${esc(display)}</div>
${copy}
</div>
`;
}).join('')}
</div>
`;
}
function renderSharedWith(items) {
const accounts = asArray(items);
if (!accounts.length) return '';
return `
<div>
<div class="site-subhead">Shared With</div>
<div class="site-shared-grid">
${accounts.map(account => `
<div class="site-shared-pill">
<div>
<strong>${esc(account.name || 'Account')}</strong>
<span>${account.account_id ? 'Account #' + esc(account.account_id) : 'Account'}</span>
</div>
${icons.users}
</div>
`).join('')}
</div>
</div>
`;
}
function copyValue(value) {
const text = String(value || '');
if (!text) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => showToast('Copied.'));
}
}
function renderSites() {
const stats = summaryStats();
const filtered = visibleSites();
const start = (state.page - 1) * state.perPage;
const paginated = filtered.slice(start, start + state.perPage);
const pages = Math.max(1, Math.ceil(filtered.length / state.perPage));
if (state.page > pages) state.page = pages;
const pills = `
<span class="pill"><span class="dot ok"></span><b>${formatNumber(stats.production)}</b> production</span>
<span class="pill"><span class="dot warn"></span><b>${formatNumber(stats.staging)}</b> staging</span>
<span class="pill"><b>${formatStorage(stats.storage)}</b> storage</span>
<span class="pill"><b>${formatNumber(stats.visits)}</b> visits</span>
`;
return `
${renderPageHeader('Sites', `${formatNumber(stats.sites)} sites - ${formatNumber(stats.environments)} environments`, pills)}
${renderToolbar('Search sites, URLs, accounts...', { action: 'open-new-site', label: 'New Site', adminOnly: true })}
${renderFilters()}
${state.error.sites ? renderError(state.error.sites, 'refresh-sites') : ''}
${state.loading.sites ? renderLoading('Loading sites') : ''}
${!state.loading.sites && !state.error.sites ? (state.viewMode === 'grid' ? renderSiteGrid(paginated, filtered.length, pages) : renderSiteList(paginated, filtered.length, pages)) : ''}
`;
}
function renderToolbar(placeholder, addButton) {
const add = addButton || null;
const showAdd = add && (!add.adminOnly || isAdministrator());
return `
<div class="toolbar">
<div class="search-input">
${icons.search}
<input id="cc-v2-search" type="search" value="${attr(state.search)}" placeholder="${attr(placeholder)}" data-input="search">
<span class="scope">${state.envFilter === 'all' ? 'all environments' : state.envFilter}</span>
</div>
<div class="segmented" role="tablist" aria-label="View">
<button type="button" class="${state.viewMode === 'list' ? 'active' : ''}" data-action="set-view" data-view="list" title="List" aria-label="List">${icons.list}</button>
<button type="button" class="${state.viewMode === 'grid' ? 'active' : ''}" data-action="set-view" data-view="grid" title="Grid" aria-label="Grid">${icons.grid}</button>
</div>
${showAdd ? '<button class="primary-btn" type="button" data-action="' + attr(add.action) + '">' + icons.plus + ' ' + esc(add.label) + '</button>' : ''}
</div>
`;
}
function renderFilters() {
return `
<div class="chips">
<button class="chip-filter ${state.envFilter === 'all' ? 'active' : ''}" type="button" data-action="env-filter" data-filter="all">All <span class="count">${formatNumber(state.sites.length)}</span></button>
<button class="chip-filter ${state.envFilter === 'production' ? 'active' : ''}" type="button" data-action="env-filter" data-filter="production">Production</button>
<button class="chip-filter ${state.envFilter === 'staging' ? 'active' : ''}" type="button" data-action="env-filter" data-filter="staging">Staging</button>
${renderFilterPopover('core', 'Core', state.selectedCore, CC.site_filters_core || [])}
${renderFilterPopover('themes', 'Theme', state.selectedThemes, (CC.site_filters || []).filter(f => f.type === 'themes'))}
${renderFilterPopover('plugins', 'Plugin', state.selectedPlugins, (CC.site_filters || []).filter(f => f.type === 'plugins'))}
${activeFilters() ? '<button class="chip-filter" type="button" data-action="clear-filters">Clear</button>' : ''}
</div>
${renderFilterLogicControls()}
${renderSecondaryFilters()}
`;
}
function renderFilterPopover(type, label, selected, options) {
const selectedNames = new Set(selected.map(item => item.name));
const q = normalizeText(state.filterSearch[type]);
const filtered = options.filter(item => !q || normalizeText(item.search || item.title || item.name).includes(q)).slice(0, 80);
return `
<div class="chip-wrap">
<button class="chip-filter ${selected.length ? 'active' : ''}" type="button" data-action="toggle-popover" data-popover="${attr(type)}">
${esc(label)} ${selected.length ? '<span class="count">' + selected.length + '</span>' : ''}
</button>
<div class="filter-pop ${state.popover === type ? 'open' : ''}" data-popover-panel="${attr(type)}">
<div class="filter-pop__head">
<div class="meta-label">${esc(label)}</div>
<div class="filter-search">${icons.search}<input type="search" value="${attr(state.filterSearch[type])}" data-input="filter-search" data-filter-type="${attr(type)}" placeholder="Search ${attr(label.toLowerCase())}"></div>
</div>
<div class="filter-list">
${filtered.length ? filtered.map((item, index) => `
<button type="button" class="filter-option ${selectedNames.has(item.name) ? 'active' : ''}" data-action="toggle-filter" data-filter-type="${attr(type)}" data-filter-index="${index}">
<span class="name">${esc(item.title || item.name)}</span>
<span class="muted">${esc(item.count || item.name || '')}</span>
</button>
`).join('') : '<div class="empty-mini">No matches</div>'}
</div>
</div>
</div>
`;
}
function renderFilterToggle(action, active, values) {
return '<div class="filter-toggle">' + values.map(item => '<button type="button" class="' + (active === item.value ? 'active' : '') + '" data-action="' + attr(action) + '" data-value="' + attr(item.value) + '">' + esc(item.label) + '</button>').join('') + '</div>';
}
function selectedComponentFilters() {
return state.selectedThemes.concat(state.selectedPlugins);
}
function totalAdvancedFilters() {
return state.selectedCore.length + selectedComponentFilters().length;
}
function selectedSecondaryFilters(kind) {
const key = kind === 'status' ? 'selected_statuses' : 'selected_versions';
return selectedComponentFilters().flatMap(filter => asArray(filter[key]));
}
function renderFilterLogicControls() {
const controls = [];
if (totalAdvancedFilters() > 1) {
controls.push(`
<div class="filter-control">
<span class="filter-control__label">Primary</span>
${renderFilterToggle('filter-logic', state.filterLogic, [{ value: 'and', label: 'AND' }, { value: 'or', label: 'OR' }])}
</div>
`);
}
if (selectedSecondaryFilters('version').length) {
controls.push(`
<div class="filter-control">
<span class="filter-control__label">Version</span>
${renderFilterToggle('filter-version-mode', state.filterVersionMode, [{ value: 'include', label: 'is' }, { value: 'exclude', label: 'is not' }])}
${selectedSecondaryFilters('version').length > 1 && state.filterVersionMode === 'include' ? renderFilterToggle('filter-version-logic', state.filterVersionLogic, [{ value: 'and', label: 'AND' }, { value: 'or', label: 'OR' }]) : ''}
</div>
`);
}
if (selectedSecondaryFilters('status').length) {
controls.push(`
<div class="filter-control">
<span class="filter-control__label">Status</span>
${renderFilterToggle('filter-status-mode', state.filterStatusMode, [{ value: 'include', label: 'is' }, { value: 'exclude', label: 'is not' }])}
${selectedSecondaryFilters('status').length > 1 && state.filterStatusMode === 'include' ? renderFilterToggle('filter-status-logic', state.filterStatusLogic, [{ value: 'and', label: 'AND' }, { value: 'or', label: 'OR' }]) : ''}
</div>
`);
}
return controls.length ? '<div class="filter-logic-bar">' + controls.join('') + '</div>' : '';
}
function getVersionsForFilter(name) {
const match = state.siteFilterVersions.find(item => item && item.name === name);
return asArray(match?.versions);
}
function getStatusesForFilter(name) {
const match = state.siteFilterStatuses.find(item => item && item.name === name);
return asArray(match?.statuses);
}
function isSecondarySelected(filter, kind, option) {
const key = kind === 'status' ? 'selected_statuses' : 'selected_versions';
return asArray(filter[key]).some(item => item.name === option.name && item.slug === option.slug && item.type === option.type);
}
function renderSecondaryOption(filter, kind, option, index) {
const selected = isSecondarySelected(filter, kind, option);
const action = kind === 'status' ? 'toggle-filter-status' : 'toggle-filter-version';
return '<button type="button" class="filter-mini ' + (selected ? 'active' : '') + '" data-action="' + action + '" data-filter-type="' + attr(filter.type) + '" data-filter-name="' + attr(filter.name) + '" data-index="' + index + '">' + esc(option.name) + (option.count ? ' ' + formatNumber(option.count) : '') + '</button>';
}
function renderSecondaryFilters() {
const filters = selectedComponentFilters();
if (!filters.length) return '';
if (state.filterOptionsLoading && !state.siteFilterVersions.length && !state.siteFilterStatuses.length) {
return '<div class="filter-secondary"><div class="filter-drill"><div class="empty-mini">Loading version and status filters...</div></div></div>';
}
return `
<div class="filter-secondary">
${filters.map(filter => {
const versions = getVersionsForFilter(filter.name);
const statuses = getStatusesForFilter(filter.name);
return `
<div class="filter-drill">
<div class="filter-drill__head">
<div class="filter-drill__title">
<strong>${esc(filter.title || filter.name)}</strong>
<span>${filter.type === 'themes' ? 'Theme' : 'Plugin'} ${esc(filter.name)}</span>
</div>
${statusBadge(filter.type === 'themes' ? 'Theme' : 'Plugin')}
</div>
${versions.length ? `
<div class="filter-drill__group">
<div class="filter-control__label">Versions</div>
<div class="filter-drill__options">${versions.map((option, index) => renderSecondaryOption(filter, 'version', option, index)).join('')}</div>
</div>
` : ''}
${statuses.length ? `
<div class="filter-drill__group">
<div class="filter-control__label">Statuses</div>
<div class="filter-drill__options">${statuses.map((option, index) => renderSecondaryOption(filter, 'status', option, index)).join('')}</div>
</div>
` : ''}
${!versions.length && !statuses.length ? '<div class="empty-mini">' + (state.filterOptionsError ? esc(state.filterOptionsError) : 'No version or status choices.') + '</div>' : ''}
</div>
`;
}).join('')}
</div>
`;
}
function renderSiteList(sites, total, pages) {
if (!sites.length) return renderEmpty('No sites match the current view.');
return `
<div class="sites">
<div class="sites__head">
<div><input type="checkbox" aria-label="Select all" data-action="select-all" ${allVisibleSelected(sites) ? 'checked' : ''}></div>
<div>${renderSortButton('sites', 'site', 'Site')}</div>
<div class="col-wp">${renderSortButton('sites', 'core', 'WP / PHP')}</div>
<div class="col-visits">${renderSortButton('sites', 'visits', 'Visits', 'desc')}</div>
<div class="col-storage">${renderSortButton('sites', 'storage', 'Storage', 'desc')}</div>
<div class="col-backup">${renderSortButton('sites', 'subsites', 'Subsites', 'desc')}</div>
<div>${renderSortButton('sites', 'status', 'Status')}</div>
<div></div>
</div>
${sites.map(renderSiteGroup).join('')}
<div class="sites__foot">
<span>Showing ${formatNumber(sites.length)} of ${formatNumber(total)} sites</span>
<div class="pager">
<button type="button" data-action="prev-page" ${state.page <= 1 ? 'disabled' : ''} aria-label="Previous">Prev</button>
<button type="button" disabled>${state.page} / ${pages}</button>
<button type="button" data-action="next-page" ${state.page >= pages ? 'disabled' : ''} aria-label="Next">Next</button>
</div>
</div>
</div>
`;
}
function renderSiteGroup(site) {
const envs = visibleEnvironments(site);
const account = site.customer_name || site.account || site.customer || '';
const siteId = site.site_id || site.id || '';
const title = site.name || site.site || 'Untitled site';
return `
<div class="site-group">
<div class="site-group__bar">
${siteId ? '<a class="domain" href="' + attr(routeUrl('/sites/' + siteId)) + '" data-route="/sites/' + attr(siteId) + '">' + esc(title) + '</a>' : '<span class="domain">' + esc(title) + '</span>'}
<span class="meta">${envs.length} environment${envs.length === 1 ? '' : 's'}${account ? ' - ' + esc(account) : ''}</span>
</div>
${envs.map(env => renderEnvironmentRow(site, env)).join('')}
</div>
`;
}
function renderEnvironmentRow(site, env) {
const siteId = site.site_id || site.id || '';
const key = envKey(site, env);
const selected = state.selection.has(key);
const screenshot = screenshotUrl(site, env, 100);
const envName = env.environment || 'Production';
const production = envName === 'Production';
const status = environmentStatus(env);
return `
<div role="button" tabindex="0" class="site-row ${selected ? 'selected' : ''}" data-action="open-drawer" data-site-id="${attr(site.site_id)}" data-env-id="${attr(env.environment_id || envName)}">
<div><input type="checkbox" aria-label="Select" ${selected ? 'checked' : ''} data-action="toggle-select" data-site-id="${attr(site.site_id)}" data-env-id="${attr(env.environment_id || envName)}"></div>
<div class="site-row__main">
${screenshot ? '<img class="site-thumb" src="' + attr(screenshot) + '" alt="" loading="lazy">' : '<div class="site-thumb placeholder">' + icons.monitor + '</div>'}
<div class="site-info">
<div class="url">${esc(formatUrl(env.home_url || site.site || site.name))}</div>
<div class="badges">
<span class="badge ${production ? 'production' : 'staging'}">${esc(envName)}</span>
${env.core ? '<span class="badge">WP ' + esc(env.core) + '</span>' : ''}
${env.php_version ? '<span>PHP ' + esc(env.php_version) + '</span>' : ''}
</div>
</div>
</div>
<div class="metric col-wp"><span class="label">Core</span><span class="val">${esc(env.core || '-')}</span></div>
<div class="metric col-visits"><span class="label">Visits</span><span class="val">${formatNumber(env.visits)}</span></div>
<div class="metric col-storage"><span class="label">Storage</span><span class="val">${formatStorage(env.storage)}</span></div>
<div class="metric col-backup"><span class="label">Subsites</span><span class="val">${formatNumber(env.subsite_count || 0)}</span></div>
<div class="status-cell"><span class="dot ${status.dot}"></span>${esc(status.label)}</div>
<div class="row-actions">
${siteId ? '<a class="row-action" href="' + attr(routeUrl('/sites/' + siteId)) + '" title="Manage" aria-label="Manage site" data-route="/sites/' + attr(siteId) + '">' + icons.settings + '</a>' : ''}
<button type="button" class="row-action" title="WP Admin" aria-label="WP Admin" data-action="magic-login" data-site-id="${attr(site.site_id)}" data-env-id="${attr(env.environment_id || envName)}">${icons.login}</button>
${env.home_url ? '<button type="button" class="row-action" title="Open" aria-label="Open" data-action="open-url" data-url="' + attr(env.home_url) + '">' + icons.external + '</button>' : ''}
</div>
</div>
`;
}
function environmentStatus(env) {
if (String(env?.status || '').toLowerCase() === 'inactive') return { label: 'Inactive', dot: 'bad' };
if (visibleConsoleErrorsForEnvironment(env).length) return { label: 'Errors', dot: 'warn' };
if (Number(env?.updates) > 0 || Number(env?.plugin_updates) > 0 || Number(env?.theme_updates) > 0) return { label: 'Updates', dot: 'warn' };
return { label: 'Healthy', dot: 'ok' };
}
function renderSiteGrid(sites, total, pages) {
if (!sites.length) return renderEmpty('No sites match the current view.');
return `<div class="grid-sites">${sites.map(site => {
const env = primaryEnv(site);
const shot = screenshotUrl(site, env, 800);
return `
<button type="button" class="grid-card" data-action="open-drawer" data-site-id="${attr(site.site_id)}" data-env-id="${attr(env.environment_id || env.environment || 'Production')}">
<div class="grid-preview">
${shot ? '<img src="' + attr(shot) + '" alt="" loading="lazy">' : '<div class="placeholder">' + icons.monitor + '</div>'}
</div>
<div class="grid-card__body">
<div class="grid-card__title">${esc(site.name || site.site || 'Untitled site')}</div>
<div class="badges">
<span class="badge ${env.environment === 'Production' ? 'production' : 'staging'}">${esc(env.environment || 'Production')}</span>
${env.core ? '<span class="badge">WP ' + esc(env.core) + '</span>' : ''}
</div>
</div>
</button>
`;
}).join('')}</div>
<div class="pagination-foot">
<span>Showing ${formatNumber(sites.length)} of ${formatNumber(total)} sites</span>
<div class="pager">
<button type="button" data-action="prev-page" ${state.page <= 1 ? 'disabled' : ''} aria-label="Previous">Prev</button>
<button type="button" disabled>${state.page} / ${pages}</button>
<button type="button" data-action="next-page" ${state.page >= pages ? 'disabled' : ''} aria-label="Next">Next</button>
</div>
</div>`;
}
function renderSiteDetail() {
const id = routeId('sites');
const key = detailKey('site', id);
const data = state.siteDetails[id];
if (state.loading[key] && !data) return renderLoading('Loading site');
if (state.error[key] && !data) return renderError(state.error[key], 'refresh-site-detail');
if (!data) return renderEmpty('Site not found.');
const listSite = state.sites.find(item => String(item.site_id) === String(id));
const site = Object.assign({}, listSite || {}, data.site || {});
const details = Object.assign({}, data.details || {}, {
account: data.account,
domains: data.domains,
shared_with: data.shared_with,
});
const envs = getEnvironments({ environments: data.environments || site.environments || [] });
let selected = state.detailEnvironments[id];
let env = envs.find(item => String(item.environment_id) === String(selected)) || envs.find(item => String(item.environment) === String(selected));
if (!env && envs.length) {
env = envs.find(item => item.environment === 'Production') || envs[0];
state.detailEnvironments[id] = env.environment_id || env.environment || 'Production';
}
const envDetails = asObject(env?.details);
const addonCount = asArray(env?.plugins).length + asArray(env?.themes).length;
const featureBucket = siteFeature(id, env || {});
const userCount = featureBucket.usersLoaded ? featureBucket.users.length : asArray(env?.users).length;
const consoleErrorCount = visibleConsoleErrorsForEnvironment(env).length;
const backupCount = Number(envDetails.backup_count || 0);
const quicksaveCount = Number(envDetails.quicksave_count || envDetails.quicksave_usage?.count || 0);
const updateLogCount = featureBucket.updatesLoaded ? featureBucket.updates.length : Number(envDetails.update_log_count || envDetails.update_logs_count || 0);
const tabs = [
{ key: 'overview', label: 'Info', icon: icons.info },
{ key: 'stats', label: 'Stats', icon: icons.chart },
{ key: 'logs', label: 'Logs', icon: icons.file, count: tabCount(consoleErrorCount) },
{ key: 'addons', label: 'Addons', icon: icons.plug, count: tabCount(addonCount) },
{ key: 'updates', label: 'Updates', icon: icons.refresh, count: tabCount(updateLogCount) },
{ key: 'users', label: 'Users', icon: icons.users, count: tabCount(userCount) },
{ key: 'scripts', label: 'Scripts', icon: icons.terminal },
{ key: 'backups', label: 'Backups', icon: icons.refresh, count: tabCount(backupCount) },
{ key: 'quicksaves', label: 'Quicksaves', icon: icons.save, count: tabCount(quicksaveCount) },
{ key: 'timeline', label: 'Timeline', icon: icons.clock },
{ key: 'domains', label: 'Domains', icon: icons.globe, count: tabCount(asArray(details.domains).length) },
];
const routeTab = siteTabFromRoute();
if (routeTab) state.detailTabs.site = routeTab;
const active = tabs.some(tab => tab.key === state.detailTabs.site) ? state.detailTabs.site : 'overview';
state.detailTabs.site = active;
ensureSiteFeature(active, id, env);
const panels = {
overview: renderSiteOverview(site, env, details),
stats: renderSiteStats(site, env),
backups: renderSiteBackups(site, env),
quicksaves: renderSiteQuicksaves(site, env),
updates: renderSiteUpdates(site, env),
timeline: renderSiteTimeline(site, env),
addons: renderSiteAddons(env),
domains: renderSiteDomains(details, env),
users: renderSiteUsers(site, env),
logs: renderSiteLogs(details, env),
scripts: renderSiteScripts(site, env),
};
const account = site.account || site.customer_name || site.customer || details.account?.name || '';
const heroShot = env ? screenshotUrl(site, env, 100) : '';
const siteTitle = site.name || site.site || 'Site';
const siteUrl = env?.home_url || env?.link || '';
const created = firstDefined(env?.created_at, site.created_at);
const envLabel = env?.environment || 'Environment';
const envClass = envLabel === 'Production' ? 'production' : 'staging';
return `
<div class="site-detail-shell">
<div class="site-crumb">
<button type="button" data-route="/sites">Sites</button>
<span class="site-crumb__sep">/</span>
<span>${esc(siteTitle)}</span>
</div>
<section class="site-hero">
<div class="site-hero__thumb">${heroShot ? '<img src="' + attr(heroShot) + '" alt="" loading="lazy">' : icons.monitor}</div>
<div class="site-hero__main">
<div class="site-hero__title">
<h1>${esc(siteTitle)}</h1>
${env ? '<span class="site-env-badge ' + envClass + '">' + esc(envLabel) + '</span>' : ''}
</div>
<div class="site-hero__meta">
${icons.globe}
${siteUrl ? '<button type="button" data-action="open-url" data-url="' + attr(siteUrl) + '">' + esc(siteUrl) + '</button>' : '<span>No primary URL</span>'}
${created ? '<span>-</span><span>created ' + esc(formatDate(created)) + '</span>' : ''}
${account ? '<span>-</span><span>' + esc(account) + '</span>' : ''}
</div>
</div>
<div class="site-hero__actions">
${siteUrl ? '<button class="site-action-btn" type="button" data-action="open-url" data-url="' + attr(siteUrl) + '">' + icons.external + '<span>Open</span></button>' : ''}
${env ? '<button class="site-action-btn primary" type="button" data-action="magic-login" data-site-id="' + attr(site.site_id || id) + '" data-env-id="' + attr(env.environment_id || env.environment) + '"><span>Login to WordPress</span>' + icons.external + '</button>' : ''}
</div>
</section>
<div class="site-detail-toolbar">
${envs.length ? '<div class="site-env-switch" role="tablist" aria-label="Environment">' + envs.map(item => '<button type="button" class="' + (String(item.environment_id || item.environment) === String(state.detailEnvironments[id]) ? 'active' : '') + '" data-action="select-detail-env" data-site-id="' + attr(id) + '" data-env-id="' + attr(item.environment_id || item.environment) + '">' + esc(item.environment || 'Environment') + '</button>').join('') + '</div>' : '<span class="summary__sub">No environments</span>'}
<div class="site-toolbar-actions">
<button class="site-action-btn" type="button" data-action="refresh-site-detail">${icons.refresh}<span>Refresh</span></button>
${env ? '<button class="site-action-btn" type="button" data-action="open-terminal-current">' + icons.terminal + '<span>Terminal</span></button>' : ''}
<button class="site-action-btn" type="button" data-action="detail-tab" data-detail-type="site" data-tab="timeline">${icons.clock}<span>Timeline</span></button>
</div>
</div>
<div class="site-detail-body">
${renderSiteTabs(tabs, active)}
<div class="site-detail-panel">${panels[active] || panels.overview}</div>
</div>
</div>
`;
}
function renderSiteOverview(site, env, details) {
if (!env) return renderEmpty('No environment data available.');
const status = environmentStatus(env);
const envDetails = asObject(env.details);
const created = firstDefined(site.created_at, env.created_at, details.created_at);
const updated = firstDefined(site.updated_at, env.updated_at, details.updated_at);
const shot = screenshotUrl(site, env, 800);
const storageLimit = details.account?.plan?.limits?.storage ? Number(details.account.plan.limits.storage) * 1073741824 : 0;
const storageLabel = formatStorage(env.storage) + (storageLimit ? ' used / ' + formatStorage(storageLimit) + ' plan' : ' used');
const phpVersion = firstDefined(env.php_version, envDetails.php_version);
const memoryLimit = firstDefined(env.php_memory, envDetails.php_memory);
const captures = firstDefined(env.captures, envDetails.captures, envDetails.capture_count);
const dbSize = firstDefined(envDetails.db_size, env.database_size);
const sftpKey = 'sftp:' + (env.environment_id || env.environment || routeId('sites'));
const dbKey = 'database:' + (env.environment_id || env.environment || routeId('sites'));
const captureButtonAttrs = 'data-action="open-captures" data-site-id="' + attr(site.site_id || routeId('sites')) + '" data-env-id="' + attr(env.environment_id || env.environment) + '"';
return `
<section class="site-section">
<div class="site-section__head">
<div>
<div class="site-section__sub">/ Info</div>
<h2>Site overview</h2>
</div>
${statusBadge(status.label)}
</div>
<div class="site-overview-grid">
<div class="site-overview-col">
<button class="site-preview-frame" type="button" ${captureButtonAttrs} aria-label="Open visual captures">
${shot ? '<img src="' + attr(shot) + '" alt="" loading="lazy">' : '<div class="site-preview-frame__empty">' + icons.image + '<span>No visual capture yet.</span></div>'}
<span class="site-preview-frame__overlay">${icons.image}<span>Visual captures</span></span>
</button>
<div>
<div class="site-subhead">Site Details</div>
<div class="site-kv-list">
${renderSiteKv('Link', env.home_url || env.link || '-', env.home_url ? '<button class="site-icon-btn" type="button" data-action="open-url" data-url="' + attr(env.home_url) + '" title="Visit site" aria-label="Visit site">' + icons.external + '</button>' : '')}
${renderSiteKv('Created', formatDate(created), '')}
${renderSiteKv('WordPress version', env.core || '-', '<button class="site-icon-btn" type="button" data-action="copy-value" data-value="' + attr(env.core || '') + '" title="Copy" aria-label="Copy">' + icons.copy + '</button>')}
${renderSiteKv('PHP / memory', [phpVersion ? 'PHP ' + phpVersion : '', memoryLimit ? memoryLimit + ' memory' : ''].filter(Boolean).join(' / ') || '-')}
${renderSiteKv('Storage', storageLabel)}
${renderSiteKv('Visits', formatNumber(env.visits))}
${renderSiteKv('Visual captures', captures ? formatNumber(captures) + ' captures' : '-', '<button class="site-icon-btn" type="button" ' + captureButtonAttrs + ' title="View visual captures" aria-label="View visual captures">' + icons.image + '</button>')}
${renderSiteKv('Updated', formatDate(updated))}
</div>
</div>
</div>
<div class="site-overview-col">
<div>
<div class="site-subhead">Connection & Credentials</div>
${renderSiteSecretSection(sftpKey, 'SFTP', icons.lock, [
{ label: 'Address', value: env.address, secret: true },
{ label: 'Username', value: env.username, secret: true },
{ label: 'Password', value: env.password, secret: true },
{ label: 'Protocol / Port', value: [env.protocol, env.port].filter(Boolean).join(' / '), copy: false },
{ label: 'Home directory', value: env.home_directory },
{ label: 'SSH', value: env.ssh },
])}
${renderSiteSecretSection(dbKey, 'Database', icons.database, [
{ label: 'Manager', value: env.database ? 'PHPMyAdmin' : '', copy: false },
{ label: 'DB name', value: env.database_name, secret: true },
{ label: 'DB user', value: env.database_username, secret: true },
{ label: 'DB password', value: env.database_password, secret: true },
{ label: 'DB host', value: env.database_host },
{ label: 'DB size', value: dbSize ? formatStorage(dbSize) : '', copy: false },
])}
</div>
${renderSharedWith(details.shared_with)}
</div>
</div>
</section>
<section class="site-section">
<div class="site-section__sub">/ Site options</div>
<h2>Environment actions</h2>
<div class="site-action-strip" style="margin-top:14px">
<button class="site-action-btn" type="button" data-action="detail-tab" data-detail-type="site" data-tab="domains">${icons.globe}<span>Configure domains</span></button>
<button class="site-action-btn" type="button" data-action="detail-tab" data-detail-type="site" data-tab="backups">${icons.refresh}<span>Manage backups</span></button>
<button class="site-action-btn" type="button" data-action="detail-tab" data-detail-type="site" data-tab="scripts">${icons.terminal}<span>Run scripts</span></button>
<button class="site-action-btn" type="button" data-action="open-terminal-current">${icons.terminal}<span>Open terminal</span></button>
</div>
</section>
`;
}
function isoDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return dateOffsetIso(0);
return date.toISOString().slice(0, 10);
}
function dateFromIso(value) {
const date = new Date(String(value || dateOffsetIso(0)) + 'T00:00:00');
return Number.isNaN(date.getTime()) ? new Date() : date;
}
function applyStatsTimeframe(range) {
const to = dateFromIso(state.siteStats.to_at);
const from = new Date(to);
if (range === '7d') {
state.siteStats.grouping = 'Day';
from.setDate(from.getDate() - 7);
} else if (range === '30d') {
state.siteStats.grouping = 'Day';
from.setDate(from.getDate() - 30);
} else if (range === '5y') {
state.siteStats.grouping = 'Year';
from.setFullYear(from.getFullYear() - 5);
} else {
state.siteStats.grouping = 'Month';
from.setFullYear(from.getFullYear() - 1);
range = '12m';
}
state.siteStats.from_at = isoDate(from);
state.siteStats.to_at = isoDate(to);
state.siteStats.timeframe = range;
refreshStatsForCurrent();
}
function adjustStatsRangeForGrouping(grouping) {
const to = dateFromIso(state.siteStats.to_at);
const from = new Date(to);
if (grouping === 'Hour') from.setDate(from.getDate() - 7);
else if (grouping === 'Day') from.setMonth(from.getMonth() - 1);
else if (grouping === 'Year') from.setFullYear(from.getFullYear() - 5);
else from.setFullYear(from.getFullYear() - 1);
state.siteStats.from_at = isoDate(from);
}
function refreshStatsForCurrent() {
const { id, env } = currentSiteContext();
if (!id || !env) return;
const bucket = siteFeature(id, env);
bucket.statsLoaded = false;
loadSiteFeature('stats', id, env, true);
}
function statsTrackerValue(tracker) {
return String(firstDefined(tracker?.code, tracker?.id, tracker?.fathom_id, tracker?.value));
}
function statsTrackerLabel(tracker) {
return firstDefined(tracker?.domain, tracker?.name, tracker?.site, tracker?.code, tracker?.id, tracker?.fathom_id, 'Tracker');
}
function statsSharingValue(site) {
const value = String(site?.sharing || 'none').toLowerCase();
return ['public', 'private'].includes(value) ? value : 'none';
}
function statsShareUrl(site) {
const id = String(site?.id || '').toLowerCase();
const name = String(site?.name || '').trim();
if (!id || !name) return '';
return 'https://app.usefathom.com/share/' + encodeURIComponent(id) + '/' + encodeURIComponent(name);
}
function renderStatsChart(items) {
const rows = asArray(items).slice(-80);
if (!rows.length) return '';
const width = 760;
const height = 315;
const left = 54;
const right = 18;
const top = 16;
const bottom = 280;
const plotWidth = width - left - right;
const plotHeight = bottom - top;
const max = Math.max(1, ...rows.flatMap(item => [Number(item.visits) || 0, Number(item.pageviews) || 0]));
const point = (item, index, field) => {
const x = left + (rows.length === 1 ? plotWidth / 2 : index * plotWidth / Math.max(1, rows.length - 1));
const y = bottom - ((Number(item[field]) || 0) / max) * plotHeight;
return { x, y };
};
const pageviewPoints = rows.map((item, index) => point(item, index, 'pageviews'));
const visitPoints = rows.map((item, index) => point(item, index, 'visits'));
const pointString = points => points.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ');
const areaPath = points => {
if (!points.length) return '';
return 'M ' + points[0].x.toFixed(1) + ' ' + bottom + ' L ' + points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L ') + ' L ' + points[points.length - 1].x.toFixed(1) + ' ' + bottom + ' Z';
};
const ticks = [0, .25, .5, .75, 1].map(scale => {
const value = Math.round(max * scale);
const y = bottom - scale * plotHeight;
return { value, y };
});
const labelIndexes = rows.length <= 8
? rows.map((row, index) => index)
: [0, Math.floor((rows.length - 1) / 2), rows.length - 1];
const hoverPoints = rows.map((item, index) => {
const pagePoint = pageviewPoints[index];
const visitPoint = visitPoints[index];
const previousPoint = pageviewPoints[index - 1];
const nextPoint = pageviewPoints[index + 1];
const zoneLeft = previousPoint ? (previousPoint.x + pagePoint.x) / 2 : 0;
const zoneRight = nextPoint ? (pagePoint.x + nextPoint.x) / 2 : width;
const zoneWidth = Math.max(1, zoneRight - zoneLeft);
const zoneLeftPct = zoneLeft / width * 100;
const zoneWidthPct = zoneWidth / width * 100;
const pointLeftPct = (pagePoint.x - zoneLeft) / zoneWidth * 100;
const chartXPct = pagePoint.x / width * 100;
const yPct = Math.min(pagePoint.y, visitPoint.y) / height * 100;
const edge = chartXPct < 18 ? ' edge-left' : (chartXPct > 82 ? ' edge-right' : '');
return `
<button class="stats-hover-point${edge}" type="button" style="left:${zoneLeftPct.toFixed(4)}%;width:${zoneWidthPct.toFixed(4)}%;--point-left:${pointLeftPct.toFixed(4)}%;--point-top:${yPct.toFixed(4)}%" aria-label="${attr((item.date || 'Period') + ' stats')}">
<span class="stats-tooltip">
<strong>${esc(item.date || 'Period')}</strong>
<span class="pageviews">Pageviews: ${formatNumber(item.pageviews)}</span>
<span class="visits">Visitors: ${formatNumber(item.visits)}</span>
</span>
</button>
`;
}).join('');
return `
<div class="stats-chart">
<div class="stats-chart__legend"><span class="stats-key pageviews">Pageviews</span><span class="stats-key">Visitors</span></div>
<div class="stats-chart__plot">
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Pageviews and visitors chart">
${ticks.map(tick => `
<line x1="${left}" y1="${tick.y.toFixed(1)}" x2="${width - right}" y2="${tick.y.toFixed(1)}" stroke="var(--rule)" />
<text class="stats-axis-label" x="${left - 8}" y="${(tick.y + 4).toFixed(1)}" text-anchor="end">${esc(formatCompactNumber(tick.value))}</text>
`).join('')}
<path d="${attr(areaPath(pageviewPoints))}" fill="color-mix(in oklab, var(--ink) 13%, transparent)" />
<path d="${attr(areaPath(visitPoints))}" fill="color-mix(in oklab, var(--brand) 18%, transparent)" />
<polyline points="${attr(pointString(pageviewPoints))}" fill="none" stroke="color-mix(in oklab, var(--ink) 72%, transparent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="${attr(pointString(visitPoints))}" fill="none" stroke="var(--brand)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
${labelIndexes.map(index => {
const p = pageviewPoints[index] || { x: left };
const anchor = index === 0 ? 'start' : (index === rows.length - 1 ? 'end' : 'middle');
return '<text class="stats-axis-label" x="' + p.x.toFixed(1) + '" y="' + (height - 4) + '" text-anchor="' + anchor + '">' + esc(rows[index]?.date || '') + '</text>';
}).join('')}
</svg>
${hoverPoints}
</div>
</div>
`;
}
function renderStatsSharing(siteId, env, stats) {
const fathomSite = asObject(stats.site);
if (!fathomSite.id) return '';
const sharing = statsSharingValue(fathomSite);
const shareUrl = statsShareUrl(fathomSite);
const saving = state.siteStats.shareSaving;
const disabled = saving ? ' disabled' : '';
const chip = (value, label) => '<button class="stats-share-chip ' + (sharing === value ? 'active' : '') + '" type="button" data-action="stats-share" data-sharing="' + attr(value) + '"' + disabled + '>' + esc(label) + '</button>';
return `
<div class="stats-sharing">
<div>
<h3>Sharing</h3>
<p>Stats are powered by <a href="https://usefathom.com" target="_blank" rel="noopener noreferrer">Fathom Analytics</a>. To view the stats dashboard directly, enable a public or private sharing option.</p>
<div class="stats-share-chips" role="group" aria-label="Stats sharing">
${chip('none', 'Off')}
${chip('private', 'Private')}
${chip('public', 'Public')}
</div>
${state.siteStats.shareError ? '<div class="stats-share-error">' + esc(state.siteStats.shareError) + '</div>' : ''}
</div>
<div class="stats-share-panel">
${sharing === 'none' ? '<div><strong>Sharing is off</strong><p>No public dashboard link is exposed.</p></div>' : `
<div>
<strong>Share URL</strong><br>
<a href="${attr(shareUrl)}" target="_blank" rel="noopener noreferrer">${esc(shareUrl)}</a>
</div>
${sharing === 'private' ? `
<div class="stats-share-password">
<label>Share Password
<input type="text" value="${attr(state.siteStats.sharePassword)}" data-input="stats-share-password" autocomplete="off" spellcheck="false">
</label>
<button class="secondary-btn" type="button" data-action="stats-share-save"${disabled}>${saving ? 'Saving' : 'Save'}</button>
</div>
` : ''}
`}
</div>
</div>
`;
}
async function shareStats(sharing) {
const { id, env } = currentSiteContext();
if (!id || !env) return;
const bucket = siteFeature(id, env);
const stats = asObject(bucket.stats);
const fathomSite = asObject(stats.site);
const fathomId = firstDefined(stats.fathom_id, state.siteStats.fathom_id, fathomSite.id);
if (!fathomId) {
state.siteStats.shareError = 'No Fathom tracker is available for this environment.';
render();
return;
}
const nextSharing = ['none', 'private', 'public'].includes(sharing) ? sharing : 'none';
if (nextSharing === 'private' && !state.siteStats.sharePassword) {
state.siteStats.sharePassword = 'changeme';
}
state.siteStats.shareSaving = true;
state.siteStats.shareError = '';
render();
try {
const payload = { fathom_id: fathomId, sharing: nextSharing };
if (nextSharing === 'private' && state.siteStats.sharePassword) {
payload.share_password = state.siteStats.sharePassword;
}
await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(id) + '/stats/share', {
method: 'POST',
body: JSON.stringify(payload),
});
const nextSite = Object.assign({}, fathomSite, { id: fathomSite.id || fathomId, sharing: nextSharing });
bucket.stats = Object.assign({}, stats, { fathom_id: fathomId, site: nextSite });
showToast('Stats sharing is ' + (nextSharing === 'none' ? 'off' : nextSharing) + '.');
} catch (error) {
state.siteStats.shareError = error.message || 'Unable to update stats sharing.';
showToast('Stats sharing update failed.');
} finally {
state.siteStats.shareSaving = false;
render();
}
}
function renderSiteStats(site, env) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env);
const stats = asObject(bucket.stats);
const summary = asObject(stats.summary);
const trackers = asArray(env.fathom_analytics);
const timeframeButtons = [
{ key: '7d', label: '7D' },
{ key: '30d', label: '30D' },
{ key: '12m', label: '12M' },
{ key: '5y', label: '5Y' },
];
const trackerSelect = trackers.length > 1 ? `
<div class="stats-control">
<label>Tracker</label>
<select data-input="stats-filter" data-field="fathom_id" aria-label="Fathom tracker">
<option value="">Default tracker</option>
${trackers.map(tracker => {
const value = statsTrackerValue(tracker);
return '<option value="' + attr(value) + '" ' + (state.siteStats.fathom_id === value ? 'selected' : '') + '>' + esc(statsTrackerLabel(tracker)) + '</option>';
}).join('')}
</select>
</div>
` : '';
const rows = asArray(stats.items);
return `
<div class="feature-bar stats-toolbar">
<div class="stats-controls">
<div class="stats-timeframes" role="group" aria-label="Stats timeframes">
${timeframeButtons.map(item => '<button type="button" class="' + (state.siteStats.timeframe === item.key ? 'active' : '') + '" data-action="stats-timeframe" data-range="' + attr(item.key) + '">' + esc(item.label) + '</button>').join('')}
</div>
<div class="stats-control">
<label>Date Grouping</label>
<select data-input="stats-filter" data-field="grouping" aria-label="Stats grouping">
${['Hour', 'Day', 'Month', 'Year'].map(group => '<option value="' + group + '" ' + (state.siteStats.grouping === group ? 'selected' : '') + '>' + group + '</option>').join('')}
</select>
</div>
<div class="stats-control">
<label>From</label>
<input type="date" value="${attr(state.siteStats.from_at)}" data-input="stats-filter" data-field="from_at" aria-label="Stats from date">
</div>
<div class="stats-control">
<label>To</label>
<input type="date" value="${attr(state.siteStats.to_at)}" data-input="stats-filter" data-field="to_at" aria-label="Stats to date">
</div>
${trackerSelect}
</div>
<div class="detail-actions">
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="stats">${bucket.statsLoaded ? 'Refresh' : 'Load'} Stats</button>
</div>
</div>
${bucket.statsLoading ? renderLoading('Loading stats') : ''}
${bucket.statsError ? '<div class="error-state"><div>' + esc(bucket.statsError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="stats">Retry</button></div>' : ''}
${!bucket.statsLoading && !bucket.statsError && bucket.statsLoaded && bucket.stats ? `
<div class="stats-kpi-grid">
<div class="stats-kpi"><div class="k">Unique Visitors</div><div class="v">${formatCompactNumber(summary.visits)}</div></div>
<div class="stats-kpi"><div class="k">Pageviews</div><div class="v">${formatCompactNumber(summary.pageviews)}</div></div>
<div class="stats-kpi"><div class="k">Avg Time On Site</div><div class="v">${formatTime(summary.avg_duration)}</div></div>
<div class="stats-kpi"><div class="k">Bounce Rate</div><div class="v">${formatPercent(summary.bounce_rate)}</div></div>
</div>
${renderStatsChart(stats.items)}
<div class="stats-breakdown-head">
<div class="section-title">Details breakdown</div>
<span>${formatNumber(rows.length)} ${esc(state.siteStats.grouping.toLowerCase())} periods</span>
</div>
${renderDetailTable(['Date', 'Visitors', 'Pageviews', 'Avg Duration', 'Bounce'], rows.map(item => [
esc(item.date || '-'),
formatNumber(item.visits),
formatNumber(item.pageviews),
formatTime(item.avg_duration),
formatPercent(item.bounce_rate),
]), 'No stats items returned.', { pageKey: 'site-stats-' + siteFeatureKey(siteId, env), label: 'rows', perPage: 20 })}
${renderStatsSharing(siteId, env, stats)}
` : (!bucket.statsLoading && !bucket.statsLoaded ? renderEmpty('Stats have not been loaded yet.') : '')}
`;
}
function renderSiteAddons(env) {
if (!env) return renderEmpty('No environment data available.');
const envId = env.environment_id || env.environment || 'environment';
const plugins = asArray(env.plugins);
const themes = asArray(env.themes);
const pluginRows = plugins.map(plugin => [
'<strong>' + esc(plugin.title || plugin.name || 'Plugin') + '</strong><small>' + esc(plugin.name || '') + '</small>',
statusBadge(plugin.status || '-'),
esc(plugin.version || '-'),
esc(plugin.update_version || plugin.latest_version || plugin.new_version || '-'),
]);
const themeRows = themes.map(theme => [
'<strong>' + esc(theme.title || theme.name || 'Theme') + '</strong><small>' + esc(theme.name || '') + '</small>',
statusBadge(theme.status || '-'),
esc(theme.version || '-'),
esc(theme.update_version || theme.latest_version || theme.new_version || '-'),
]);
return `
<div class="section-title">Plugins (${formatNumber(plugins.length)})</div>
${renderDetailTable(['Name', 'Status', 'Version', 'Update'], pluginRows, 'No plugin data available.', { pageKey: 'site-addons-plugins-' + envId, label: 'plugins' })}
<div class="section-title">Themes (${formatNumber(themes.length)})</div>
${renderDetailTable(['Name', 'Status', 'Version', 'Update'], themeRows, 'No theme data available.', { pageKey: 'site-addons-themes-' + envId, label: 'themes' })}
`;
}
function renderSiteDomains(details, env) {
const linked = asArray(details?.domains).concat(asArray(env?.domains));
const unique = [];
const seen = new Set();
linked.forEach(domain => {
const key = domain.domain_id || domain.id || domain.name || domain.domain || JSON.stringify(domain);
if (seen.has(key)) return;
seen.add(key);
unique.push(domain);
});
const domainRows = unique.map(domain => {
const id = domain.domain_id || domain.id || '';
const name = domain.name || domain.domain || domain.domain_name || domain.url || 'Domain';
const link = id ? '<a href="' + attr(routeUrl('/domains/' + id)) + '" data-route="/domains/' + attr(id) + '">' + esc(name) + '</a>' : esc(name);
return [link, esc(domain.type || domain.environment || '-'), statusBadge(domain.status || (domain.primary ? 'Primary' : '-'))];
});
const shared = asArray(details?.shared_with);
const sharedRows = shared.map(account => [
account.account_id ? '<a href="' + attr(routeUrl('/accounts/' + account.account_id)) + '" data-route="/accounts/' + attr(account.account_id) + '">' + esc(account.name || 'Account') + '</a>' : esc(account.name || 'Account'),
esc(account.email || account.role || account.level || '-'),
]);
return `
<div class="section-title">Linked Domains</div>
${renderDetailTable(['Domain', 'Type', 'Status'], domainRows, 'No linked domains.', { pageKey: 'site-domains-' + routeId('sites'), label: 'domains' })}
<div class="section-title">Shared With</div>
${renderDetailTable(['Account', 'Details'], sharedRows, 'This site is not shared with other accounts.', { pageKey: 'site-shared-' + routeId('sites'), label: 'accounts' })}
`;
}
function formatUserRoles(user) {
const roles = user?.roles;
if (Array.isArray(roles)) return roles.join(', ');
if (typeof roles === 'string') return roles.split(',').map(role => role.trim()).filter(Boolean).join(', ');
return String(user?.role || '-');
}
function renderSiteUsers(site, env) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env);
const users = bucket.usersLoaded ? bucket.users : asArray(env.users);
const roleOptions = Array.from(new Set(users.flatMap(user => formatUserRoles(user).split(', ').filter(Boolean)))).sort();
if (state.userRoleFilter !== 'all' && !roleOptions.includes(state.userRoleFilter)) state.userRoleFilter = 'all';
const q = normalizeText(state.userSearch);
const filtered = users.filter(user => {
const roles = formatUserRoles(user);
const matchesRole = state.userRoleFilter === 'all' || roles.split(', ').includes(state.userRoleFilter);
const matchesSearch = !q || normalizeText([user.user_login, user.login, user.name, user.display_name, user.user_email, user.email, roles].join(' ')).includes(q);
return matchesRole && matchesSearch;
});
const adminCount = users.filter(user => formatUserRoles(user).toLowerCase().includes('administrator')).length;
const rows = filtered.map(user => {
const userId = user.user_id || user.ID || '';
const login = firstDefined(user.user_login, user.login, user.name, user.display_name, '-');
const email = firstDefined(user.user_email, user.email, '');
const roles = formatUserRoles(user);
return [
'<div class="user-name-cell"><strong>' + esc(login) + '</strong>' + (userId ? '<small>ID ' + esc(userId) + '</small>' : '') + '</div>',
esc(email || '-'),
'<div class="inline-list">' + roles.split(', ').filter(Boolean).map(role => statusBadge(role)).join('') + '</div>',
userId ? '<button type="button" class="row-action" data-action="magic-login" data-site-id="' + attr(site.site_id) + '" data-env-id="' + attr(env.environment_id || env.environment) + '" data-user-id="' + attr(userId) + '" title="WP Admin" aria-label="WP Admin">' + icons.login + '</button>' : '-',
];
});
return `
<div class="site-users-toolbar">
<div class="site-users-filters">
<div class="search-input">
${icons.search}
<input type="search" value="${attr(state.userSearch)}" data-input="site-user-search" placeholder="Search users...">
</div>
<select data-input="site-user-role" aria-label="Filter users by role">
<option value="all">All roles</option>
${roleOptions.map(role => '<option value="' + attr(role) + '" ' + (state.userRoleFilter === role ? 'selected' : '') + '>' + esc(role) + '</option>').join('')}
</select>
</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="users">${bucket.usersLoaded ? 'Refresh' : 'Load'} Users</button>
</div>
<div class="user-summary-grid">
<div class="user-pill"><span>Total Users</span><strong>${formatNumber(users.length)}</strong></div>
<div class="user-pill"><span>Administrators</span><strong>${formatNumber(adminCount)}</strong></div>
<div class="user-pill"><span>Visible</span><strong>${formatNumber(filtered.length)}</strong></div>
</div>
${bucket.usersLoading ? renderLoading('Loading users') : ''}
${bucket.usersError ? '<div class="error-state"><div>' + esc(bucket.usersError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="users">Retry</button></div>' : ''}
${!bucket.usersLoading && !bucket.usersError ? renderDetailTable(['User', 'Email', 'Roles', ''], rows, bucket.usersLoaded ? 'No users match the current filters.' : 'Users have not been loaded yet.', { pageKey: 'site-users-' + siteFeatureKey(siteId, env), label: 'users' }) : ''}
`;
}
function renderSiteBackups(site, env) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env);
const rows = bucket.backups.map(item => {
const id = backupId(item);
const size = firstDefined(item.size, item.bytes, item.file_size, item.total_size);
const name = firstDefined(item.name, item.backup_name, item.backup_id, item.id, 'Backup');
const created = firstDefined(item.created_at, item.date, item.time, item.timestamp);
const sizeLabel = size ? (Number.isNaN(Number(size)) ? String(size) : formatStorage(size)) : '';
const meta = [formatDate(created), sizeLabel, item.type || item.kind || ''].filter(Boolean).join(' - ');
const downloadLoading = state.actionLoading['backup_download:' + id];
const restoreLoading = state.actionLoading['backup_restore:' + id];
return {
title: esc(name),
meta: esc(meta || 'Backup'),
hint: id ? 'Click card to download backup' : '',
action: id ? 'backup-download' : '',
dataset: id ? { 'backup-id': id } : {},
ariaLabel: id ? 'Download backup ' + name : 'Backup ' + name,
aside: `
<div class="row-button-group">
${statusBadge(item.status || item.state || 'Backup')}
${id ? '<button type="button" class="secondary-btn" data-action="backup-download" data-backup-id="' + attr(id) + '">' + (downloadLoading ? 'Starting' : 'Download') + '</button>' : ''}
${id ? '<button type="button" class="danger-btn" data-action="backup-restore" data-backup-id="' + attr(id) + '">' + (restoreLoading ? 'Starting' : 'Restore') + '</button>' : ''}
</div>
`,
};
});
return `
<div class="feature-bar">
<div class="summary__sub">${formatNumber(bucket.backups.length)} backups for ${esc(env.environment || 'environment')}</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="backups">${bucket.backupsLoaded ? 'Refresh' : 'Load'} Backups</button>
</div>
${bucket.backupsLoading ? renderLoading('Loading backups') : ''}
${bucket.backupsError ? '<div class="error-state"><div>' + esc(bucket.backupsError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="backups">Retry</button></div>' : ''}
${!bucket.backupsLoading && !bucket.backupsError ? renderStackRows(rows, bucket.backupsLoaded ? 'No backups found for this environment.' : 'Backups have not been loaded yet.', { pageKey: 'site-backups-' + siteFeatureKey(siteId, env), label: 'backups' }) : ''}
`;
}
function renderChangeSummaryItem(label, value) {
return '<div class="change-summary__item"><span>' + esc(label) + '</span><strong>' + esc(value) + '</strong></div>';
}
function renderComponentFiles(hash, match, detailKey, bucket) {
const key = detailKey + ':' + match;
const files = bucket.componentFiles[key] || [];
const filesLoading = bucket.componentFilesLoading[key];
const filesError = bucket.componentFilesError[key];
return `
<div class="changed-files">
${filesLoading ? renderLoading('Loading files') : ''}
${filesError ? '<div class="error-state"><div>' + esc(filesError) + '</div><button class="secondary-btn" type="button" data-action="load-component-files" data-hash="' + attr(hash) + '" data-match="' + attr(match) + '" data-detail-key="' + attr(detailKey) + '">Retry</button></div>' : ''}
${!filesLoading && !filesError && !files.length ? '<div class="empty-mini">No changed files found.</div>' : ''}
${!filesLoading && !filesError ? files.slice(0, 100).map(file => `
<div class="changed-file">
<code>${esc(cleanFileName(file))}</code>
<button type="button" class="secondary-btn" data-action="quicksave-diff" data-hash="${attr(hash)}" data-file="${attr(file)}">Diff</button>
</div>
`).join('') : ''}
</div>
`;
}
function renderChangeComponent(item, hash, detailKey, bucket) {
const typeLabel = item.type === 'themes' ? 'Theme' : 'Plugin';
const title = componentTitle(item.component, typeLabel);
const slug = componentSlug(item.component);
const match = componentMatchPath(item.type, item.component);
const key = detailKey + ':' + match;
const expanded = !!bucket.expandedComponents[key];
const meta = [typeLabel, slug].concat(componentVersionMeta(item.component, item.deleted)).filter(Boolean);
return `
<div class="change-component">
<div class="change-component__head">
<div>
<div class="change-component__title">${esc(title)}</div>
<div class="change-component__meta">${meta.map(piece => '<span>' + esc(piece) + '</span>').join('')}</div>
</div>
<div class="row-button-group">
${statusBadge(item.stateLabel)}
${match ? '<button type="button" class="secondary-btn" data-action="toggle-component-files" data-hash="' + attr(hash) + '" data-match="' + attr(match) + '" data-detail-key="' + attr(detailKey) + '">' + (expanded ? 'Hide Files' : 'Files') + '</button>' : ''}
</div>
</div>
${expanded ? renderComponentFiles(hash, match, detailKey, bucket) : ''}
</div>
`;
}
function renderFileChangeOverview(hash, bucket) {
const files = bucket.quicksaveFiles[hash] || [];
const loading = bucket.quicksaveFilesLoading[hash];
const error = bucket.quicksaveFilesError[hash];
if (loading) return '<div class="change-component"><div class="empty-mini">Loading changed file overview...</div></div>';
if (error) return '<div class="error-state"><div>' + esc(error) + '</div><button class="secondary-btn" type="button" data-action="load-quicksave-files" data-hash="' + attr(hash) + '">Retry</button></div>';
if (!files.length) return '';
const groups = groupChangedFiles(files);
return `
<div>
<div class="section-title">Changed files by location</div>
<div class="change-component-list">
${groups.map(group => {
const samples = group.files.slice(0, 3).map(file => cleanFileName(file));
const groupKey = hash + ':' + group.key;
const expanded = !!bucket.expandedFileGroups[groupKey];
return `
<div class="change-component">
<div class="change-component__head">
<div>
<div class="change-component__title">${esc(group.label)}</div>
<div class="change-component__meta">
<span>${esc(group.type)}</span>
${samples.map(file => '<span>' + esc(file) + '</span>').join('')}
</div>
</div>
<div class="row-button-group">
${statusBadge(formatNumber(group.files.length) + ' files')}
<button type="button" class="secondary-btn" data-action="toggle-file-group" data-hash="${attr(hash)}" data-group-key="${attr(group.key)}">${expanded ? 'Hide Files' : 'Files'}</button>
</div>
</div>
${expanded ? `
<div class="changed-files">
${group.files.map(file => `
<div class="changed-file">
<code>${esc(cleanFileName(file))}</code>
<button type="button" class="secondary-btn" data-action="quicksave-diff" data-hash="${attr(hash)}" data-file="${attr(file)}">Diff</button>
</div>
`).join('')}
</div>
` : ''}
</div>
`;
}).join('')}
</div>
</div>
`;
}
function renderQuicksaveFullFiles(hash, bucket) {
const expanded = bucket.expandedQuicksaveFiles === hash;
const files = bucket.quicksaveFiles[hash] || [];
const filesLoading = bucket.quicksaveFilesLoading[hash];
const filesError = bucket.quicksaveFilesError[hash];
return `
<div class="row-button-group">
<button type="button" class="secondary-btn" data-action="toggle-quicksave-files" data-hash="${attr(hash)}">${expanded ? 'Hide All Files' : 'All Changed Files'}</button>
</div>
${expanded ? `
<div class="changed-files">
${filesLoading ? renderLoading('Loading changed files') : ''}
${filesError ? '<div class="error-state"><div>' + esc(filesError) + '</div><button class="secondary-btn" type="button" data-action="load-quicksave-files" data-hash="' + attr(hash) + '">Retry</button></div>' : ''}
${!filesLoading && !filesError && !files.length ? '<div class="empty-mini">No changed files found.</div>' : ''}
${!filesLoading && !filesError ? files.slice(0, 150).map(file => `
<div class="changed-file">
<code>${esc(cleanFileName(file))}</code>
<button type="button" class="secondary-btn" data-action="quicksave-diff" data-hash="${attr(hash)}" data-file="${attr(file)}">Diff</button>
</div>
`).join('') : ''}
</div>
` : ''}
`;
}
function renderChangeDetail(detail, hash, detailKey, bucket, options = {}) {
const data = asObject(detail);
const components = collectChangeComponents(data);
const counts = changeDetailCounts(data);
const fileGroups = groupChangedFiles(bucket.quicksaveFiles[hash] || []);
const pluginFileGroups = fileGroups.filter(group => group.type === 'Plugin').length;
const themeFileGroups = fileGroups.filter(group => group.type === 'Theme').length;
const hasCoreFiles = fileGroups.some(group => group.key === 'core');
const core = asObject(data.core);
const coreMeta = componentVersionMeta(core);
const coreValue = firstDefined(core.version, core.current_version, core.version_after, data.core_version, data.status?.core, hasCoreFiles ? 'Files' : '-');
const previous = firstDefined(data.previous_created_at, data.before_created_at);
const current = firstDefined(data.created_at, data.after_created_at);
return `
<div class="change-detail">
<div class="change-summary">
${renderChangeSummaryItem('Plugins', counts.plugins || pluginFileGroups)}
${renderChangeSummaryItem('Themes', counts.themes || themeFileGroups)}
${renderChangeSummaryItem('Deleted', counts.deleted)}
${renderChangeSummaryItem('Core', coreValue)}
</div>
${previous || current ? '<div class="summary__sub">' + esc([previous ? 'Previous ' + formatDate(previous) : '', current ? 'Current ' + formatDate(current) : ''].filter(Boolean).join(' - ')) + '</div>' : ''}
${coreMeta.length ? `
<div class="change-component">
<div class="change-component__head">
<div>
<div class="change-component__title">WordPress Core</div>
<div class="change-component__meta">${coreMeta.map(piece => '<span>' + esc(piece) + '</span>').join('')}</div>
</div>
${statusBadge('Core')}
</div>
</div>
` : ''}
${options.showFileOverview ? renderFileChangeOverview(hash, bucket) : ''}
${components.length ? '<div class="change-component-list">' + components.map(item => renderChangeComponent(item, hash, detailKey, bucket)).join('') + '</div>' : '<div class="empty-mini">' + esc(options.emptyLabel || 'No plugin or theme changes were reported.') + '</div>'}
${options.showAllFiles ? renderQuicksaveFullFiles(hash, bucket) : ''}
</div>
`;
}
function renderSiteUpdates(site, env) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env);
const pageKey = 'site-updates-' + siteFeatureKey(siteId, env);
const pageInfo = paginateRows(pageKey, bucket.updates, 25);
return `
<div class="feature-bar">
<div class="summary__sub">${formatNumber(bucket.updates.length)} update logs for ${esc(env.environment || 'environment')}</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="updates">${bucket.updatesLoaded ? 'Refresh' : 'Load'} Updates</button>
</div>
${bucket.updatesLoading ? renderLoading('Loading updates') : ''}
${bucket.updatesError ? '<div class="error-state"><div>' + esc(bucket.updatesError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="updates">Retry</button></div>' : ''}
${!bucket.updatesLoading && !bucket.updatesError ? renderUpdateRows(siteId, env, bucket, pageInfo) : ''}
`;
}
function renderUpdateRows(siteId, env, bucket, pageInfo) {
if (!bucket.updatesLoaded && !bucket.updates.length) return renderEmpty('Updates have not been loaded yet.');
if (!pageInfo.total) return renderEmpty('No update logs found for this environment.');
return `
<div class="stack-list">
${pageInfo.rows.map(item => {
const hashes = updateLogHashes(item);
const detailKey = updateLogDetailKey(hashes.hashBefore, hashes.hashAfter);
const expanded = bucket.expandedUpdate === detailKey;
const summary = updateLogSummary(item);
const created = firstDefined(item.created_at, item.date, item.time, item.timestamp);
const metaParts = [
formatDate(created),
summary.pluginChanged || summary.pluginCount ? formatNumber(summary.pluginChanged || summary.pluginCount) + ' plugin changes' : '',
summary.themeChanged || summary.themeCount ? formatNumber(summary.themeChanged || summary.themeCount) + ' theme changes' : '',
item.email || item.author || item.user || '',
].filter(Boolean);
const hasHashes = hashes.hashBefore && hashes.hashAfter;
return `
<div class="stack-row ${hasHashes ? 'clickable' : ''} ${expanded ? 'expanded' : ''}"${hasHashes ? ' role="button" tabindex="0" data-action="toggle-update-log"' + renderDataAttrs({ 'hash-before': hashes.hashBefore, 'hash-after': hashes.hashAfter }) + ' aria-label="' + attr((expanded ? 'Hide update details for ' : 'Show update details for ') + (formatDate(created) || 'update')) + '"' : ''}>
<div class="stack-row__primary">
<div class="stack-row__title"><strong>${esc(firstDefined(item.title, item.name, 'Update log'))}</strong></div>
<small>${esc(metaParts.join(' - ') || 'Update log')}</small>
${hasHashes ? '<div class="stack-row__hint">' + (expanded ? 'Click card to hide component changes' : 'Click card to view component changes') + '</div>' : ''}
</div>
<div class="row-button-group">
${statusBadge(item.status || 'Update')}
${hasHashes ? '<button type="button" class="secondary-btn" data-action="toggle-update-log" data-hash-before="' + attr(hashes.hashBefore) + '" data-hash-after="' + attr(hashes.hashAfter) + '">' + (expanded ? 'Hide' : 'Details') + '</button>' : ''}
</div>
</div>
${expanded ? renderUpdateDetailPanel(hashes.hashBefore, hashes.hashAfter, bucket) : ''}
`;
}).join('')}
</div>
${renderPagination('site-updates-' + siteFeatureKey(siteId, env), pageInfo, 'updates')}
`;
}
function renderUpdateDetailPanel(hashBefore, hashAfter, bucket) {
const detailKey = updateLogDetailKey(hashBefore, hashAfter);
const detail = bucket.quicksaveDetails[detailKey];
const loading = bucket.quicksaveDetailsLoading[detailKey];
const error = bucket.quicksaveDetailsError[detailKey];
if (loading) return '<div class="change-detail">' + renderLoading('Loading update details') + '</div>';
if (error) return '<div class="change-detail"><div class="error-state"><div>' + esc(error) + '</div><button class="secondary-btn" type="button" data-action="load-update-log-detail" data-hash-before="' + attr(hashBefore) + '" data-hash-after="' + attr(hashAfter) + '">Retry</button></div></div>';
if (!detail) return '<div class="change-detail"><div class="empty-mini">Update details have not been loaded yet.</div></div>';
return renderChangeDetail(detail, hashAfter, detailKey, bucket, { showFileOverview: true, showAllFiles: true, emptyLabel: 'No plugin or theme changes were reported for this update.' });
}
function renderSiteQuicksaves(site, env) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env);
const q = normalizeText(state.quicksaveSearch);
const quicksaves = bucket.quicksaves.filter(item => !q || normalizeText(JSON.stringify(item)).includes(q));
const pageKey = 'site-quicksaves-' + siteFeatureKey(siteId, env);
const pageInfo = paginateRows(pageKey, quicksaves, 25);
return `
<div class="feature-bar">
<div class="feature-controls" style="flex:1">
<div class="search-input" style="border:1px solid var(--rule);border-radius:var(--radius-sm);padding:7px 10px;max-width:360px;width:100%">
${icons.search}
<input type="search" value="${attr(state.quicksaveSearch)}" data-input="quicksave-search" placeholder="Search quicksaves...">
</div>
</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="quicksaves">${bucket.quicksavesLoaded ? 'Refresh' : 'Load'} Quicksaves</button>
</div>
${bucket.quicksavesLoading ? renderLoading('Loading quicksaves') : ''}
${bucket.quicksavesError ? '<div class="error-state"><div>' + esc(bucket.quicksavesError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="quicksaves">Retry</button></div>' : ''}
${!bucket.quicksavesLoading && !bucket.quicksavesError ? renderQuicksaveRows(siteId, env, bucket, pageInfo) : ''}
`;
}
function renderQuicksaveRows(siteId, env, bucket, pageInfo) {
if (!bucket.quicksavesLoaded && !bucket.quicksaves.length) return renderEmpty('Quicksaves have not been loaded yet.');
if (!pageInfo.total) return renderEmpty('No quicksaves found for this environment.');
return `
<div class="stack-list">
${pageInfo.rows.map(item => {
const hash = quicksaveHash(item);
const created = firstDefined(item.created_at, item.date, item.time, item.timestamp);
const changes = firstDefined(item.changes, item.git_status, item.summary, item.message);
const meta = [formatDate(created), item.email || item.author || '', changes && typeof changes !== 'object' ? changes : ''].filter(Boolean).join(' - ');
const rollbackLoading = state.actionLoading['quicksave-rollback:' + hash];
const expanded = bucket.expandedQuicksave === hash;
const filesExpanded = bucket.expandedQuicksaveFiles === hash;
return `
<div class="stack-row ${hash ? 'clickable' : ''} ${expanded ? 'expanded' : ''}"${hash ? ' role="button" tabindex="0" data-action="toggle-quicksave-detail"' + renderDataAttrs({ hash }) + ' aria-label="' + attr((expanded ? 'Hide quicksave details for ' : 'Show quicksave details for ') + hash) + '"' : ''}>
<div class="stack-row__primary">
<div class="stack-row__title"><strong>${esc(hash || 'Quicksave')}</strong></div>
${meta ? '<small>' + esc(meta) + '</small>' : ''}
${hash ? '<div class="stack-row__hint">' + (expanded ? 'Click card to hide change summary' : 'Click card to view change summary') + '</div>' : ''}
</div>
<div class="row-button-group">
${statusBadge(item.status || 'Quicksave')}
${hash ? '<button type="button" class="secondary-btn" data-action="toggle-quicksave-detail" data-hash="' + attr(hash) + '">' + (expanded ? 'Hide' : 'Details') + '</button>' : ''}
${hash ? '<button type="button" class="secondary-btn" data-action="toggle-quicksave-files" data-hash="' + attr(hash) + '">' + (filesExpanded ? 'Hide Files' : 'Files') + '</button>' : ''}
${hash ? '<button type="button" class="danger-btn" data-action="quicksave-rollback" data-hash="' + attr(hash) + '">' + (rollbackLoading ? 'Starting' : 'Rollback') + '</button>' : ''}
</div>
</div>
${expanded ? renderQuicksaveDetailPanel(hash, bucket) : ''}
`;
}).join('')}
</div>
${renderPagination('site-quicksaves-' + siteFeatureKey(siteId, env), pageInfo, 'quicksaves')}
`;
}
function renderQuicksaveDetailPanel(hash, bucket) {
const detailKey = quicksaveDetailKey(hash);
const detail = bucket.quicksaveDetails[detailKey];
const loading = bucket.quicksaveDetailsLoading[detailKey];
const error = bucket.quicksaveDetailsError[detailKey];
if (loading) return '<div class="change-detail">' + renderLoading('Loading quicksave details') + '</div>';
if (error) return '<div class="change-detail"><div class="error-state"><div>' + esc(error) + '</div><button class="secondary-btn" type="button" data-action="load-quicksave-detail" data-hash="' + attr(hash) + '">Retry</button></div></div>';
if (!detail) return '<div class="change-detail"><div class="empty-mini">Quicksave details have not been loaded yet.</div></div>';
return renderChangeDetail(detail, hash, detailKey, bucket, { showFileOverview: true, showAllFiles: true, emptyLabel: 'No plugin or theme version/status changes were reported for this quicksave.' });
}
function renderSiteTimeline(site, env) {
const siteId = site.site_id || routeId('sites');
const bucket = siteFeature(siteId, env || {});
const rows = bucket.timeline.map(item => {
const description = firstDefined(item.description, item.title, item.action, item.message, 'Timeline entry');
const author = firstDefined(item.author, item.user, item.user_name);
const created = firstDefined(item.created_at, item.date, item.time, item.timestamp);
const related = asArray(item.websites).map(website => website.name || website.site || website.domain).filter(Boolean).join(', ');
return {
html: '<div class="timeline-markdown">' + renderTimelineMarkdown(description) + '</div>',
meta: esc([author, formatDate(created), related].filter(Boolean).join(' - ')),
aside: statusBadge(item.type || item.entity || 'Timeline'),
};
});
return `
<div class="feature-bar">
<div class="summary__sub">${formatNumber(bucket.timeline.length)} timeline entries</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="timeline">${bucket.timelineLoaded ? 'Refresh' : 'Load'} Timeline</button>
</div>
${bucket.timelineLoading ? renderLoading('Loading timeline') : ''}
${bucket.timelineError ? '<div class="error-state"><div>' + esc(bucket.timelineError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="timeline">Retry</button></div>' : ''}
${!bucket.timelineLoading && !bucket.timelineError ? renderStackRows(rows, bucket.timelineLoaded ? 'No timeline activity.' : 'Timeline has not been loaded yet.', { pageKey: 'site-timeline-' + siteId, label: 'entries' }) : ''}
`;
}
function renderSiteLogs(details, env, site = null) {
if (!env) return renderEmpty('No environment data available.');
const siteId = site?.site_id || routeId('sites') || state.drawer?.siteId || '';
const bucket = siteFeature(siteId, env);
const consoleErrors = visibleConsoleErrorsForEnvironment(env);
const audits = extractItems(details?.site_audits || details?.audits || details?.site?.site_audits);
const selectedFile = bucket.logFiles.find(file => file.path === bucket.selectedLog) || bucket.logFiles[0] || {};
const q = normalizeText(state.logSearch);
const logLines = String(bucket.logContent || '').split('\n').filter(line => line !== '');
const filteredLines = q ? logLines.filter(line => normalizeText(line).includes(q)) : logLines;
const hiddenLines = Math.max(0, logLines.length - filteredLines.length);
const auditCards = audits.map(audit => {
const title = firstDefined(audit.title, audit.name, audit.report_title, 'Site audit');
const status = firstDefined(audit.status, audit.filesystem_status, '-');
const issues = firstDefined(audit.issues_count, audit.finding_counts?.open, audit.findings_count, '');
return `
<div class="audit-card">
<div>
<strong>${esc(title)}</strong>
<small>${esc(formatDate(audit.created_at || audit.date))}${issues !== '' ? ' - ' + esc(issues) + ' issue' + (Number(issues) === 1 ? '' : 's') : ''}</small>
</div>
${statusBadge(status)}
</div>
`;
}).join('');
return `
<div class="site-logs-toolbar">
<div class="site-log-controls">
<select data-input="site-log-file" aria-label="Server log file" ${bucket.logFiles.length ? '' : 'disabled'}>
${bucket.logFiles.length ? bucket.logFiles.map(file => '<option value="' + attr(file.path) + '" ' + (file.path === bucket.selectedLog ? 'selected' : '') + '>' + esc(file.label || file.path) + '</option>').join('') : '<option>No server logs</option>'}
</select>
<input type="number" min="10" max="10000" step="10" value="${attr(bucket.logLimit)}" data-input="site-log-limit" aria-label="Log line limit">
<button class="secondary-btn" type="button" data-action="load-selected-log-file" ${bucket.selectedLog ? '' : 'disabled'}>${bucket.logContentLoaded ? 'Reload File' : 'Load File'}</button>
</div>
<button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="logs">${bucket.logsLoaded ? 'Refresh Logs' : 'Load Logs'}</button>
</div>
${bucket.logsLoading ? renderLoading('Loading server logs') : ''}
${bucket.logsError ? '<div class="error-state"><div>' + esc(bucket.logsError) + '</div><button class="secondary-btn" type="button" data-action="refresh-site-feature" data-feature="logs">Retry</button></div>' : ''}
${!bucket.logsLoading && !bucket.logsError ? `
<div class="logs-grid">
<div>
<div class="section-title">Server Logs</div>
${bucket.logFiles.length ? `
<div class="log-file-list">
${bucket.logFiles.map(file => `
<button type="button" class="log-file ${file.path === bucket.selectedLog ? 'active' : ''}" data-action="load-log-file" data-file="${attr(file.path)}">
<strong>${esc(file.label || file.path)}</strong>
${file.meta ? '<small>' + esc(file.meta) + '</small>' : ''}
</button>
`).join('')}
</div>
` : renderEmpty(bucket.logsLoaded ? 'No server log files returned.' : 'Server logs have not been loaded yet.')}
${consoleErrors.length ? '<div class="section-title">Console Errors</div><pre class="code-block">' + esc(consoleErrors.map(item => typeof item === 'string' ? item : JSON.stringify(item, null, 2)).join('\n\n')) + '</pre>' : ''}
${audits.length ? '<div class="section-title">Site Audits</div><div class="audit-list">' + auditCards + '</div>' : ''}
</div>
<div class="log-panel">
<div class="site-log-controls">
<div class="search-input">
${icons.search}
<input type="search" value="${attr(state.logSearch)}" data-input="site-log-search" placeholder="Search loaded log...">
</div>
<div class="log-meta">${selectedFile.path ? esc(selectedFile.path) : 'No file selected'}${hiddenLines ? ' - ' + formatNumber(hiddenLines) + ' hidden by search' : ''}</div>
</div>
${bucket.logContentLoading ? renderLoading('Loading log file') : ''}
${bucket.logContentError ? '<div class="error-state"><div>' + esc(bucket.logContentError) + '</div><button class="secondary-btn" type="button" data-action="load-selected-log-file">Retry</button></div>' : ''}
${!bucket.logContentLoading && !bucket.logContentError ? (bucket.logContentLoaded ? '<pre class="log-output">' + esc(filteredLines.join('\n') || 'No lines match the current search.') + '</pre>' : renderEmpty(bucket.selectedLog ? 'Select Load File to view this log.' : 'Choose a server log file.')) : ''}
</div>
</div>
` : ''}
`;
}
function renderSiteScripts(site, env) {
if (!env) return renderEmpty('No environment data available.');
if (!state.fetched.recipes && !state.loading.recipes) {
setTimeout(() => loadRecipes(), 0);
}
const scheduled = asArray(env.scheduled_scripts);
const recipes = state.recipes.slice(0, 12);
const scheduledRows = scheduled.map(script => [
'<code>' + esc(String(script.code || '').slice(0, 120)) + (String(script.code || '').length > 120 ? '...' : '') + '</code>',
esc(script.author || script.user || '-'),
esc(formatDate(script.run_at || script.created_at)),
statusBadge(script.status || 'scheduled'),
]);
return `
<div class="feature-bar">
<div>
<div class="section-title" style="margin:0">Command Console</div>
<div class="summary__sub">Run WP-CLI commands, Bash scripts, recipes, and system tools on ${esc(env.environment || 'this environment')}.</div>
</div>
<div class="detail-actions">
<button class="primary-btn" type="button" data-action="open-terminal-current">${icons.terminal} Open Terminal</button>
<button class="secondary-btn" type="button" data-action="refresh-recipes">Refresh Recipes</button>
</div>
</div>
<div class="section-title">System Tools</div>
<div class="script-grid">
${state.systemTools.map(tool => `
<button class="script-card" type="button" data-action="scripts-system-tool" data-tool="${attr(tool.key)}">
<strong>${esc(tool.label)}</strong>
<small>${esc(tool.description)}</small>
</button>
`).join('')}
</div>
<div class="section-title">Cookbook</div>
${state.loading.recipes ? renderLoading('Loading recipes') : ''}
${state.error.recipes ? '<div class="error-state"><div>' + esc(state.error.recipes) + '</div><button class="secondary-btn" type="button" data-action="refresh-recipes">Retry</button></div>' : ''}
${!state.loading.recipes && !state.error.recipes ? `
<div class="script-grid">
${recipes.map(recipe => `
<button class="script-card" type="button" data-action="scripts-preview-recipe" data-recipe-id="${attr(recipe.recipe_id)}">
<strong>${esc(recipe.title || 'Recipe')}</strong>
<small>${recipe.public == 1 ? 'Public recipe' : 'Private recipe'} - loads into terminal</small>
</button>
`).join('') || '<div class="empty-state">No recipes found.</div>'}
</div>
` : ''}
<div class="section-title">Scheduled Scripts</div>
${renderDetailTable(['Code', 'User', 'Run Date', 'Status'], scheduledRows, 'No scheduled scripts found for this environment.', { pageKey: 'site-scheduled-scripts-' + (env.environment_id || routeId('sites')), label: 'scripts', perPage: 20 })}
`;
}
function renderDomainDetail() {
const id = routeId('domains');
const key = detailKey('domain', id);
const data = state.domainDetails[id];
if (state.loading[key] && !data) return renderLoading('Loading domain');
if (state.error[key] && !data) return renderError(state.error[key], 'refresh-domain-detail');
if (!data) return renderEmpty('Domain not found.');
const domain = data.domain || {};
const details = data.details || {};
const provider = data.provider || {};
const tabs = [
{ key: 'dns', label: 'DNS' },
{ key: 'management', label: 'Management' },
{ key: 'email', label: 'Email' },
{ key: 'mailgun', label: 'Mailgun' },
];
const active = tabs.some(tab => tab.key === state.detailTabs.domain) ? state.detailTabs.domain : 'dns';
const panels = {
dns: renderDomainDns(domain, data.records || []),
management: renderDomainManagement(domain, provider, details),
email: renderDomainEmail(details),
mailgun: renderDomainMailgun(details),
};
const pills = `
<span class="pill"><span class="dot ${domain.remote_id ? 'ok' : 'warn'}"></span>${domain.remote_id ? 'DNS active' : 'No DNS zone'}</span>
${domain.provider_id ? '<span class="pill">Registered</span>' : ''}
${domain.expires ? '<span class="pill">Expires ' + esc(formatDate(domain.expires)) + '</span>' : ''}
`;
return `
<div class="detail-shell">
<div class="detail-toolbar">
<button class="secondary-btn" type="button" data-route="/domains">${icons.back} Domains</button>
<div class="detail-actions"><button class="secondary-btn" type="button" data-action="refresh-domain-detail">Refresh</button></div>
</div>
${renderPageHeader(domain.name || domain.domain || 'Domain', domain.account || domain.customer || domain.status || 'Domain record', pills)}
${renderDetailTabs('domain', tabs)}
<div class="detail-panel">${panels[active] || panels.dns}</div>
</div>
`;
}
function renderDomainDns(domain, records) {
const domainId = domain.domain_id || routeId('domains');
const rows = records.map(record => {
const id = dnsRecordId(record);
const type = dnsRecordType(record);
const name = dnsRecordName(record) || '@';
const ttl = dnsRecordTtl(record);
const value = recordValue(record) || '-';
return {
title: statusBadge(type) + ' <strong>' + esc(name) + '</strong>',
html: '<div class="stack-row__title">' + statusBadge(type) + '<strong>' + esc(name) + '</strong></div>',
meta: esc(value),
hint: 'TTL ' + esc(ttl),
action: id ? 'edit-dns-record' : '',
dataset: id ? { 'record-id': id } : {},
aside: id ? '<div class="row-button-group"><button type="button" class="secondary-btn" data-action="edit-dns-record" data-record-id="' + attr(id) + '">Edit</button><button type="button" class="danger-btn" data-action="delete-dns-record" data-record-id="' + attr(id) + '">Delete</button></div>' : '',
ariaLabel: 'Edit DNS record ' + type + ' ' + name,
};
});
return `
<div class="detail-grid">
<div class="detail-card"><div class="k">Zone</div><div class="v">${domain.remote_id ? 'Active' : 'Inactive'}</div><div class="sub">${esc(domain.remote_id || 'No remote zone')}</div></div>
<div class="detail-card"><div class="k">Records</div><div class="v">${formatNumber(records.length)}</div><div class="sub">DNS entries</div></div>
</div>
<div class="site-section__head" style="margin-top:18px">
<div>
<div class="section-title" style="margin:0 0 4px">DNS Records</div>
<div class="site-section__sub">${domain.remote_id ? 'Click a card to edit a record.' : 'Activate DNS before adding records.'}</div>
</div>
<div class="detail-actions">
${domain.remote_id ? '<button class="primary-btn" type="button" data-action="open-dns-record">' + icons.plus + ' Add Record</button>' : '<button class="primary-btn" type="button" data-action="activate-dns-zone" data-domain-id="' + attr(domainId) + '">Activate DNS Zone</button>'}
<button class="secondary-btn" type="button" data-action="refresh-domain-detail">Refresh</button>
</div>
</div>
${domain.remote_id ? renderStackRows(rows, 'No DNS records found.', { pageKey: 'domain-dns-' + domainId, label: 'records', perPage: 50 }) : renderEmpty('DNS zone is not active for this domain.')}
`;
}
function renderDomainManagement(domain, provider, details) {
const domainId = domain.domain_id || routeId('domains');
const nameservers = asArray(provider.nameservers).map(item => typeof item === 'object' ? firstDefined(item.value, item.name, JSON.stringify(item)) : item);
const contacts = provider.contacts || {};
const domainRows = [
['Name', esc(domain.name || domain.domain || '-')],
['Provider ID', esc(domain.provider_id || '-')],
['Remote ID', esc(domain.remote_id || '-')],
['Status', esc(domain.status || domain.state || '-')],
['Expires', esc(formatDate(domain.expires || domain.expiration_date))],
['Lock', esc(provider.locked || details.locked || '-')],
['WHOIS Privacy', esc(provider.whois_privacy || details.whois_privacy || '-')],
];
const contactCards = Object.keys(contacts).map(type => {
const contact = contacts[type] || {};
return `
<div class="detail-card">
<div class="k">${esc(type.replace(/_/g, ' '))}</div>
<div class="v">${esc([contact.first_name, contact.last_name].filter(Boolean).join(' ') || contact.organization || '-')}</div>
<div class="sub">${esc([contact.email, contact.phone].filter(Boolean).join(' - ') || '-')}</div>
</div>
`;
}).join('');
const authCode = state.revealedSecrets['domain-auth-code:' + domainId];
return `
<div class="detail-actions" style="justify-content:flex-start;margin-bottom:14px">
${domain.remote_id ? '<button class="danger-btn" type="button" data-action="delete-dns-zone" data-domain-id="' + attr(domainId) + '">Delete DNS Zone</button>' : '<button class="primary-btn" type="button" data-action="activate-dns-zone" data-domain-id="' + attr(domainId) + '">Activate DNS Zone</button>'}
${domain.provider_id ? '<button class="secondary-btn" type="button" data-action="fetch-domain-auth-code" data-domain-id="' + attr(domainId) + '">' + (authCode ? 'Refresh Auth Code' : 'Retrieve Auth Code') + '</button>' : ''}
<button class="danger-btn" type="button" data-action="delete-domain" data-domain-id="${attr(domainId)}">Delete Domain</button>
</div>
${authCode ? '<div class="alert info" style="margin-bottom:14px"><strong>Auth code:</strong> ' + esc(authCode) + '</div>' : ''}
<div class="section-title">Registration</div>
${renderDetailTable(['Field', 'Value'], domainRows, 'No registration data.')}
<div class="section-title">Nameservers</div>
${renderStackRows(nameservers.map(ns => ({ title: esc(ns), meta: '', aside: '' })), 'No nameservers returned by the provider.', { pageKey: 'domain-nameservers-' + (domain.domain_id || routeId('domains')), label: 'nameservers' })}
${contactCards ? '<div class="section-title">Contacts</div><div class="detail-grid">' + contactCards + '</div>' : ''}
`;
}
function renderDomainEmail(details) {
const rows = [
['Forwarding ID', esc(details.mailgun_forwarding_id || '-')],
['Forwarding Zone', esc(details.mailgun_forwarding_zone || details.mailgun_forwarding_domain || '-')],
['Status', esc(details.mailgun_forwarding_status || '-')],
];
return `
<div class="section-title">Email Forwarding</div>
${renderDetailTable(['Field', 'Value'], rows, 'No email forwarding data.')}
`;
}
function renderDomainMailgun(details) {
const rows = [
['Mailgun ID', esc(details.mailgun_id || '-')],
['Zone', esc(details.mailgun_zone || '-')],
['SMTP Host', details.mailgun_zone ? 'smtp.mailgun.org' : '-'],
['SMTP Username', details.mailgun_zone ? 'postmaster@' + esc(details.mailgun_zone) : '-'],
['SMTP Password', details.mailgun_smtp_password ? 'Stored' : '-'],
];
return `
<div class="section-title">Mailgun</div>
${renderDetailTable(['Field', 'Value'], rows, 'No Mailgun data.')}
`;
}
function renderAccountDetail() {
const id = routeId('accounts');
const key = detailKey('account', id);
const data = state.accountDetails[id];
if (state.loading[key] && !data) return renderLoading('Loading account');
if (state.error[key] && !data) return renderError(state.error[key], 'refresh-account-detail');
if (!data) return renderEmpty('Account not found.');
const account = data.account || data || {};
const plan = account.plan || {};
const tabs = [
{ key: 'overview', label: 'Overview' },
{ key: 'users', label: 'Users' },
{ key: 'sites', label: 'Sites' },
{ key: 'domains', label: 'Domains' },
{ key: 'plan', label: 'Plan' },
{ key: 'invoices', label: 'Invoices' },
{ key: 'timeline', label: 'Timeline' },
];
const active = tabs.some(tab => tab.key === state.detailTabs.account) ? state.detailTabs.account : 'overview';
const canInvite = accountCanInvite(data);
const canEdit = accountCanEditSettings(data);
const panels = {
overview: renderAccountOverview(account, data),
users: renderAccountUsers(data),
sites: renderAccountSites(data),
domains: renderAccountDomains(data),
plan: renderAccountPlan(account, data),
invoices: renderAccountInvoices(data),
timeline: renderAccountTimeline(data),
};
const pills = `
<span class="pill"><b>${formatNumber(asArray(data.sites).length)}</b> sites</span>
<span class="pill"><b>${formatNumber(asArray(data.domains).length)}</b> domains</span>
${plan.name ? '<span class="pill">' + esc(plan.name) + '</span>' : ''}
`;
return `
<div class="detail-shell">
<div class="detail-toolbar">
<button class="secondary-btn" type="button" data-route="/accounts">${icons.back} Accounts</button>
<div class="detail-actions">
${canEdit ? '<button class="secondary-btn" type="button" data-action="open-account-edit">Edit Account</button>' : ''}
${canInvite ? '<button class="primary-btn" type="button" data-action="open-account-invite">' + icons.plus + ' Invite User</button>' : ''}
${isAdministrator() ? '<button class="danger-btn" type="button" data-action="delete-account" data-account-id="' + attr(id) + '">Delete Account</button>' : ''}
<button class="secondary-btn" type="button" data-action="refresh-account-detail">Refresh</button>
</div>
</div>
${renderPageHeader(account.name || 'Account', account.email || account.status || 'Account record', pills)}
${renderDetailTabs('account', tabs)}
<div class="detail-panel">${panels[active] || panels.overview}</div>
</div>
`;
}
function renderAccountOverview(account, data) {
const plan = account.plan || {};
const usage = plan.usage || {};
const limits = plan.limits || {};
return `
<div class="detail-grid">
<div class="detail-card"><div class="k">Users</div><div class="v">${formatNumber(asArray(data.users).length)}</div><div class="sub">${formatNumber(asArray(data.invites).length)} pending invites</div></div>
<div class="detail-card"><div class="k">Sites</div><div class="v">${formatNumber(asArray(data.sites).length)}</div><div class="sub">Managed properties</div></div>
<div class="detail-card"><div class="k">Domains</div><div class="v">${formatNumber(asArray(data.domains).length)}</div><div class="sub">DNS and registrations</div></div>
<div class="detail-card"><div class="k">Plan</div><div class="v">${esc(plan.name || '-')}</div><div class="sub">${plan.price ? formatMoney(plan.price) : '-'} ${plan.interval ? '/ ' + esc(plan.interval) + ' months' : ''}</div></div>
<div class="detail-card"><div class="k">Storage</div><div class="v">${formatStorage(usage.storage)}</div><div class="sub">${limits.storage ? 'Limit ' + esc(limits.storage) + ' GB' : 'No limit data'}</div></div>
<div class="detail-card"><div class="k">Visits</div><div class="v">${formatNumber(usage.visits)}</div><div class="sub">${limits.visits ? 'Limit ' + formatNumber(limits.visits) : 'No limit data'}</div></div>
</div>
`;
}
function renderAccountUsers(data) {
const users = asArray(data.users);
const invites = asArray(data.invites);
const account = data.account || {};
const ownerId = accountBillingUserId(account);
const canInvite = accountCanInvite(data);
const canManage = accountCanManageUsers(data);
const userRows = users.map(user => {
const userId = accountUserId(user);
const level = user.level || user.role || '';
const isOwner = ownerId && String(userId) === String(ownerId);
const aside = isOwner
? statusBadge('Owner')
: (canManage && userId ? `
<div class="row-button-group">
<select data-input="account-user-level" data-user-id="${attr(userId)}" aria-label="Access level for ${attr(user.name || user.login || user.email || 'user')}">${accessLevelOptions(level)}</select>
${level === 'full' ? '<button type="button" class="secondary-btn" data-action="transfer-account-owner" data-user-id="' + attr(userId) + '">Make Owner</button>' : ''}
<button type="button" class="danger-btn" data-action="remove-account-user" data-user-id="${attr(userId)}">Remove</button>
</div>
` : statusBadge(levelLabel(level)));
return {
title: esc(user.name || user.login || user.email || 'User'),
meta: esc([user.email, isOwner ? 'Owner' : levelLabel(level)].filter(Boolean).join(' - ')),
hint: canManage && !isOwner ? 'Change access or remove user' : '',
aside,
};
});
const inviteRows = invites.map(invite => ({
title: esc(invite.email || 'Pending invite'),
meta: esc(formatDate(invite.created_at)),
hint: invite.token ? 'Invite link is available in the legacy UI.' : '',
aside: '<div class="row-button-group">' + statusBadge(invite.status || 'Pending') + (invite.invite_id && canManage ? '<button type="button" class="danger-btn" data-action="delete-account-invite" data-invite-id="' + attr(invite.invite_id) + '">Delete</button>' : '') + '</div>',
}));
return `
<div class="site-section__head">
<div>
<div class="section-title" style="margin:0 0 4px">Users</div>
<div class="site-section__sub">Account access and pending invitations</div>
</div>
${canInvite ? '<div class="detail-actions"><button class="primary-btn" type="button" data-action="open-account-invite">' + icons.plus + ' Invite User</button></div>' : ''}
</div>
${renderStackRows(userRows, 'No users.', { pageKey: 'account-users-' + routeId('accounts'), label: 'users' })}
<div class="section-title">Pending Invites</div>
${renderStackRows(inviteRows, 'No pending invites.', { pageKey: 'account-invites-' + routeId('accounts'), label: 'invites' })}
`;
}
function renderAccountSites(data) {
const rows = asArray(data.sites).map(site => {
const siteId = site.site_id || site.id || '';
const meta = [
formatStorage(site.storage),
site.visits != null && site.visits !== '' ? formatNumber(site.visits) + ' visits' : '',
site.domain || site.home_url ? formatUrl(site.domain || site.home_url) : '',
].filter(value => value && value !== '-').join(' - ');
return {
title: esc(site.name || site.site || 'Site'),
meta: esc(meta || '-'),
hint: siteId ? 'Click card to manage site' : '',
aside: statusBadge(site.status || site.environment || '-'),
route: siteId ? '/sites/' + siteId : '',
ariaLabel: 'Open site ' + (site.name || site.site || ''),
};
});
return renderStackRows(rows, 'No sites for this account.', { pageKey: 'account-sites-' + routeId('accounts'), label: 'sites' });
}
function renderAccountDomains(data) {
const rows = asArray(data.domains).map(domain => {
const domainId = domain.domain_id || domain.id || '';
const meta = [
domain.expires ? 'Expires ' + formatDate(domain.expires) : '',
domain.provider_id ? 'Registered' : '',
domain.remote_id ? 'Managed DNS zone' : 'DNS zone not active',
].filter(Boolean).join(' - ');
return {
title: esc(domain.name || domain.domain || 'Domain'),
meta: esc(meta || '-'),
hint: domainId ? 'Click card to manage DNS' : '',
aside: statusBadge(domain.status || (domain.remote_id ? 'DNS active' : 'No DNS zone')),
route: domainId ? '/domains/' + domainId : '',
ariaLabel: 'Open domain ' + (domain.name || domain.domain || ''),
};
});
return renderStackRows(rows, 'No domains for this account.', { pageKey: 'account-domains-' + routeId('accounts'), label: 'domains' });
}
function renderAccountPlan(account, data) {
const plan = account.plan || {};
const planRows = [
['Name', esc(plan.name || '-')],
['Price', plan.price ? formatMoney(plan.price) : '-'],
['Interval', plan.interval ? esc(plan.interval) + ' months' : '-'],
['Next Renewal', esc(plan.next_renewal || '-')],
];
const addons = asArray(plan.addons).map(item => [esc(item.name || '-'), esc(item.quantity || '-'), item.price ? formatMoney(item.price) : '-']);
const charges = asArray(plan.charges).map(item => [esc(item.name || '-'), esc(item.quantity || '-'), item.price ? formatMoney(item.price) : '-']);
const credits = asArray(plan.credits).map(item => [esc(item.name || '-'), esc(item.quantity || '-'), item.price ? formatMoney(item.price) : '-']);
return `
<div class="section-title">Plan</div>
${renderDetailTable(['Field', 'Value'], planRows, 'No plan data.')}
<div class="section-title">Addons</div>
${renderDetailTable(['Name', 'Qty', 'Price'], addons, 'No addons.', { pageKey: 'account-plan-addons-' + routeId('accounts'), label: 'addons' })}
<div class="section-title">Charges</div>
${renderDetailTable(['Name', 'Qty', 'Price'], charges, 'No charges.', { pageKey: 'account-plan-charges-' + routeId('accounts'), label: 'charges' })}
<div class="section-title">Credits</div>
${renderDetailTable(['Name', 'Qty', 'Price'], credits, 'No credits.', { pageKey: 'account-plan-credits-' + routeId('accounts'), label: 'credits' })}
<div class="section-title">Per-Site Breakdown</div>
${renderAccountSites(data)}
`;
}
function renderAccountInvoices(data) {
const rows = asArray(data.invoices).map(invoice => [
esc(invoice.order_id ? '#' + invoice.order_id : invoice.id || '-'),
esc(formatDate(invoice.date || invoice.created_at)),
statusBadge(invoice.status || '-'),
invoice.total ? formatMoney(invoice.total) : '-',
]);
return renderDetailTable(['Order', 'Date', 'Status', 'Total'], rows, 'No invoices.', { pageKey: 'account-invoices-' + routeId('accounts'), label: 'invoices' });
}
function renderAccountTimeline(data) {
const rows = asArray(data.timeline).map(item => ({
html: '<div class="timeline-markdown">' + renderTimelineMarkdown(item.description || item.title || 'Timeline entry') + '</div>',
meta: esc([item.author, formatDate(item.created_at || item.date)].filter(Boolean).join(' - ')),
aside: item.type ? statusBadge(item.type) : '',
}));
return renderStackRows(rows, 'No timeline entries.', { pageKey: 'account-timeline-' + routeId('accounts'), label: 'entries' });
}
function renderDirectory(title, key) {
const rows = state[key] || [];
const sub = state.loading[key] ? 'Loading' : `${formatNumber(rows.length)} records`;
const addButton = key === 'domains'
? { action: 'open-new-domain', label: 'Add Domain' }
: (key === 'accounts' ? { action: 'open-new-account', label: 'Add Account', adminOnly: true } : null);
return `
${renderPageHeader(title, sub)}
${renderToolbar('Search ' + title.toLowerCase() + '...', addButton)}
${state.error[key] ? renderError(state.error[key], 'refresh-current') : ''}
${state.loading[key] ? renderLoading('Loading ' + title.toLowerCase()) : ''}
${!state.loading[key] && !state.error[key] ? renderDirectoryRows(rows, key) : ''}
`;
}
function renderDirectoryRows(rows, key) {
const q = normalizeText(state.search);
const filtered = rows.filter(row => !q || normalizeText(JSON.stringify(row)).includes(q));
if (!filtered.length) return renderEmpty('No records match the current view.');
const pageKey = 'directory-' + key;
const cards = filtered.map(row => {
const name = row.name || row.title || row.domain || row.site || row.email || row.subscription_id || row.id || 'Record';
const metrics = row.metrics || {};
const metaParts = [];
const addMeta = value => {
const clean = String(value == null ? '' : value).trim();
if (!clean || clean === '-' || metaParts.includes(clean)) return;
metaParts.push(clean);
};
const addCount = (value, label) => {
if (value == null || value === '') return;
const number = Array.isArray(value) ? value.length : Number(value);
if (!Number.isFinite(number)) return;
addMeta(formatNumber(number) + ' ' + label + (number === 1 ? '' : 's'));
};
let status = row.status || row.state || row.type || '';
let hint = 'Click card to open';
if (key === 'domains') {
addMeta(row.account || row.customer);
addMeta(row.expires ? 'Expires ' + formatDate(row.expires) : '');
addMeta(row.provider_id ? 'Registered' : '');
addMeta(row.remote_id ? 'Managed DNS zone' : 'DNS zone not active');
status = row.status || row.state || (row.remote_id ? 'DNS active' : 'No DNS zone');
hint = 'Click card to manage DNS';
} else if (key === 'accounts') {
addMeta(row.email || row.customer);
addMeta(row.plan?.name);
addCount(metrics.sites ?? row.sites, 'site');
addCount(metrics.domains ?? row.domains, 'domain');
addCount(metrics.users ?? row.users, 'user');
status = row.status || row.state || row.plan?.name || 'Account';
hint = 'Click card to manage account';
} else {
addMeta(row.email || row.account || row.customer || row.plan?.name || row.expires || row.domain || row.status);
}
const id = row.domain_id || row.account_id || row.subscription_id || row.id || '';
const route = key === 'domains' ? '/domains/' + id : (key === 'accounts' ? '/accounts/' + id : '/billing/' + id);
return {
title: esc(name),
meta: esc(metaParts.join(' - ') || '-'),
hint,
aside: statusBadge(status),
route,
ariaLabel: 'Open ' + name,
};
});
return renderStackRows(cards, 'No records match the current view.', { pageKey, label: 'records', perPage: 50 });
}
function recordId(record, keys = ['id']) {
for (const key of keys) {
if (record && record[key] !== undefined && record[key] !== null && record[key] !== '') return record[key];
}
return '';
}
function findRecord(rows, id, keys = ['id']) {
return asArray(rows).find(row => String(recordId(row, keys)) === String(id));
}
function matchesSearch(row, fields = []) {
const query = normalizeText(state.search);
if (!query) return true;
const haystack = fields.length ? fields : [JSON.stringify(row || {})];
return haystack.some(field => normalizeText(field).includes(query));
}
function renderAdminToolbar(placeholder, actionLabel, action) {
return `
<div class="toolbar">
<div class="search-input">
${icons.search}
<input id="cc-v2-search" type="search" value="${attr(state.search)}" placeholder="${attr(placeholder)}" data-input="search">
</div>
${action ? '<button class="primary-btn" type="button" data-action="' + attr(action) + '">' + icons.plus + ' ' + esc(actionLabel || 'New') + '</button>' : ''}
</div>
`;
}
function renderAdminStatus(key, label) {
return `${state.error[key] ? renderError(state.error[key], 'refresh-current') : ''}${state.loading[key] ? renderLoading('Loading ' + label) : ''}`;
}
function adminCard(title, value, sub) {
return '<div class="detail-card"><div class="k">' + esc(title) + '</div><div class="v">' + esc(value == null || value === '' ? '-' : value) + '</div><div class="sub">' + esc(sub || '') + '</div></div>';
}
function renderMultiOptions(rows, selected, idKeys, labelFn) {
const values = new Set(asArray(selected).map(value => String(value)));
return asArray(rows).map(row => {
const value = recordId(row, idKeys);
const label = labelFn(row);
return '<option value="' + attr(value) + '"' + (values.has(String(value)) ? ' selected' : '') + '>' + esc(label) + '</option>';
}).join('');
}
function renderCookbook() {
const rows = asArray(state.recipes).filter(recipe => matchesSearch(recipe, [recipe.title, recipe.content]));
const cards = rows.map(recipe => {
const id = recordId(recipe, ['recipe_id', 'id']);
const meta = [
recipe.public == 1 || recipe.public === true ? 'Public' : 'Private',
recipe.author || recipe.created_by || '',
].filter(Boolean).join(' - ');
const actions = `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="load-recipe-terminal" data-recipe-id="${attr(id)}">${icons.terminal} Load</button>
<button class="secondary-btn" type="button" data-action="open-recipe-form" data-recipe-id="${attr(id)}">Edit</button>
<button class="danger-btn" type="button" data-action="delete-recipe" data-recipe-id="${attr(id)}">Delete</button>
</div>
`;
return {
title: esc(recipe.title || 'Recipe'),
meta: esc(meta || 'Terminal recipe'),
hint: recipe.content ? esc(String(recipe.content).slice(0, 160)) : '',
aside: actions,
};
});
return `
${renderPageHeader('Cookbook', formatNumber(asArray(state.recipes).length) + ' recipes')}
${renderAdminToolbar('Search recipes...', 'New Recipe', 'open-recipe-form')}
${renderAdminStatus('recipes', 'recipes')}
${!state.loading.recipes && !state.error.recipes ? renderStackRows(cards, 'No recipes found.', { pageKey: 'admin-cookbook', label: 'recipes', perPage: 50 }) : ''}
`;
}
function renderProvidersConnect(embed = false) {
const rows = asArray(state.providers).filter(provider => matchesSearch(provider, [provider.name, provider.provider, provider.status]));
const cards = rows.map(provider => {
const id = recordId(provider, ['provider_id', 'id']);
const credentials = asArray(provider.credentials);
const actions = `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="open-provider-import" data-provider-id="${attr(id)}">Import</button>
<button class="secondary-btn" type="button" data-action="open-provider-form" data-provider-id="${attr(id)}">Edit</button>
<button class="danger-btn" type="button" data-action="delete-provider" data-provider-id="${attr(id)}">Delete</button>
</div>
`;
return {
title: esc(provider.name || provider.provider || 'Provider'),
meta: esc([provider.provider, credentials.length ? credentials.length + ' credential fields' : 'No credentials'].filter(Boolean).join(' - ')),
hint: 'Connect external hosting providers and import sites.',
aside: '<div class="row-button-group">' + statusBadge(provider.status || (provider.connected ? 'Connected' : 'Configured')) + actions + '</div>',
};
});
const body = `
${renderAdminToolbar('Search providers...', 'Add Provider', 'open-provider-form')}
${renderAdminStatus('providers', 'providers')}
${!state.loading.providers && !state.error.providers ? renderStackRows(cards, 'No providers configured.', { pageKey: 'admin-providers', label: 'providers', perPage: 50 }) : ''}
`;
if (embed) return body;
return `${renderPageHeader('Providers', formatNumber(asArray(state.providers).length) + ' connections')}${body}`;
}
function renderDefaults(embed = false) {
const defaults = state.defaults || {};
const selectedRecipes = asArray(defaults.recipes || defaults.recipe_ids);
const usersJson = JSON.stringify(asArray(defaults.users), null, 2);
const body = `
<form class="admin-section admin-form" data-action="save-defaults">
<div class="admin-section__head">
<h2>Site Defaults</h2>
<button class="primary-btn" type="submit"${state.loading.defaultsSave ? ' disabled' : ''}>${state.loading.defaultsSave ? 'Saving' : 'Save Defaults'}</button>
</div>
${state.error.defaults ? '<div class="alert error">' + esc(state.error.defaults) + '</div>' : ''}
${state.loading.defaults ? renderLoading('Loading defaults') : ''}
<div class="admin-form-grid">
<div class="form-field"><label for="defaults-email">Default Email</label><input id="defaults-email" name="email" type="email" value="${attr(defaults.email || '')}" placeholder="admin@example.com"></div>
<div class="form-field"><label for="defaults-timezone">Timezone</label><input id="defaults-timezone" name="timezone" value="${attr(defaults.timezone || '')}" placeholder="America/New_York"></div>
<div class="form-field"><label for="defaults-recipes">Recipes</label><select id="defaults-recipes" name="recipes" multiple size="8">${renderMultiOptions(state.recipes, selectedRecipes, ['recipe_id', 'id'], recipe => recipe.title || 'Recipe')}</select></div>
<div class="form-field"><label for="defaults-users">Default Users JSON</label><textarea id="defaults-users" name="users" rows="8" spellcheck="false">${esc(usersJson)}</textarea></div>
</div>
</form>
`;
if (embed) return body;
return `${renderPageHeader('Defaults', 'Global settings applied to new sites')}${body}`;
}
function renderConfigurations() {
const config = Object.assign({}, CC.configurations || {}, state.configurationDetails || {});
const colors = Object.assign({}, CC.colors || {}, config.colors || {});
const tabs = [
{ key: 'branding', label: 'Branding' },
{ key: 'providers', label: 'Providers' },
{ key: 'defaults', label: 'Defaults' },
];
if (isAdministrator()) tabs.push({ key: 'billing', label: 'Billing' });
const active = tabs.some(tab => tab.key === state.detailTabs.configurations) ? state.detailTabs.configurations : 'branding';
const colorFields = ['primary', 'accent', 'success', 'warning', 'error', 'info'].map(key => `
<div class="form-field">
<label for="config-color-${attr(key)}">${esc(key)}</label>
<div style="display:grid;grid-template-columns:44px minmax(0,1fr);gap:8px">
<input id="config-color-${attr(key)}" type="color" name="color_${attr(key)}" value="${attr(colors[key] || '#000000')}">
<input name="color_text_${attr(key)}" value="${attr(colors[key] || '')}" spellcheck="false">
</div>
</div>
`).join('');
const branding = `
<form class="admin-section admin-form" data-action="save-configurations">
<div class="admin-section__head">
<h2>Branding</h2>
<button class="primary-btn" type="submit"${state.loading.configurationsSave ? ' disabled' : ''}>${state.loading.configurationsSave ? 'Saving' : 'Save Configurations'}</button>
</div>
${renderAdminStatus('configurationDetails', 'configurations')}
<div class="admin-form-grid">
<div class="form-field"><label for="config-name">Name</label><input id="config-name" name="name" value="${attr(config.name || '')}"></div>
<div class="form-field"><label for="config-logo">Logo URL</label><input id="config-logo" name="logo" value="${attr(config.logo || '')}" placeholder="https://"></div>
<div class="form-field"><label for="config-logo-width">Logo Width</label><input id="config-logo-width" name="logo_width" type="number" min="1" value="${attr(config.logo_width || '')}"></div>
<label class="checkbox-line"><input type="checkbox" name="logo_only" value="1" ${config.logo_only ? 'checked' : ''}> Logo only</label>
<div class="form-field"><label for="config-dns-intro">DNS Introduction</label><textarea id="config-dns-intro" name="dns_introduction" rows="5">${esc(config.dns_introduction || '')}</textarea></div>
<div class="form-field"><label for="config-dns-nameservers">DNS Nameservers</label><textarea id="config-dns-nameservers" name="dns_nameservers" rows="5">${esc(config.dns_nameservers || '')}</textarea></div>
</div>
<div class="section-title">Theme Colors</div>
<div class="admin-form-grid">${colorFields}</div>
</form>
`;
const billing = `
<form class="admin-section admin-form" data-action="save-configurations">
<div class="admin-section__head">
<h2>Billing Configuration</h2>
<button class="primary-btn" type="submit"${state.loading.configurationsSave ? ' disabled' : ''}>${state.loading.configurationsSave ? 'Saving' : 'Save Billing Config'}</button>
</div>
<div class="admin-form-grid">
<div class="form-field"><label for="config-stripe-publishable">Stripe Publishable Key</label><input id="config-stripe-publishable" name="stripe_publishable_key" value="${attr(config.stripe_publishable_key || '')}" placeholder="pk_..."></div>
<div class="form-field"><label for="config-currency">Currency</label><input id="config-currency" name="currency" value="${attr(config.currency || 'usd')}"></div>
</div>
</form>
`;
const panels = { branding, providers: renderProvidersConnect(true), defaults: renderDefaults(true), billing };
return `
${renderPageHeader('Configurations', 'Branding, providers, defaults, and billing settings')}
${renderDetailTabs('configurations', tabs)}
<div class="detail-panel">${panels[active] || panels.branding}</div>
`;
}
function renderUsers() {
const rows = asArray(state.users).filter(user => matchesSearch(user, [user.name, user.display_name, user.username, user.user_login, user.login, user.email]));
const cards = rows.map(user => {
const id = userRecordId(user);
const name = userName(user);
const accountCount = asArray(user.account_ids).length;
return {
title: esc(name),
meta: esc([user.email, userLogin(user), formatUserRoles(user), accountCount ? accountCount + ' account' + (accountCount === 1 ? '' : 's') : ''].filter(Boolean).join(' - ')),
hint: 'Click card to view user details and account access.',
action: 'open-user-drawer',
dataset: { 'user-id': id },
ariaLabel: 'Open user ' + name,
aside: `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="open-user-form" data-user-id="${attr(id)}">Edit</button>
</div>
`,
};
});
return `
${renderPageHeader('Users', formatNumber(asArray(state.users).length) + ' users')}
${renderAdminToolbar('Search users...', 'New User', 'open-user-form')}
${renderAdminStatus('users', 'users')}
${!state.loading.users && !state.error.users ? renderStackRows(cards, 'No users found.', { pageKey: 'admin-users', label: 'users', perPage: 50 }) : ''}
`;
}
function renderKeys() {
const rows = asArray(state.keys).filter(key => matchesSearch(key, [key.title, key.fingerprint, key.key]));
const cards = rows.map(key => {
const id = recordId(key, ['key_id', 'id']);
const actions = `
<div class="row-button-group">
${!key.primary ? '<button class="secondary-btn" type="button" data-action="set-primary-key" data-key-id="' + attr(id) + '">Set Primary</button>' : ''}
<button class="secondary-btn" type="button" data-action="open-key-form" data-key-id="${attr(id)}">Edit</button>
<button class="danger-btn" type="button" data-action="delete-key" data-key-id="${attr(id)}">Delete</button>
</div>
`;
return {
title: esc(key.title || 'SSH Key'),
meta: esc(key.fingerprint || key.key || 'No fingerprint'),
aside: '<div class="row-button-group">' + (key.primary ? statusBadge('Primary') : '') + actions + '</div>',
};
});
return `
${renderPageHeader('Keys', formatNumber(asArray(state.keys).length) + ' SSH keys')}
${renderAdminToolbar('Search keys...', 'Add Key', 'open-key-form')}
${renderAdminStatus('keys', 'keys')}
${!state.loading.keys && !state.error.keys ? renderStackRows(cards, 'No SSH keys found.', { pageKey: 'admin-keys', label: 'keys', perPage: 50 }) : ''}
`;
}
function renderHandbook() {
const rows = asArray(state.processes).filter(process => matchesSearch(process, [process.name, process.title, process.description, process.roles]));
const cards = rows.map(process => {
const id = recordId(process, ['process_id', 'id']);
return {
title: esc(process.name || process.title || 'Process'),
meta: esc([process.roles, process.time_estimate, [process.repeat_quantity, process.repeat_interval].filter(Boolean).join(' ')].filter(Boolean).join(' - ')),
hint: process.description ? esc(String(process.description).replace(/<[^>]+>/g, '').slice(0, 180)) : '',
aside: `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="view-process" data-process-id="${attr(id)}">Open</button>
${isAdministrator() ? '<button class="secondary-btn" type="button" data-action="open-process-form" data-process-id="' + attr(id) + '">Edit</button>' : ''}
</div>
`,
};
});
return `
${renderPageHeader('Handbook', formatNumber(asArray(state.processes).length) + ' processes')}
${renderAdminToolbar('Search handbook...', isAdministrator() ? 'New Process' : '', isAdministrator() ? 'open-process-form' : '')}
${renderAdminStatus('processes', 'processes')}
${!state.loading.processes && !state.error.processes ? renderStackRows(cards, 'No handbook processes found.', { pageKey: 'admin-handbook', label: 'processes', perPage: 50 }) : ''}
`;
}
function renderReports() {
const rows = asArray(state.scheduledReports).filter(report => matchesSearch(report, [report.recipient, report.interval, report.account_name]));
const cards = rows.map(report => {
const id = recordId(report, ['scheduled_report_id', 'id']);
const siteIds = asArray(report.site_ids);
const meta = [
report.interval || 'scheduled',
report.account_id ? 'Account #' + report.account_id : '',
siteIds.length ? siteIds.length + ' sites' : '',
].filter(Boolean).join(' - ');
return {
title: esc(report.recipient || 'Scheduled report'),
meta: esc(meta || '-'),
aside: `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="open-report-form" data-report-id="${attr(id)}">Edit</button>
<button class="danger-btn" type="button" data-action="delete-report" data-report-id="${attr(id)}">Delete</button>
</div>
`,
};
});
return `
${renderPageHeader('Reports', formatNumber(asArray(state.scheduledReports).length) + ' scheduled reports')}
${renderAdminToolbar('Search reports...', 'New Report', 'open-report-form')}
${renderAdminStatus('scheduledReports', 'scheduled reports')}
${!state.loading.scheduledReports && !state.error.scheduledReports ? renderStackRows(cards, 'No scheduled reports found.', { pageKey: 'admin-reports', label: 'reports', perPage: 50 }) : ''}
`;
}
function normalizeArchive(item) {
return {
name: item.name || item.Name || item.path || item.Path || 'Archive',
size: item.size || item.Size || '',
mod_time: item.mod_time || item.ModTime || item.modified || '',
path: item.path || item.Path || item.name || item.Name || '',
};
}
function renderArchives() {
const rows = asArray(state.archives).map(normalizeArchive).filter(item => matchesSearch(item, [item.name, item.path]));
const cards = rows.map(item => ({
title: esc(item.name),
meta: esc([formatStorage(item.size), formatDate(item.mod_time)].filter(value => value && value !== '-').join(' - ') || item.path || '-'),
hint: item.path ? esc(item.path) : '',
aside: '<button class="secondary-btn" type="button" data-action="share-archive" data-archive-path="' + attr(item.path) + '">Share Link</button>',
}));
return `
${renderPageHeader('Archives', formatNumber(asArray(state.archives).length) + ' stored archives')}
${renderAdminToolbar('Search archives...', 'Store Archive', 'open-archive-store')}
${renderAdminStatus('archives', 'archives')}
${!state.loading.archives && !state.error.archives ? renderStackRows(cards, 'No archives found.', { pageKey: 'admin-archives', label: 'archives', perPage: 50 }) : ''}
`;
}
function renderActivityLogs() {
const logs = asArray(state.activityLogs.items);
const rows = logs.map(log => ({
title: esc(log.description || log.entity_name || log.action || 'Activity'),
meta: esc([formatDate(log.created_at), log.user_name, log.entity_type ? String(log.entity_type).replace(/_/g, ' ') : '', log.entity_name].filter(Boolean).join(' - ')),
aside: statusBadge(log.action || '-'),
}));
const pages = Math.max(1, Math.ceil((state.activityLogs.total || logs.length) / state.activityLogs.perPage));
return `
${renderPageHeader('Activity Logs', formatNumber(state.activityLogs.total || logs.length) + ' entries')}
<form class="admin-section admin-form" data-action="apply-activity-filters">
<div class="admin-section__head">
<h2>Filters</h2>
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="clear-activity-filters">Clear</button>
<button class="primary-btn" type="submit">Apply</button>
</div>
</div>
<div class="admin-form-grid">
<div class="form-field"><label for="activity-action">Action</label><input id="activity-action" name="action_filter" value="${attr(state.activityLogs.filters.action)}" placeholder="created"></div>
<div class="form-field"><label for="activity-entity">Entity Type</label><input id="activity-entity" name="entity_type" value="${attr(state.activityLogs.filters.entity_type)}" placeholder="site"></div>
<div class="form-field"><label for="activity-from">From</label><input id="activity-from" type="date" name="date_from" value="${attr(state.activityLogs.filters.date_from)}"></div>
<div class="form-field"><label for="activity-to">To</label><input id="activity-to" type="date" name="date_to" value="${attr(state.activityLogs.filters.date_to)}"></div>
</div>
</form>
${renderAdminStatus('activityLogs', 'activity logs')}
${!state.loading.activityLogs && !state.error.activityLogs ? renderStackRows(rows, 'No activity logs found.', { pageKey: 'admin-activity', label: 'entries', perPage: 50 }) : ''}
${pages > 1 ? '<div class="pager" style="margin-top:14px"><button type="button" data-action="activity-page" data-page="' + attr(state.activityLogs.page - 1) + '"' + (state.activityLogs.page <= 1 ? ' disabled' : '') + '>Prev</button><button type="button" disabled>' + esc(state.activityLogs.page + ' / ' + pages) + '</button><button type="button" data-action="activity-page" data-page="' + attr(state.activityLogs.page + 1) + '"' + (state.activityLogs.page >= pages ? ' disabled' : '') + '>Next</button></div>' : ''}
`;
}
function renderSiteAudits() {
const rows = asArray(state.siteAudits).filter(audit => matchesSearch(audit, [audit.site_name, audit.environment, audit.report_type, audit.status, audit.summary]));
const cards = rows.map(audit => {
const id = recordId(audit, ['site_audit_id', 'id']);
const published = !!audit.report_path;
const loadingKey = 'site-audit-publish:' + id;
const publishing = !!state.actionLoading[loadingKey];
const reportUrl = published ? (audit.report_url || window.location.origin + '/reports/' + audit.report_path) : '';
const publishButton = published
? '<button class="secondary-btn" type="button" data-action="unpublish-site-audit" data-audit-id="' + attr(id) + '"' + (publishing ? ' disabled' : '') + '>' + (publishing ? 'Updating' : 'Unpublish') + '</button>'
: '<button class="secondary-btn" type="button" data-action="publish-site-audit" data-audit-id="' + attr(id) + '"' + (publishing ? ' disabled' : '') + '>' + (publishing ? 'Publishing' : 'Publish') + '</button>';
return {
title: esc(audit.site_name || audit.domain || 'Site Audit'),
meta: esc([audit.environment, audit.report_type, audit.status, formatDate(audit.created_at || audit.requested_at)].filter(Boolean).join(' - ')),
hint: audit.summary ? esc(String(audit.summary).slice(0, 180)) : '',
aside: `
<div class="row-button-group">
<button class="secondary-btn" type="button" data-action="open-site-audit" data-audit-id="${attr(id)}">Report</button>
${published ? '<button class="secondary-btn" type="button" data-action="copy-site-audit-link" data-url="' + attr(reportUrl) + '"' + (publishing ? ' disabled' : '') + '>Copy Link</button>' : ''}
${publishButton}
${audit.status === 'requested' || audit.status === 'pending' ? '<button class="danger-btn" type="button" data-action="cancel-site-audit" data-audit-id="' + attr(id) + '">Cancel</button>' : ''}
</div>
`,
};
});
return `
${renderPageHeader('Site Audits', formatNumber(asArray(state.siteAudits).length) + ' audit reports')}
${renderAdminToolbar('Search audits...', 'Request Audit', 'open-site-audit-request')}
${renderAdminStatus('siteAudits', 'site audits')}
${!state.loading.siteAudits && !state.error.siteAudits ? renderStackRows(cards, 'No site audits found.', { pageKey: 'admin-site-audits', label: 'audits', perPage: 50 }) : ''}
`;
}
function renderSecurity() {
const tab = state.security.tab || 'vulnerabilities';
const tabs = [
['vulnerabilities', 'Vulnerabilities'],
['patches', 'Patches'],
['coverage', 'Coverage'],
];
const threatRows = asArray(state.security.threats).filter(item => matchesSearch(item, [item.slug, item.name, item.title, item.version, item.severity]));
const patchRows = asArray(state.security.patches).filter(item => matchesSearch(item, [item.slug, item.name, item.version, item.status]));
const coverage = state.security.coverage || {};
const nav = '<nav class="detail-tabs" aria-label="Security sections">' + tabs.map(([key, label]) => '<button type="button" class="' + (tab === key ? 'active' : '') + '" data-action="security-tab" data-tab="' + attr(key) + '">' + esc(label) + '</button>').join('') + '</nav>';
const threats = renderStackRows(threatRows.map(item => ({
title: esc(item.name || item.title || item.slug || 'Threat'),
meta: esc([item.type, item.version, item.severity, item.source].filter(Boolean).join(' - ')),
hint: item.description ? esc(String(item.description).slice(0, 180)) : '',
aside: statusBadge(item.status || item.severity || '-'),
})), 'No active security threats.', { pageKey: 'admin-security-threats', label: 'threats', perPage: 50 });
const patches = renderStackRows(patchRows.map(item => ({
title: esc(item.name || item.slug || 'Patch'),
meta: esc([item.version, item.type, item.created_at ? formatDate(item.created_at) : ''].filter(Boolean).join(' - ')),
aside: statusBadge(item.status || 'Registered'),
})), 'No security patches registered.', { pageKey: 'admin-security-patches', label: 'patches', perPage: 50 });
const coverageCards = `
<div class="detail-grid">
${adminCard('Components', formatNumber(coverage.components || coverage.total_components || coverage.total || 0), 'Tracked across the fleet')}
${adminCard('Audited', formatNumber(coverage.audited || coverage.audited_components || 0), 'Known safe or reviewed')}
${adminCard('Unaudited', formatNumber(coverage.unaudited || coverage.pending || 0), 'Needs review')}
${adminCard('Coverage', coverage.coverage || coverage.percent || formatPercent(coverage.coverage_percent), 'Audit coverage')}
</div>
${coverage.summary ? '<div class="admin-section"><div class="timeline-markdown">' + renderTimelineMarkdown(coverage.summary) + '</div></div>' : ''}
`;
return `
${renderPageHeader('Security', 'Vulnerabilities, patches, and audit coverage')}
${renderAdminToolbar('Search security...', '', '')}
${nav}
${renderAdminStatus('securityThreatsPayload', 'security threats')}
${renderAdminStatus('securityPatchesPayload', 'security patches')}
${renderAdminStatus('securityCoveragePayload', 'security coverage')}
<div class="detail-panel">${tab === 'patches' ? patches : (tab === 'coverage' ? coverageCards : threats)}</div>
`;
}
function renderBilling() {
const billing = state.billingDetails || {};
const invoices = asArray(billing.invoices || billing.orders);
const methods = asArray(billing.payment_methods);
const subscriptions = asArray(state.subscriptions);
const invoiceRows = invoices.map(invoice => [
esc(invoice.order_id ? '#' + invoice.order_id : invoice.id || '-'),
esc(formatDate(invoice.date || invoice.created_at)),
statusBadge(invoice.status || '-'),
invoice.total ? formatMoney(invoice.total) : esc(invoice.total || '-'),
]);
const methodRows = methods.map(method => {
const token = method.token || method.id || '';
const label = method.method ? [method.method.brand, method.method.last4 ? 'ending ' + method.method.last4 : ''].filter(Boolean).join(' ') : (method.label || method.brand || 'Payment method');
return {
title: esc(label),
meta: esc([method.expires, method.type].filter(Boolean).join(' - ')),
aside: '<div class="row-button-group">' + (method.primary || method.default ? statusBadge('Primary') : '<button class="secondary-btn" type="button" data-action="set-primary-payment" data-token="' + attr(token) + '">Set Primary</button>') + '<button class="danger-btn" type="button" data-action="delete-payment" data-token="' + attr(token) + '">Delete</button></div>',
};
});
const subRows = subscriptions.map(subscription => ({
title: esc(subscription.name || subscription.account || subscription.subscription_id || 'Subscription'),
meta: esc([subscription.status, subscription.total || subscription.price, subscription.next_renewal || subscription.renewal].filter(Boolean).join(' - ')),
aside: statusBadge(subscription.status || '-'),
route: subscription.subscription_id || subscription.id ? '/billing/' + (subscription.subscription_id || subscription.id) : '',
}));
return `
${renderPageHeader('Billing', 'Invoices, plans, and payment methods')}
${renderAdminStatus('billingDetails', 'billing')}
${renderAdminStatus('subscriptions', 'subscriptions')}
<div class="admin-grid">
${adminCard('Invoices', formatNumber(invoices.length), 'Billing history')}
${adminCard('Payment Methods', formatNumber(methods.length), 'Saved cards and ACH')}
${adminCard('Subscriptions', formatNumber(subscriptions.length), 'Plans and renewals')}
</div>
<div class="admin-section">
<div class="admin-section__head"><h2>Invoices</h2></div>
${renderDetailTable(['Order', 'Date', 'Status', 'Total'], invoiceRows, 'No invoices found.', { pageKey: 'billing-invoices', label: 'invoices' })}
</div>
<div class="admin-section">
<div class="admin-section__head"><h2>Payment Methods</h2></div>
${renderStackRows(methodRows, 'No payment methods found.', { pageKey: 'billing-methods', label: 'methods' })}
</div>
<div class="admin-section">
<div class="admin-section__head"><h2>Subscriptions</h2></div>
${renderStackRows(subRows, 'No subscriptions found.', { pageKey: 'billing-subscriptions', label: 'subscriptions' })}
</div>
`;
}
function renderProfile() {
const form = state.profileForm;
const sessions = asArray(form.sessions);
const sessionRows = sessions.map(session => ({
title: esc(session.label || session.ip || session.browser || 'Session'),
meta: esc([session.location, session.device, session.last_seen ? formatDate(session.last_seen) : '', session.current ? 'Current session' : ''].filter(Boolean).join(' - ')),
aside: session.current ? statusBadge('Current') : '<button class="danger-btn" type="button" data-action="destroy-session" data-session-hash="' + attr(session.hash || '') + '">Sign Out</button>',
}));
return `
${renderPageHeader('Profile', CC.user?.login || 'Account settings')}
<form class="admin-section admin-form" data-action="save-profile">
<div class="admin-section__head">
<h2>Edit Profile</h2>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Profile'}</button>
</div>
${renderErrorList(form.errors)}
${form.success ? '<div class="alert info">' + esc(form.success) + '</div>' : ''}
<div class="admin-form-grid">
<div class="form-field"><label for="profile-display-name">Display Name</label><input id="profile-display-name" name="display_name" value="${attr(form.display_name)}"></div>
<div class="form-field"><label for="profile-email">Email</label><input id="profile-email" name="email" type="email" value="${attr(form.email)}"></div>
<div class="form-field"><label for="profile-password">New Password</label><input id="profile-password" name="new_password" type="password" value="${attr(form.new_password)}" autocomplete="new-password"></div>
</div>
</form>
<div class="admin-section">
<div class="admin-section__head">
<h2>Sessions</h2>
<button class="secondary-btn" type="button" data-action="destroy-other-sessions">Sign Out Others</button>
</div>
${renderAdminStatus('profileSessionsPayload', 'sessions')}
${renderStackRows(sessionRows, 'No active sessions found.', { pageKey: 'profile-sessions', label: 'sessions' })}
</div>
`;
}
function renderInviteAcceptance() {
const invite = state.invite;
const details = invite.details || {};
return `
${renderPageHeader('Accept Invite', details.account_name || details.email || 'Pending invitation')}
<div class="admin-section admin-form">
${invite.loading ? renderLoading('Loading invite') : ''}
${invite.error ? '<div class="alert error">' + esc(invite.error) + '</div>' : ''}
${!invite.account || !invite.token ? '<div class="alert error">Invite account and token are required.</div>' : ''}
${details.account_name || details.email ? '<div class="detail-grid">' + adminCard('Account', details.account_name || invite.account, 'Invitation target') + adminCard('Email', details.email || CC.user?.email || '-', 'Invited address') + '</div>' : ''}
<form data-action="accept-invite" class="admin-form" style="margin-top:16px">
<div class="form-field"><label for="invite-password">Password</label><input id="invite-password" name="password" type="password" value="${attr(invite.password)}" autocomplete="new-password"></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-route="/accounts">Cancel</button>
<button class="primary-btn" type="submit"${invite.accepting || !invite.account || !invite.token ? ' disabled' : ''}>${invite.accepting ? 'Accepting' : 'Accept Invite'}</button>
</div>
</form>
</div>
`;
}
function renderLoading(label) {
return `<div class="loading-state">${esc(label)}...</div>`;
}
function renderEmpty(label) {
return `<div class="empty-state">${esc(label)}</div>`;
}
function renderError(message, action) {
return `<div class="error-state"><div>${esc(message)}</div><button class="secondary-btn" type="button" data-action="${attr(action)}">Retry</button></div>`;
}
function allVisibleSelected(sites) {
const keys = sites.flatMap(site => visibleEnvironments(site).map(env => envKey(site, env)));
return keys.length > 0 && keys.every(key => state.selection.has(key));
}
function renderBulkBar() {
if (!state.selection.size) {
bulkRegion.classList.remove('visible');
bulkRegion.innerHTML = '';
return;
}
bulkRegion.classList.add('visible');
bulkRegion.innerHTML = `
<span class="count">${state.selection.size} selected</span>
<button type="button" data-action="bulk-terminal">Terminal</button>
<button type="button" data-action="bulk-open">Open</button>
<button type="button" data-action="bulk-login">WP Admin</button>
<button type="button" data-action="clear-selection">Clear</button>
`;
}
function renderTerminalJob(job) {
const running = job.status === 'running' || job.status === 'queued';
const lines = job.stream.flatMap(chunk => String(chunk || '').split('\n')).filter(line => line.trim() !== 'Finished.');
return `
<div class="terminal-job">
<div class="terminal-job__head">
<span>-></span>
<strong>${esc(job.description || 'Command')}</strong>
${running ? '<button type="button" data-action="terminal-cancel-job" data-job-client-id="' + attr(job.clientId) + '">Cancel</button>' : '<button type="button" data-action="terminal-copy-job" data-job-client-id="' + attr(job.clientId) + '">Copy</button>'}
<time>${esc(formatDate(job.created_at))}</time>
</div>
${lines.length ? lines.map(line => '<span class="terminal-line' + (job.status === 'error' ? ' error' : '') + '">' + esc(line || ' ') + '</span>').join('') : '<span class="terminal-line">Waiting for output...</span>'}
${job.status === 'error' ? '<span class="terminal-line error">Process failed.</span>' : ''}
${job.status === 'cancelled' ? '<span class="terminal-line error">Cancelled.</span>' : ''}
</div>
`;
}
function renderTerminal() {
if (!terminalRegion) return;
const active = terminalActiveJob();
const running = runningJobCount();
if (!state.terminal.open) {
if (!state.terminal.show && !state.jobs.length) {
terminalRegion.innerHTML = '';
return;
}
terminalRegion.innerHTML = `
<div class="terminal-island" role="button" tabindex="0" data-action="terminal-open">
<span class="terminal-island__status ${running ? 'running' : ''}"></span>
<span class="terminal-island__text">
<strong>${esc(active?.description || 'Terminal Ready')}</strong>
<small>${esc(lastJobLine(active) || (state.terminal.selectedTargets.length ? state.terminal.selectedTargets.length + ' target(s) selected' : 'No active output'))}</small>
</span>
<button class="icon-btn" type="button" data-action="terminal-hide" aria-label="Hide">${icons.close}</button>
</div>
`;
return;
}
const selectedCount = state.terminal.selectedTargets.length;
const targetLabel = selectedCount === 1 ? (state.terminal.selectedTargets[0].home_url || state.terminal.selectedTargets[0].name) : selectedCount + ' environments selected';
const targets = filteredTerminalTargets().slice(0, 150);
const selectedKeys = new Set(state.terminal.selectedTargets.map(targetKey));
const tools = filteredTerminalTools().slice(0, 120);
const jobs = state.jobs.length ? state.jobs.map(renderTerminalJob).join('') : '<div class="terminal-empty"><div>' + icons.terminal + '<br>Select targets and enter a command.</div></div>';
terminalRegion.innerHTML = `
<div class="terminal-window ${state.terminal.fullscreen ? 'fullscreen' : ''}">
<div class="terminal-head">
<span class="terminal-dots">
<button class="terminal-dot close" type="button" data-action="terminal-close" aria-label="Close"></button>
<button class="terminal-dot hide" type="button" data-action="terminal-minimize" aria-label="Minimize"></button>
<button class="terminal-dot full" type="button" data-action="terminal-fullscreen" aria-label="Fullscreen"></button>
</span>
<div class="terminal-title">captaincore-cli - ${esc(targetLabel)}</div>
<button type="button" data-action="terminal-close" aria-label="Close">Close</button>
</div>
<div class="terminal-body">
<aside class="terminal-sidebar">
<input class="terminal-search" type="search" value="${attr(state.terminal.targetSearch)}" data-input="terminal-target-search" placeholder="Search targets">
<div>
<div class="terminal-section-title">Targets</div>
<div class="terminal-actions" style="justify-content:flex-start;margin-bottom:7px">
<button type="button" data-action="terminal-add-filtered">Add Filtered</button>
<button type="button" data-action="terminal-clear-targets">Clear</button>
</div>
</div>
<div class="terminal-targets">
${targets.map(target => `
<button type="button" class="terminal-target ${selectedKeys.has(targetKey(target)) ? 'active' : ''}" data-action="terminal-toggle-target" data-env-id="${attr(target.environment_id)}">
<strong>${selectedKeys.has(targetKey(target)) ? '[x] ' : '[ ] '}${esc(target.home_url || target.name)}</strong>
<small>${esc(target.name)} - ${esc(target.environment)}</small>
</button>
`).join('') || '<div class="terminal-empty">No targets found.</div>'}
</div>
<div class="terminal-tools">
<div class="terminal-section-title">Tools</div>
<input class="terminal-search" type="search" value="${attr(state.terminal.toolSearch)}" data-input="terminal-tool-search" placeholder="Search tools and recipes">
<div class="terminal-actions" style="justify-content:flex-start;margin:7px 0">
<button type="button" class="${state.terminal.toolTab === 'system' ? 'primary' : ''}" data-action="terminal-tool-tab" data-tab="system">System</button>
<button type="button" class="${state.terminal.toolTab === 'cookbook' ? 'primary' : ''}" data-action="terminal-tool-tab" data-tab="cookbook">Cookbook</button>
</div>
${state.terminal.toolTab === 'cookbook' && state.loading.recipes ? '<div class="terminal-line">Loading recipes...</div>' : ''}
${state.terminal.toolTab === 'cookbook' && state.error.recipes ? '<div class="terminal-line error">' + esc(state.error.recipes) + '</div>' : ''}
${tools.map(item => state.terminal.toolTab === 'cookbook' ? `
<button type="button" class="terminal-tool" data-action="terminal-preview-recipe" data-recipe-id="${attr(item.recipe_id)}">
<strong>${esc(item.title || 'Recipe')}</strong>
<small>${item.public == 1 ? 'Public recipe' : 'Private recipe'}</small>
</button>
` : `
<button type="button" class="terminal-tool" data-action="terminal-system-tool" data-tool="${attr(item.key)}">
<strong>${esc(item.label)}</strong>
<small>${esc(item.description)}</small>
</button>
`).join('') || '<div class="terminal-line">No matches.</div>'}
</div>
</aside>
<main class="terminal-main">
<div class="terminal-output" data-terminal-output>${jobs}</div>
<div class="terminal-input">
<div class="terminal-prompt">$</div>
<textarea class="terminal-command" rows="1" data-input="terminal-command" spellcheck="false" placeholder="Enter WP-CLI command or Bash script. Cmd/Ctrl+Enter runs it.">${esc(state.terminal.command)}</textarea>
<div class="terminal-actions">
<button type="button" data-action="terminal-save-open">${icons.save} Save</button>
<button type="button" data-action="terminal-schedule-open">${icons.clock} Schedule</button>
<button type="button" class="primary" data-action="terminal-run">Run</button>
</div>
</div>
</main>
</div>
</div>
`;
scrollTerminalToBottom();
}
function findSiteAndEnv(siteId, envId) {
let site = state.sites.find(item => String(item.site_id) === String(siteId));
const detail = state.siteDetails[siteId];
if (!site && detail) site = Object.assign({}, detail.site || {}, { environments: detail.environments || [] });
else if (site && detail) site = Object.assign({}, site, detail.site || {}, { environments: detail.environments || site.environments || [] });
if (!site) return {};
const envs = getEnvironments(site);
const env = envs.find(item => String(item.environment_id) === String(envId)) || envs.find(item => String(item.environment) === String(envId)) || primaryEnv(site);
return { site, env };
}
function userRecordId(user) {
return recordId(user, ['user_id', 'ID', 'id']);
}
function userName(user) {
return firstDefined(
user?.name,
user?.display_name,
[user?.first_name, user?.last_name].filter(Boolean).join(' '),
user?.username,
user?.user_login,
user?.login,
user?.email,
'User'
);
}
function userLogin(user) {
return firstDefined(user?.username, user?.user_login, user?.login, '');
}
function userDetailBucket(userId) {
const key = String(userId || '');
if (!state.userDetails[key]) {
state.userDetails[key] = {
details: null,
accountIds: [],
loaded: false,
loading: false,
error: '',
};
}
return state.userDetails[key];
}
function userAccountIds(user, bucket) {
const ids = asArray(bucket?.accountIds).length ? bucket.accountIds : asArray(firstDefined(user?.account_ids, user?.accounts));
return ids.map(item => {
if (item && typeof item === 'object') return recordId(item, ['account_id', 'id']);
return item;
}).map(id => String(id)).filter(Boolean);
}
function accountsForUser(user, bucket) {
const accountIds = userAccountIds(user, bucket);
const byId = new Map(state.accounts.map(account => [String(recordId(account, ['account_id', 'id'])), account]));
return accountIds.map(id => byId.get(String(id)) || { account_id: id, name: 'Account #' + id });
}
async function loadUserDrawerDetails(userId, force = false) {
if (!userId) return;
const bucket = userDetailBucket(userId);
if (bucket.loading || (bucket.loaded && !force)) return;
bucket.loading = true;
bucket.error = '';
renderDrawer();
try {
const [details, accountIds] = await Promise.all([
apiFetch('/wp-json/captaincore/v1/users/' + encodeURIComponent(userId)),
apiFetch('/wp-json/captaincore/v1/users/' + encodeURIComponent(userId) + '/accounts').catch(() => null),
]);
bucket.details = details || {};
bucket.accountIds = asArray(accountIds || details?.account_ids);
bucket.loaded = true;
} catch (error) {
bucket.error = error.message || 'Unable to load user details.';
} finally {
bucket.loading = false;
renderDrawer();
}
}
function openUserDrawer(userId) {
if (!userId) return;
state.drawer = { type: 'user', userId };
render();
loadUserDrawerDetails(userId);
}
function drawerTabs() {
return [
{ key: 'overview', label: 'Overview' },
{ key: 'backups', label: 'Backups' },
{ key: 'updates', label: 'Updates' },
{ key: 'logs', label: 'Logs' },
];
}
function updateTargetVersion(item) {
return String(firstDefined(
item?.update_version,
item?.latest_version,
item?.new_version,
item?.available_version,
item?.version_latest,
item?.latest,
''
)).trim();
}
function hasUpdateTarget(item) {
const target = updateTargetVersion(item);
const current = String(firstDefined(item?.version, item?.current_version, '')).trim();
if (!target || target === '-' || target.toLowerCase() === 'none') return false;
return !current || target !== current;
}
function renderDrawerOverview(site, env, status) {
return `
<div class="kv-grid">
<div class="kv"><div class="k">Visits</div><div class="v">${formatNumber(env.visits)}</div></div>
<div class="kv"><div class="k">Storage</div><div class="v">${formatStorage(env.storage)}</div></div>
<div class="kv"><div class="k">WordPress</div><div class="v">${esc(env.core || '-')}</div></div>
<div class="kv"><div class="k">PHP</div><div class="v">${esc(env.php_version || '-')}</div></div>
</div>
<div class="section-title">Status</div>
<div class="activity">
<div class="activity__row"><span class="dot ${status.dot}"></span><span>${esc(status.label)}</span><time>${esc(env.environment || '')}</time></div>
<div class="activity__row"><span class="dot"></span><span>${esc(formatUrl(env.home_url))}</span><time>URL</time></div>
<div class="activity__row"><span class="dot"></span><span>${formatNumber(env.subsite_count || 0)} subsites</span><time>Sites</time></div>
</div>
`;
}
function renderDrawerUpdates(site, env) {
const siteId = site.site_id || state.drawer?.siteId || routeId('sites');
const route = siteId ? '/sites/' + siteId + '/addons' : '';
const coreUpdate = asObject(env.core_update);
const coreTarget = String(firstDefined(
coreUpdate.version,
coreUpdate.update_version,
coreUpdate.latest_version,
env.core_update_version,
env.core_latest_version,
env.wp_latest_version,
env.latest_core,
''
)).trim();
const rows = [];
if (coreTarget && coreTarget !== '-' && coreTarget !== String(env.core || '').trim()) {
rows.push({
title: esc('WordPress Core'),
meta: esc(['Current ' + (env.core || '-'), 'Available ' + coreTarget].join(' - ')),
aside: statusBadge('Core'),
route,
ariaLabel: 'Manage WordPress core update',
});
}
asArray(env.plugins).filter(hasUpdateTarget).forEach(plugin => {
const target = updateTargetVersion(plugin);
const current = firstDefined(plugin.version, plugin.current_version, '-');
rows.push({
title: esc(plugin.title || plugin.name || 'Plugin'),
meta: esc(['Current ' + current, 'Available ' + target, plugin.name || ''].filter(Boolean).join(' - ')),
aside: statusBadge('Plugin'),
route,
ariaLabel: 'Manage plugin update',
});
});
asArray(env.themes).filter(hasUpdateTarget).forEach(theme => {
const target = updateTargetVersion(theme);
const current = firstDefined(theme.version, theme.current_version, '-');
rows.push({
title: esc(theme.title || theme.name || 'Theme'),
meta: esc(['Current ' + current, 'Available ' + target, theme.name || ''].filter(Boolean).join(' - ')),
aside: statusBadge('Theme'),
route,
ariaLabel: 'Manage theme update',
});
});
const reportedCount = Number(env.updates || 0) || (Number(env.plugin_updates || 0) + Number(env.theme_updates || 0));
const summary = rows.length ? formatNumber(rows.length) + ' pending update' + (rows.length === 1 ? '' : 's') : (reportedCount ? formatNumber(reportedCount) + ' updates reported' : 'No pending updates');
return `
<div class="feature-bar">
<div class="summary__sub">${esc(summary)} for ${esc(env.environment || 'environment')}</div>
${route ? '<a class="secondary-btn" href="' + attr(routeUrl(route)) + '" data-route="' + attr(route) + '">' + icons.plug + ' Manage Addons</a>' : ''}
</div>
${rows.length ? renderStackRows(rows, 'No pending updates detected.', { pageKey: 'drawer-updates-' + siteFeatureKey(siteId, env), label: 'updates' }) : renderEmpty(reportedCount ? 'Updates are reported, but component details are not available in this view.' : 'No pending updates detected.')}
`;
}
function renderDrawerBody(site, env, status) {
const active = state.drawerTab || 'overview';
const details = Object.assign({}, state.siteDetails[site.site_id]?.details || {});
if (active === 'backups') {
ensureSiteFeature('backups', site.site_id, env);
return renderSiteBackups(site, env);
}
if (active === 'updates') return renderDrawerUpdates(site, env);
if (active === 'logs') {
ensureSiteFeature('logs', site.site_id, env);
return renderSiteLogs(details, env, site);
}
return renderDrawerOverview(site, env, status);
}
function renderUserDrawer() {
const userId = state.drawer?.userId || '';
const bucket = userDetailBucket(userId);
const listUser = findRecord(state.users, userId, ['user_id', 'ID', 'id']) || {};
const user = Object.assign({}, listUser, bucket.details || {});
const name = userName(user);
const login = userLogin(user);
const email = firstDefined(user.email, user.user_email, '');
const created = firstDefined(user.created_at, user.user_registered, user.registered, '');
const roles = formatUserRoles(user);
const accounts = accountsForUser(user, bucket);
const accountRows = accounts.map(account => {
const accountId = recordId(account, ['account_id', 'id']);
const accountName = account.name || account.email || ('Account #' + accountId);
const meta = [account.status, account.email, accountId ? 'ID ' + accountId : ''].filter(Boolean).join(' - ');
return {
title: esc(accountName),
meta: esc(meta || 'Account access'),
aside: statusBadge(account.level || account.access_level || account.status || 'Access'),
route: accountId ? '/accounts/' + accountId : '',
ariaLabel: 'Open account ' + accountName,
};
});
drawerRegion.innerHTML = `
<div class="drawer__head">
<div class="drawer__preview user-drawer__avatar">${esc(initials(name))}</div>
<div>
<h2>${esc(name)}</h2>
<div class="summary__sub">${esc([login, email].filter(Boolean).join(' - ') || 'User')}</div>
</div>
<button class="drawer__close" type="button" data-action="close-drawer" aria-label="Close">${icons.close}</button>
</div>
<div class="drawer__body">
<div class="kv-grid">
<div class="kv"><div class="k">Full Name</div><div class="v">${esc(name)}</div></div>
<div class="kv"><div class="k">Email</div><div class="v">${esc(email || '-')}</div></div>
<div class="kv"><div class="k">Username</div><div class="v">${esc(login || '-')}</div></div>
<div class="kv"><div class="k">Created</div><div class="v">${esc(formatDate(created))}</div></div>
<div class="kv"><div class="k">Role</div><div class="v">${esc(roles || '-')}</div></div>
<div class="kv"><div class="k">User ID</div><div class="v">${esc(userId || '-')}</div></div>
</div>
${bucket.loading ? renderLoading('Loading user details') : ''}
${bucket.error ? '<div class="error-state"><div>' + esc(bucket.error) + '</div><button class="secondary-btn" type="button" data-action="refresh-user-drawer" data-user-id="' + attr(userId) + '">Retry</button></div>' : ''}
<div class="section-title">Account Access</div>
${!bucket.loading && !bucket.error ? renderStackRows(accountRows, bucket.loaded ? 'No account access assigned.' : 'Account access has not been loaded yet.', { pageKey: 'drawer-user-accounts-' + userId, label: 'accounts', perPage: 20 }) : ''}
</div>
<div class="drawer__cta">
<button class="primary-btn" type="button" data-action="open-user-form" data-user-id="${attr(userId)}">Edit User</button>
<button class="secondary-btn" type="button" data-action="refresh-user-drawer" data-user-id="${attr(userId)}">${bucket.loaded ? 'Refresh' : 'Load'} Details</button>
</div>
`;
}
function renderDrawer() {
const open = !!state.drawer;
drawerBackdrop.classList.toggle('open', open);
drawerRegion.classList.toggle('open', open);
drawerRegion.setAttribute('aria-hidden', open ? 'false' : 'true');
if (!open) {
drawerRegion.innerHTML = '';
return;
}
if (state.drawer.type === 'user') {
renderUserDrawer();
return;
}
const { site, env } = findSiteAndEnv(state.drawer.siteId, state.drawer.envId);
if (!site || !env) return;
const shot = screenshotUrl(site, env, 800);
const status = environmentStatus(env);
const tabs = drawerTabs();
const active = tabs.some(tab => tab.key === state.drawerTab) ? state.drawerTab : 'overview';
state.drawerTab = active;
drawerRegion.innerHTML = `
<div class="drawer__head">
${shot ? '<img class="drawer__preview" src="' + attr(shot) + '" alt="">' : '<div class="drawer__preview"></div>'}
<div>
<h2>${esc(site.name || site.site || 'Site')}</h2>
<div class="summary__sub">${esc(env.environment || 'Production')} - ${esc(formatUrl(env.home_url))}</div>
</div>
<button class="drawer__close" type="button" data-action="close-drawer" aria-label="Close">${icons.close}</button>
</div>
<nav class="drawer__tabs">
${tabs.map(tab => '<button class="' + (tab.key === active ? 'active' : '') + '" type="button" data-action="drawer-tab" data-tab="' + attr(tab.key) + '">' + esc(tab.label) + '</button>').join('')}
</nav>
<div class="drawer__body">
${renderDrawerBody(site, env, status)}
</div>
<div class="drawer__cta">
<a class="primary-btn" href="${attr(routeUrl('/sites/' + site.site_id))}" data-route="/sites/${attr(site.site_id)}">Manage</a>
${env.home_url ? '<button class="secondary-btn" type="button" data-action="open-url" data-url="' + attr(env.home_url) + '">' + icons.external + ' Open</button>' : ''}
<button class="secondary-btn" type="button" data-action="magic-login" data-site-id="${attr(site.site_id)}" data-env-id="${attr(env.environment_id || env.environment)}">${icons.login} WP Admin</button>
</div>
`;
}
function globalSearchScopes() {
return [
{ key: 'all', label: 'All' },
{ key: 'sites', label: 'Sites' },
{ key: 'domains', label: 'Domains' },
{ key: 'accounts', label: 'Accounts' },
{ key: 'billing', label: 'Billing' },
].filter(scope => scope.key !== 'billing' || !!CC.modules?.billing);
}
function globalSearchItems() {
const items = [];
state.sites.forEach(site => {
const siteId = site.site_id || site.id;
const siteName = site.name || site.site || 'Untitled site';
const account = site.customer_name || site.account || site.customer || '';
items.push({
type: 'sites',
typeLabel: 'Site',
icon: icons.monitor,
label: siteName,
sub: [account, site.site].filter(Boolean).join(' - '),
route: siteId ? '/sites/' + siteId : '/sites',
haystack: [siteName, site.site, account, site.customer, site.customer_name].join(' '),
});
getEnvironments(site).forEach(env => {
const envName = env.environment || 'Environment';
items.push({
type: 'sites',
typeLabel: envName,
icon: icons.monitor,
label: formatUrl(env.home_url || siteName),
sub: [siteName, env.core ? 'WP ' + env.core : '', env.php_version ? 'PHP ' + env.php_version : ''].filter(Boolean).join(' - '),
route: siteId ? '/sites/' + siteId : '/sites',
haystack: [siteName, site.site, account, envName, env.home_url, env.core, env.php_version, env.address].join(' '),
});
});
});
state.domains.forEach(domain => {
const id = domain.domain_id || domain.id;
const name = domain.name || domain.domain || 'Domain';
items.push({
type: 'domains',
typeLabel: 'Domain',
icon: icons.globe,
label: name,
sub: [domain.account, domain.customer, domain.status, domain.expires ? 'expires ' + formatDate(domain.expires) : ''].filter(Boolean).join(' - '),
route: id ? '/domains/' + id : '/domains',
haystack: [name, domain.account, domain.customer, domain.status, domain.remote_id, domain.provider_id].join(' '),
});
});
state.accounts.forEach(account => {
const id = account.account_id || account.id;
const name = account.name || account.title || account.email || 'Account';
items.push({
type: 'accounts',
typeLabel: 'Account',
icon: icons.users,
label: name,
sub: [account.email, account.plan?.name, account.status].filter(Boolean).join(' - '),
route: id ? '/accounts/' + id : '/accounts',
haystack: [name, account.email, account.plan?.name, account.status, account.customer].join(' '),
});
});
state.subscriptions.forEach(subscription => {
const name = subscription.name || subscription.title || subscription.account || subscription.email || 'Subscription';
items.push({
type: 'billing',
typeLabel: 'Billing',
icon: icons.credit,
label: name,
sub: [subscription.email, subscription.status, subscription.plan?.name, subscription.total ? formatMoney(subscription.total) : ''].filter(Boolean).join(' - '),
route: '/billing',
haystack: [name, subscription.email, subscription.status, subscription.plan?.name, subscription.total, subscription.subscription_id].join(' '),
});
});
return items;
}
function globalSearchResults() {
const q = normalizeText(state.globalSearch.query).trim();
const scope = state.globalSearch.scope || 'all';
const terms = q.split(/\s+/).filter(Boolean);
let items = globalSearchItems().filter(item => scope === 'all' || item.type === scope);
if (terms.length) {
items = items.map(item => {
const haystack = normalizeText([item.label, item.sub, item.typeLabel, item.haystack].join(' '));
const matches = terms.every(term => haystack.includes(term));
if (!matches) return null;
let score = 0;
const label = normalizeText(item.label);
if (label === q) score += 20;
if (label.startsWith(q)) score += 10;
if (haystack.includes(q)) score += 3;
score += item.type === 'sites' ? 1 : 0;
return Object.assign({}, item, { score });
}).filter(Boolean).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label));
} else {
items = items.slice(0, 36);
}
return items.slice(0, 80);
}
function groupedGlobalSearchResults() {
const groups = { sites: [], domains: [], accounts: [], billing: [] };
globalSearchResults().forEach(item => {
if (groups[item.type]) groups[item.type].push(item);
});
return groups;
}
function renderGlobalSearchModal() {
const loading = ['sites', 'domains', 'accounts', 'subscriptions'].some(key => state.loading[key]);
const scopes = globalSearchScopes();
const groups = groupedGlobalSearchResults();
const flat = Object.values(groups).flat();
if (state.globalSearch.activeIndex >= flat.length) state.globalSearch.activeIndex = Math.max(0, flat.length - 1);
const groupLabels = { sites: 'Sites & Environments', domains: 'Domains', accounts: 'Accounts', billing: 'Billing' };
let index = -1;
const sections = Object.keys(groups).map(type => {
const rows = groups[type];
if (!rows.length) return '';
return `
<div class="search-group">
<div class="search-group__title">${groupLabels[type]}</div>
${rows.map(item => {
index++;
return `
<a class="search-result ${index === state.globalSearch.activeIndex ? 'active' : ''}" href="${attr(routeUrl(item.route))}" data-route="${attr(item.route)}" data-search-index="${index}">
<span class="search-result__icon">${item.icon}</span>
<span><strong>${esc(item.label)}</strong><small>${esc(item.sub || item.route)}</small></span>
<span class="search-result__type">${esc(item.typeLabel)}</span>
</a>
`;
}).join('')}
</div>
`;
}).join('');
const empty = !flat.length ? renderEmpty(state.globalSearch.query ? 'No results match that search.' : 'No searchable records loaded yet.') : '';
return `
<div class="modal-card search-modal" role="dialog" aria-modal="true" aria-label="Search">
<div class="search-modal__bar">
${icons.search}
<input id="cc-global-search" type="search" value="${attr(state.globalSearch.query)}" data-input="global-search" aria-label="Global search" placeholder="Search sites, domains, accounts...">
<kbd>esc</kbd>
</div>
<div class="search-modal__scopes">
${scopes.map(scope => '<button type="button" class="' + (state.globalSearch.scope === scope.key ? 'active' : '') + '" data-action="search-scope" data-scope="' + attr(scope.key) + '">' + esc(scope.label) + '</button>').join('')}
</div>
<div class="search-modal__body">
${loading ? renderLoading('Loading searchable records') : ''}
${sections || empty}
</div>
<div class="search-modal__foot">
<span>${formatNumber(flat.length)} result${flat.length === 1 ? '' : 's'}</span>
<span>Press Enter to open the selected result.</span>
</div>
</div>
`;
}
function ensureGlobalSearchData() {
const loaders = [
loadCollection('sites', '/wp-json/captaincore/v1/sites'),
loadCollection('domains', '/wp-json/captaincore/v1/domains/'),
loadCollection('accounts', '/wp-json/captaincore/v1/accounts/'),
];
if (CC.modules?.billing) loaders.push(loadCollection('subscriptions', '/wp-json/captaincore/v1/subscriptions/'));
return Promise.allSettled(loaders);
}
function openGlobalSearch() {
if (!state.nonce) return;
state.modal = 'global-search';
state.drawer = null;
state.popover = '';
state.globalSearch.activeIndex = 0;
render();
ensureGlobalSearchData();
}
function openActiveGlobalSearchResult() {
if (state.modal !== 'global-search') return;
const results = Object.values(groupedGlobalSearchResults()).flat();
const result = results[state.globalSearch.activeIndex] || results[0];
if (!result) return;
state.modal = null;
navTo(result.route);
}
function renderCapturesModal() {
const captureState = state.captures;
const capture = selectedCapture();
const captureIndex = capture ? captureState.captures.indexOf(capture) : -1;
const pages = normalizedCapturePages(capture);
const page = selectedCapturePage(capture);
const imageUrl = capturePageImageUrl(capture, page);
const pageName = page ? firstDefined(page.name, page.page, page.path, '/') : '';
const captureMeta = capture ? [captureLabel(capture, captureIndex), pageName].filter(Boolean).join(' - ') : '';
const disabled = captureState.loading || captureState.saving || captureState.requesting ? ' disabled' : '';
const captureOptions = captureState.captures.map((item, index) => {
const id = captureId(item, index);
return '<option value="' + attr(id) + '" ' + (id === captureState.selectedCaptureId ? 'selected' : '') + '>' + esc(captureLabel(item, index)) + '</option>';
}).join('');
const pageOptions = pages.map((item, index) => {
const key = capturePageKey(item, index);
const label = firstDefined(item.name, item.page, item.path, 'Page ' + (index + 1));
return '<option value="' + attr(key) + '" ' + (key === captureState.selectedPageKey ? 'selected' : '') + '>' + esc(label) + '</option>';
}).join('');
return `
<div class="modal-card captures-modal" role="dialog" aria-modal="true" aria-label="Visual captures">
<div class="modal-head">
<div>
<h2>Visual Captures</h2>
<div class="summary__sub">${esc(captureState.homeUrl || captureState.siteName || '')}</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="captures-content">
<div class="captures-toolbar">
<div class="captures-controls">
<div class="captures-control">
<label for="capture-select">Taken On</label>
<select id="capture-select" data-input="capture-select" ${captureState.captures.length ? '' : 'disabled'}>
${captureState.captures.length ? captureOptions : '<option>No captures</option>'}
</select>
</div>
<div class="captures-control">
<label for="capture-page-select">Page</label>
<select id="capture-page-select" data-input="capture-page-select" ${pages.length ? '' : 'disabled'}>
${pages.length ? pageOptions : '<option>No pages</option>'}
</select>
</div>
</div>
<div class="captures-actions">
${imageUrl ? '<button class="secondary-btn" type="button" data-action="open-url" data-url="' + attr(imageUrl) + '">' + icons.external + ' Open Image</button>' : ''}
<button class="secondary-btn" type="button" data-action="capture-config-toggle">${icons.settings} Configure</button>
<button class="primary-btn" type="button" data-action="capture-check"${disabled}>${icons.refresh} ${captureState.requesting ? 'Requesting' : 'Check Now'}</button>
</div>
</div>
<div class="captures-body">
${captureState.error ? '<div class="error-state"><div>' + esc(captureState.error) + '</div></div>' : ''}
${captureState.showConfig ? `
<form class="capture-config-panel" data-action="save-capture-config" data-capture-config-form>
<div>
<div class="section-title" style="margin-top:0">Capture Pages</div>
<div class="capture-pages-list">
${captureState.pages.map((item, index) => `
<div class="capture-page-row">
<input type="text" name="capture_pages[]" value="${attr(item.page || '/')}" placeholder="/page-path">
<button class="secondary-btn" type="button" data-action="capture-remove-page" data-index="${index}" ${captureState.pages.length <= 1 ? 'disabled' : ''}>${icons.close}</button>
</div>
`).join('')}
</div>
</div>
<button class="secondary-btn" type="button" data-action="capture-add-page">${icons.plus} Add Page</button>
<div class="capture-auth-grid">
<label>Basic Auth Username
<input type="text" name="auth_username" value="${attr(captureState.auth.username || '')}" autocomplete="off">
</label>
<label>Basic Auth Password
<input type="password" name="auth_password" value="${attr(captureState.auth.password || '')}" autocomplete="off">
</label>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="capture-config-toggle">Cancel</button>
<button class="primary-btn" type="submit"${captureState.saving ? ' disabled' : ''}>${captureState.saving ? 'Saving' : 'Save Configuration'}</button>
</div>
</form>
` : ''}
${captureState.loading ? renderLoading('Loading captures') : ''}
${!captureState.loading && !captureState.error && !captureState.captures.length ? renderEmpty('No visual captures yet.') : ''}
${!captureState.loading && !captureState.error && captureState.captures.length && !imageUrl ? renderEmpty('No image is available for the selected capture.') : ''}
${!captureState.loading && !captureState.error && imageUrl ? `
<div class="capture-viewer">
<div class="capture-viewer__meta">${esc(captureMeta)}</div>
<div class="capture-image-frame">
<img src="${attr(imageUrl)}" alt="${attr(pageName || 'Visual capture')}" loading="lazy">
</div>
</div>
` : ''}
</div>
</div>
</div>
`;
}
function renderModal() {
const open = !!state.modal;
modalBackdrop.classList.toggle('open', open);
modalRegion.classList.toggle('open', open);
if (!open) {
modalRegion.innerHTML = '';
return;
}
if (state.modal === 'global-search') {
modalRegion.innerHTML = renderGlobalSearchModal();
requestAnimationFrame(() => {
const input = document.getElementById('cc-global-search');
if (input && state.modal === 'global-search' && document.activeElement !== input) {
const pos = input.value.length;
input.focus();
input.setSelectionRange(pos, pos);
}
});
return;
}
if (state.modal === 'captures') {
modalRegion.innerHTML = renderCapturesModal();
return;
}
if (state.modal === 'terminal-schedule') {
modalRegion.innerHTML = `
<form class="modal-card" data-action="schedule-terminal-script">
<div class="modal-head">
<div>
<h2>Schedule Script</h2>
<div class="summary__sub">${formatNumber(state.terminal.selectedTargets.length)} target environment${state.terminal.selectedTargets.length === 1 ? '' : 's'}</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${state.terminal.schedule.error ? '<div class="alert error">' + esc(state.terminal.schedule.error) + '</div>' : ''}
<div class="form-field"><label for="terminal-schedule-date">Date</label><input id="terminal-schedule-date" type="date" name="date" value="${attr(state.terminal.schedule.date)}" required></div>
<div class="form-field"><label for="terminal-schedule-time">Time</label><input id="terminal-schedule-time" type="time" name="time" value="${attr(state.terminal.schedule.time)}" required></div>
<pre class="code-block">${esc(state.terminal.command)}</pre>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit">${state.terminal.schedule.loading ? 'Scheduling' : 'Schedule'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'terminal-save-recipe') {
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-terminal-recipe">
<div class="modal-head">
<h2>Save Recipe</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${state.terminal.saveRecipe.error ? '<div class="alert error">' + esc(state.terminal.saveRecipe.error) + '</div>' : ''}
<div class="form-field"><label for="terminal-recipe-title">Name</label><input id="terminal-recipe-title" name="title" value="${attr(state.terminal.saveRecipe.title)}" required></div>
<div class="form-field"><label for="terminal-recipe-content">Content</label><textarea id="terminal-recipe-content" name="content" rows="9" spellcheck="false">${esc(state.terminal.command)}</textarea></div>
${CC.user?.role === 'administrator' || CC.user?.role === 'owner' ? '<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="public" value="1" ' + (state.terminal.saveRecipe.public ? 'checked' : '') + '> Public recipe</label>' : ''}
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit">${state.terminal.saveRecipe.loading ? 'Saving' : 'Save Recipe'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'quicksave-diff') {
modalRegion.innerHTML = `
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Quicksave file diff">
<div class="modal-head">
<div>
<h2>File Diff</h2>
<div class="summary__sub">${esc(state.fileDiff.file || '')}</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
${state.fileDiff.loading ? renderLoading('Loading diff') : ''}
${state.fileDiff.error ? '<div class="error-state"><div>' + esc(state.fileDiff.error) + '</div><button class="secondary-btn" type="button" data-action="quicksave-diff" data-hash="' + attr(state.fileDiff.hash) + '" data-file="' + attr(state.fileDiff.file) + '">Retry</button></div>' : ''}
${!state.fileDiff.loading && !state.fileDiff.error ? '<pre class="code-block diff-output">' + renderDiffContent(state.fileDiff.content) + '</pre>' : ''}
</div>
`;
return;
}
if (state.modal === 'dns-record') {
const form = state.domainRecordForm;
const title = form.mode === 'edit' ? 'Edit DNS Record' : 'Add DNS Record';
const typeOptions = ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'HTTP'];
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-dns-record">
<div class="modal-head">
<div>
<h2>${title}</h2>
<div class="summary__sub">${esc(dnsValueHelp(form.type))}</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="dns-record-type">Type</label><select id="dns-record-type" name="type">${typeOptions.map(type => '<option value="' + attr(type) + '"' + (type === form.type ? ' selected' : '') + '>' + esc(type) + '</option>').join('')}</select></div>
<div class="form-field"><label for="dns-record-name">Name</label><input id="dns-record-name" name="name" value="${attr(form.name)}" placeholder="@"></div>
<div class="form-field"><label for="dns-record-value">Value</label><textarea id="dns-record-value" name="value" rows="5" spellcheck="false" required>${esc(form.value)}</textarea></div>
<div class="form-field"><label for="dns-record-ttl">TTL</label><input id="dns-record-ttl" name="ttl" type="number" min="60" step="1" value="${attr(form.ttl || 3600)}"></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Record'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'account-edit') {
const form = state.accountForm;
const id = form.accountId || routeId('accounts');
const data = state.accountDetails[id] || {};
const users = asArray(data.users);
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-account">
<div class="modal-head">
<h2>Edit Account</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="account-name">Name</label><input id="account-name" name="name" value="${attr(form.name)}" required></div>
<div class="form-field"><label for="account-billing-user">Billing User</label><select id="account-billing-user" name="billing_user_id"><option value="">No billing user</option>${users.map(user => '<option value="' + attr(user.user_id || user.id) + '"' + (String(form.billing_user_id || '') === String(user.user_id || user.id || '') ? ' selected' : '') + '>' + esc([user.name || user.login, user.email].filter(Boolean).join(' - ')) + '</option>').join('')}</select></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Account'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'account-invite') {
const form = state.accountInviteForm;
const levels = [
['full', 'Full access'],
['sites-only', 'Sites only'],
['domains-only', 'Domains only'],
];
modalRegion.innerHTML = `
<form class="modal-card" data-action="send-account-invite">
<div class="modal-head">
<h2>Invite User</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="account-invite-email">Email</label><input id="account-invite-email" name="email" type="email" value="${attr(form.email)}" required></div>
<div class="form-field"><label for="account-invite-level">Access Level</label><select id="account-invite-level" name="level">${levels.map(([value, label]) => '<option value="' + attr(value) + '"' + (value === form.level ? ' selected' : '') + '>' + esc(label) + '</option>').join('')}</select></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Sending' : 'Send Invite'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'recipe-form') {
const form = state.recipeForm;
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-recipe">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit Recipe' : 'New Recipe'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="recipe-title">Title</label><input id="recipe-title" name="title" value="${attr(form.title)}" required></div>
<div class="form-field"><label for="recipe-content">Content</label><textarea id="recipe-content" name="content" rows="12" spellcheck="false" required>${esc(form.content)}</textarea></div>
<label class="checkbox-line"><input type="checkbox" name="public" value="1" ${form.public ? 'checked' : ''}> Public recipe</label>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Recipe'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'provider-form') {
const form = state.providerForm;
const providerOptions = ['', 'kinsta', 'gridpane', 'rocketdotnet', 'wpengine', 'cloudways', 'envato', 'other'];
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-provider">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit Provider' : 'Add Provider'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="provider-name">Name</label><input id="provider-name" name="name" value="${attr(form.name)}" required></div>
<div class="form-field"><label for="provider-type">Provider Type</label><select id="provider-type" name="provider">${providerOptions.map(value => '<option value="' + attr(value) + '"' + (value === form.provider ? ' selected' : '') + '>' + esc(value || 'Select provider') + '</option>').join('')}</select></div>
<div class="form-field"><label for="provider-credentials">Credentials JSON</label><textarea id="provider-credentials" name="credentials" rows="8" spellcheck="false">${esc(form.credentials)}</textarea></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Provider'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'provider-import') {
const form = state.providerImport;
const selected = form.selected || new Set();
const accountOptions = state.accounts.map(account => '<option value="' + attr(account.account_id || account.id) + '"' + (String(form.accountId || '') === String(account.account_id || account.id || '') ? ' selected' : '') + '>' + esc(account.name || account.email || account.account_id || 'Account') + '</option>').join('');
const remoteRows = asArray(form.remoteSites).map((site, index) => {
const key = String(site.id || site.site_id || site.name || site.domain || index);
return {
html: '<label class="checkbox-line"><input type="checkbox" data-action="toggle-remote-site" data-remote-index="' + attr(index) + '" ' + (selected.has(key) ? 'checked' : '') + '> <span><strong>' + esc(site.name || site.domain || key) + '</strong><small>' + esc([site.domain, site.url, site.environment].filter(Boolean).join(' - ')) + '</small></span></label>',
aside: '',
};
});
modalRegion.innerHTML = `
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Import provider sites">
<div class="modal-head">
<div>
<h2>Import Sites</h2>
<div class="summary__sub">${formatNumber(selected.size)} selected</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="provider-import-account">Account</label><select id="provider-import-account" data-input="provider-import-account"><option value="">Select account</option>${accountOptions}</select></div>
${form.loading ? renderLoading('Loading remote sites') : renderStackRows(remoteRows, 'No remote sites found.', { pageKey: 'provider-import-sites', label: 'sites', perPage: 100 })}
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="button" data-action="import-provider-sites"${form.importing || !selected.size ? ' disabled' : ''}>${form.importing ? 'Importing' : 'Import Selected'}</button>
</div>
</div>
</div>
`;
return;
}
if (state.modal === 'key-form') {
const form = state.keyForm;
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-key">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit SSH Key' : 'Add SSH Key'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="key-title">Title</label><input id="key-title" name="title" value="${attr(form.title)}" required></div>
<div class="form-field"><label for="key-value">Public Key</label><textarea id="key-value" name="key" rows="8" spellcheck="false">${esc(form.key)}</textarea></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Key'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'user-form') {
const form = state.userForm;
const selectedAccounts = asArray(form.account_ids);
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-user">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit User' : 'New User'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${renderErrorList(form.errors)}
<div class="form-field"><label for="user-first-name">First Name</label><input id="user-first-name" name="first_name" value="${attr(form.first_name)}"></div>
<div class="form-field"><label for="user-last-name">Last Name</label><input id="user-last-name" name="last_name" value="${attr(form.last_name)}"></div>
<div class="form-field"><label for="user-email">Email</label><input id="user-email" name="email" type="email" value="${attr(form.email)}" required></div>
<div class="form-field"><label for="user-login">Username</label><input id="user-login" name="login" value="${attr(form.login)}" ${form.mode === 'edit' ? 'readonly' : ''}></div>
<div class="form-field"><label for="user-accounts">Accounts</label><select id="user-accounts" name="account_ids" multiple size="8">${renderMultiOptions(state.accounts, selectedAccounts, ['account_id', 'id'], account => account.name || account.email || account.account_id || 'Account')}</select></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save User'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'process-form') {
const form = state.processForm;
const intervals = ['as-needed', 'daily', 'weekly', 'monthly', 'yearly'];
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-process">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit Process' : 'New Process'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="process-name">Name</label><input id="process-name" name="name" value="${attr(form.name)}" required></div>
<div class="form-field"><label for="process-time">Time Estimate</label><input id="process-time" name="time_estimate" value="${attr(form.time_estimate)}"></div>
<div class="form-field"><label for="process-roles">Roles</label><input id="process-roles" name="roles" value="${attr(form.roles)}"></div>
<div class="form-field"><label for="process-interval">Repeat Interval</label><select id="process-interval" name="repeat_interval">${intervals.map(value => '<option value="' + attr(value) + '"' + (value === form.repeat_interval ? ' selected' : '') + '>' + esc(value) + '</option>').join('')}</select></div>
<div class="form-field"><label for="process-repeat">Repeat Quantity</label><input id="process-repeat" name="repeat_quantity" value="${attr(form.repeat_quantity)}"></div>
<div class="form-field"><label for="process-description">Description</label><textarea id="process-description" name="description" rows="12">${esc(form.description)}</textarea></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Process'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'process-view') {
const view = state.processView;
const process = view.process || {};
modalRegion.innerHTML = `
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Handbook process">
<div class="modal-head">
<div>
<h2>${esc(process.name || process.title || 'Process')}</h2>
<div class="summary__sub">${esc([process.roles, process.time_estimate].filter(Boolean).join(' - '))}</div>
</div>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
${view.loading ? renderLoading('Loading process') : ''}
${view.error ? '<div class="alert error">' + esc(view.error) + '</div>' : ''}
${!view.loading ? '<div class="timeline-markdown">' + renderTimelineMarkdown(process.description || 'No description available.') + '</div>' : ''}
${isAdministrator() && process.process_id ? '<div style="display:flex;justify-content:flex-end;margin-top:16px"><button class="secondary-btn" type="button" data-action="open-process-form" data-process-id="' + attr(process.process_id) + '">Edit</button></div>' : ''}
</div>
`;
return;
}
if (state.modal === 'archive-store') {
const form = state.archiveStoreForm;
modalRegion.innerHTML = `
<form class="modal-card" data-action="store-archive">
<div class="modal-head">
<h2>Store Archive</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="archive-url">Zip URL</label><input id="archive-url" name="url" value="${attr(form.url)}" placeholder="https://example.com/site.zip" required></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Starting' : 'Store Archive'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'report-form') {
const form = state.reportForm;
const intervals = ['daily', 'weekly', 'monthly'];
const accountOptions = state.accounts.map(account => '<option value="' + attr(account.account_id || account.id) + '"' + (String(form.account_id || '') === String(account.account_id || account.id || '') ? ' selected' : '') + '>' + esc(account.name || account.email || account.account_id || 'Account') + '</option>').join('');
const selectedSites = String(form.site_ids || '').split(',').map(value => value.trim()).filter(Boolean);
modalRegion.innerHTML = `
<form class="modal-card" data-action="save-report">
<div class="modal-head">
<h2>${form.mode === 'edit' ? 'Edit Scheduled Report' : 'New Scheduled Report'}</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="report-recipient">Recipient</label><input id="report-recipient" name="recipient" type="email" value="${attr(form.recipient)}" required></div>
<div class="form-field"><label for="report-interval">Interval</label><select id="report-interval" name="interval">${intervals.map(value => '<option value="' + attr(value) + '"' + (value === form.interval ? ' selected' : '') + '>' + esc(value) + '</option>').join('')}</select></div>
<div class="form-field"><label for="report-account">Account</label><select id="report-account" name="account_id"><option value="">Specific sites</option>${accountOptions}</select></div>
<div class="form-field"><label for="report-sites">Sites</label><select id="report-sites" name="site_ids" multiple size="10">${renderMultiOptions(state.sites, selectedSites, ['site_id', 'id'], site => site.name || site.site || site.domain || site.site_id || 'Site')}</select></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Saving' : 'Save Report'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'site-audit-request') {
const form = state.siteAuditRequest;
const siteOptions = state.sites.map(site => '<option value="' + attr(site.site_id || site.id) + '"' + (String(form.site_id || '') === String(site.site_id || site.id || '') ? ' selected' : '') + '>' + esc(site.name || site.site || site.domain || site.site_id || 'Site') + '</option>').join('');
const envOptions = asArray(form.environments).map(env => '<option value="' + attr(env.environment_id || env.id) + '"' + (String(form.environment_id || '') === String(env.environment_id || env.id || '') ? ' selected' : '') + '>' + esc(env.environment || env.name || env.environment_id || 'Environment') + '</option>').join('');
modalRegion.innerHTML = `
<form class="modal-card" data-action="request-site-audit">
<div class="modal-head">
<h2>Request Site Audit</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="audit-site">Site</label><select id="audit-site" name="site_id" data-input="site-audit-site" required><option value="">Select site</option>${siteOptions}</select></div>
<div class="form-field"><label for="audit-env">Environment</label><select id="audit-env" name="environment_id" required ${form.loading ? 'disabled' : ''}><option value="">${form.loading ? 'Loading environments' : 'Select environment'}</option>${envOptions}</select></div>
<div class="form-field"><label for="audit-type">Report Type</label><select id="audit-type" name="report_type"><option value="security_audit" ${form.report_type === 'security_audit' ? 'selected' : ''}>Security Audit</option><option value="performance_audit" ${form.report_type === 'performance_audit' ? 'selected' : ''}>Performance Audit</option></select></div>
<div class="form-field"><label for="audit-notes">Notes</label><textarea id="audit-notes" name="notes" rows="5">${esc(form.notes)}</textarea></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.submitting ? ' disabled' : ''}>${form.submitting ? 'Submitting' : 'Submit Request'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'new-domain') {
const form = state.newDomain;
const accountOptions = state.accounts.map(account => '<option value="' + attr(account.account_id || account.id) + '"' + (String(form.account_id || '') === String(account.account_id || account.id || '') ? ' selected' : '') + '>' + esc(account.name || account.email || account.account_id || 'Account') + '</option>').join('');
const siteOptions = state.sites.map(site => '<option value="' + attr(site.site_id || site.id) + '"' + (String(form.site_id || '') === String(site.site_id || site.id || '') ? ' selected' : '') + '>' + esc(site.name || site.site || site.domain || site.site_id || 'Site') + '</option>').join('');
modalRegion.innerHTML = `
<form class="modal-card" data-action="create-domain">
<div class="modal-head">
<h2>Add Domain</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${renderErrorList(form.errors)}
<div class="form-field"><label for="new-domain-name">Domain Name</label><input id="new-domain-name" name="name" value="${attr(form.name)}" placeholder="example.com" required></div>
${isAdministrator() ? `
<div class="form-field"><label for="new-domain-account">Account</label><select id="new-domain-account" name="account_id" required ${state.loading.accounts ? 'disabled' : ''}><option value="">${state.loading.accounts ? 'Loading accounts' : 'Select account'}</option>${accountOptions}</select></div>
` : `
<div class="form-field"><label for="new-domain-site">Website</label><select id="new-domain-site" name="site_id" required ${state.loading.sites ? 'disabled' : ''}><option value="">${state.loading.sites ? 'Loading sites' : 'Select website'}</option>${siteOptions}</select></div>
`}
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="create_dns_zone" value="1" ${form.create_dns_zone ? 'checked' : ''}> Create DNS zone</label>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Adding' : 'Add Domain'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'new-account') {
const form = state.newAccount;
modalRegion.innerHTML = `
<form class="modal-card" data-action="create-account">
<div class="modal-head">
<h2>Add Account</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${form.error ? '<div class="alert error">' + esc(form.error) + '</div>' : ''}
<div class="form-field"><label for="new-account-name">Name</label><input id="new-account-name" name="name" value="${attr(form.name)}" required></div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit"${form.loading ? ' disabled' : ''}>${form.loading ? 'Adding' : 'Add Account'}</button>
</div>
</div>
</form>
`;
return;
}
if (state.modal === 'new-site') {
modalRegion.innerHTML = `
<form class="modal-card" data-action="create-site">
<div class="modal-head">
<h2>New Site</h2>
<button class="icon-btn" type="button" data-action="close-modal" aria-label="Close">${icons.close}</button>
</div>
<div class="form-grid">
${state.newSite.errors ? '<div class="alert error">' + esc(state.newSite.errors) + '</div>' : ''}
<div class="form-field"><label for="new-site-name">Name</label><input id="new-site-name" name="name" value="${attr(state.newSite.name)}" required></div>
<div class="form-field"><label for="new-site-domain">Domain</label><input id="new-site-domain" name="domain" value="${attr(state.newSite.domain)}"></div>
<div class="form-field"><label for="new-site-slug">Site Slug</label><input id="new-site-slug" name="site" value="${attr(state.newSite.site)}"></div>
<div class="form-field"><label for="new-site-address">Server Address</label><input id="new-site-address" name="address" value="${attr(state.newSite.address)}"></div>
<div class="form-field"><label for="new-site-username">Username</label><input id="new-site-username" name="username" value="${attr(state.newSite.username)}"></div>
<div class="form-field"><label for="new-site-password">Password</label><input id="new-site-password" type="password" name="password" value="${attr(state.newSite.password)}"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
<button class="secondary-btn" type="button" data-action="close-modal">Cancel</button>
<button class="primary-btn" type="submit">${state.newSite.loading ? 'Creating' : 'Create Site'}</button>
</div>
</div>
</form>
`;
}
}
function showToast(message) {
toastRegion.textContent = message;
toastRegion.classList.add('visible');
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => toastRegion.classList.remove('visible'), 2600);
}
function renderPreservingScroll() {
const x = window.scrollX;
const y = window.scrollY;
render();
requestAnimationFrame(() => window.scrollTo(x, y));
}
function getFilterOptions(type) {
if (type === 'core') return CC.site_filters_core || [];
return (CC.site_filters || []).filter(f => f.type === type);
}
function filterEndpointNames() {
return selectedComponentFilters().map(item => item.name).filter(Boolean);
}
function encodeFilterNames(names) {
return names.map(name => encodeURIComponent(name).replace(/%2C/gi, ',')).join(',');
}
function normalizeSelectedFilter(item) {
return Object.assign({}, item, {
selected_versions: asArray(item.selected_versions),
selected_statuses: asArray(item.selected_statuses),
});
}
function selectedFilterPayload(items) {
return items.map(({ name, title, search, type }) => ({ name, title, search, type }));
}
function selectedVersionPayload() {
return selectedSecondaryFilters('version').map(({ name, slug, type, count }) => ({ name, slug, type, count }));
}
function selectedStatusPayload() {
return selectedSecondaryFilters('status').map(({ name, slug, type, count }) => ({ name, slug, type, count }));
}
async function loadSecondaryFilterOptions() {
const names = filterEndpointNames();
if (!names.length) {
state.siteFilterVersions = [];
state.siteFilterStatuses = [];
state.filterOptionsLoading = false;
state.filterOptionsError = '';
render();
return;
}
const requestKey = names.join('|');
state.filterOptionsLoading = true;
state.filterOptionsError = '';
render();
try {
const encoded = encodeFilterNames(names);
const [versions, statuses] = await Promise.all([
apiFetch('/wp-json/captaincore/v1/filters/' + encoded + '/versions/'),
apiFetch('/wp-json/captaincore/v1/filters/' + encoded + '/statuses/'),
]);
if (requestKey !== filterEndpointNames().join('|')) return;
state.siteFilterVersions = asArray(versions);
state.siteFilterStatuses = asArray(statuses);
} catch (error) {
if (requestKey !== filterEndpointNames().join('|')) return;
state.siteFilterVersions = [];
state.siteFilterStatuses = [];
state.filterOptionsError = error.message || 'Unable to load filter options.';
} finally {
if (requestKey === filterEndpointNames().join('|')) {
state.filterOptionsLoading = false;
render();
}
}
}
async function applyFilters() {
if (!activeFilters()) {
state.filteredSiteIds = null;
state.filteredEnvIds = null;
render();
return;
}
const requestId = ++state.filterRequestId;
try {
const payload = {
logic: state.filterLogic,
version_logic: state.filterVersionLogic,
status_logic: state.filterStatusLogic,
version_mode: state.filterVersionMode,
status_mode: state.filterStatusMode,
themes: selectedFilterPayload(state.selectedThemes),
plugins: selectedFilterPayload(state.selectedPlugins),
core: state.selectedCore.map(item => item.name),
versions: selectedVersionPayload(),
statuses: selectedStatusPayload(),
backup_mode: null,
};
const data = await apiFetch('/wp-json/captaincore/v1/filters/sites', {
method: 'POST',
body: JSON.stringify(payload),
});
if (requestId !== state.filterRequestId) return;
const results = Array.isArray(data.results) ? data.results : [];
state.filteredSiteIds = new Set(results.map(item => item.site_id));
state.filteredEnvIds = new Set(results.map(item => item.environment_id));
} catch (error) {
if (requestId !== state.filterRequestId) return;
showToast('Filter request failed.');
state.filteredSiteIds = null;
state.filteredEnvIds = null;
}
render();
}
function toggleFilter(type, item) {
const key = type === 'core' ? 'selectedCore' : (type === 'themes' ? 'selectedThemes' : 'selectedPlugins');
const idx = state[key].findIndex(existing => existing.name === item.name);
if (idx >= 0) state[key].splice(idx, 1);
else state[key].push(normalizeSelectedFilter(item));
state.page = 1;
state.pages = {};
loadSecondaryFilterOptions();
applyFilters();
}
function findSelectedComponentFilter(type, name) {
const list = type === 'themes' ? state.selectedThemes : state.selectedPlugins;
return list.find(item => item.name === name);
}
function toggleSecondaryFilterOption(kind, type, name, index) {
const filter = findSelectedComponentFilter(type, name);
if (!filter) return;
const options = kind === 'status' ? getStatusesForFilter(name) : getVersionsForFilter(name);
const option = options[Number(index)];
if (!option) return;
const key = kind === 'status' ? 'selected_statuses' : 'selected_versions';
filter[key] = asArray(filter[key]);
const idx = filter[key].findIndex(item => item.name === option.name && item.slug === option.slug && item.type === option.type);
if (idx >= 0) filter[key].splice(idx, 1);
else filter[key].push(option);
state.page = 1;
state.pages = {};
applyFilters();
render();
}
function setFilterOperator(field, value) {
if (!['and', 'or', 'include', 'exclude'].includes(value)) return;
state[field] = value;
state.page = 1;
state.pages = {};
applyFilters();
render();
}
function selectedRows() {
const rows = [];
state.sites.forEach(site => {
getEnvironments(site).forEach(env => {
if (state.selection.has(envKey(site, env))) rows.push({ site, env });
});
});
return rows;
}
async function magicLogin(siteId, envId, userId) {
const { env } = findSiteAndEnv(siteId, envId);
if (!env) return;
const envName = String(env.environment || 'production').toLowerCase();
try {
let endpoint = '/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/' + encodeURIComponent(envName) + '/magiclogin';
if (userId) endpoint += '?user_id=' + encodeURIComponent(userId);
const url = await apiFetch(endpoint);
if (typeof url === 'string') safeOpen(url);
else showToast('Login failed.');
} catch (error) {
showToast('Login request failed.');
}
}
async function signIn(form) {
state.login.user_login = form.user_login.value;
state.login.user_password = form.user_password.value;
state.login.tfa_code = form.tfa_code ? form.tfa_code.value : '';
state.login.loading = true;
state.login.errors = '';
state.login.info = '';
render();
try {
const data = await apiFetch('/wp-json/captaincore/v1/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'signIn', login: state.login }),
});
if (!data.errors && !data.info) {
window.location = window.location.origin + routeUrl('/sites');
return;
}
state.login.errors = data.errors || '';
state.login.info = data.info || '';
} catch (error) {
state.login.errors = error.message || 'Sign in failed.';
} finally {
state.login.loading = false;
render();
}
}
async function signOut() {
try {
await apiFetch('/wp-json/captaincore/v1/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'signOut' }),
});
} catch (error) {}
window.location = window.location.origin + routeUrl('/login');
}
async function createSite(form) {
state.newSite = Object.assign(state.newSite, {
name: form.name.value,
domain: form.domain.value,
site: form.site.value,
address: form.address.value,
username: form.username.value,
password: form.password.value,
loading: true,
errors: '',
});
render();
const payload = {
site: {
name: state.newSite.name,
site: state.newSite.site || state.newSite.name,
environments: [{
environment: 'Production',
site: state.newSite.site || state.newSite.name,
address: state.newSite.address,
username: state.newSite.username,
password: state.newSite.password,
protocol: 'sftp',
port: '2222',
home_directory: '',
updates_enabled: '1',
}],
},
};
try {
await apiFetch('/wp-json/captaincore/v1/sites', {
method: 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
state.newSite = { name: '', domain: '', site: '', address: '', username: '', password: '', loading: false, errors: '' };
state.fetched.sites = false;
await loadCollection('sites', '/wp-json/captaincore/v1/sites', true);
showToast('Site created.');
} catch (error) {
state.newSite.loading = false;
state.newSite.errors = error.message || 'Unable to create site.';
render();
}
}
function openNewDomain() {
state.newDomain = { name: '', account_id: '', site_id: '', create_dns_zone: true, loading: false, errors: [] };
state.modal = 'new-domain';
render();
if (isAdministrator()) loadCollection('accounts', '/wp-json/captaincore/v1/accounts/');
else loadCollection('sites', '/wp-json/captaincore/v1/sites');
}
async function createDomain(form) {
const name = form.name.value.trim();
const accountId = form.account_id ? form.account_id.value : '';
const siteId = form.site_id ? form.site_id.value : '';
const errors = [];
if (!name) errors.push("Domain can't be empty.");
if (isAdministrator() && !accountId) errors.push("Account can't be empty.");
if (!isAdministrator() && !siteId) errors.push('Website must be selected.');
if (errors.length) {
state.newDomain.errors = errors;
render();
return;
}
state.newDomain = {
name,
account_id: accountId,
site_id: siteId,
create_dns_zone: !!form.create_dns_zone?.checked,
loading: true,
errors: [],
};
render();
const payload = {
name,
create_dns_zone: state.newDomain.create_dns_zone,
};
if (isAdministrator()) payload.account_id = accountId;
else payload.site_id = siteId;
try {
const response = await apiFetch('/wp-json/captaincore/v1/domains', {
method: 'POST',
body: JSON.stringify(payload),
});
if (response?.errors) {
state.newDomain.loading = false;
state.newDomain.errors = response.errors;
render();
return;
}
state.modal = null;
state.newDomain = { name: '', account_id: '', site_id: '', create_dns_zone: true, loading: false, errors: [] };
state.fetched.domains = false;
await loadCollection('domains', '/wp-json/captaincore/v1/domains/', true);
showToast('Domain added.');
} catch (error) {
state.newDomain.loading = false;
state.newDomain.errors = normalizeErrors(error.payload?.details || error.payload || error.message || 'Unable to add domain.');
render();
}
}
function openNewAccount() {
state.newAccount = { name: '', loading: false, error: '' };
state.modal = 'new-account';
render();
}
async function createAccount(form) {
const name = form.name.value.trim();
if (!name) {
state.newAccount.error = 'Account name is required.';
render();
return;
}
state.newAccount = { name, loading: true, error: '' };
render();
try {
await apiFetch('/wp-json/captaincore/v1/accounts/', {
method: 'POST',
body: JSON.stringify({ name }),
});
state.modal = null;
state.newAccount = { name: '', loading: false, error: '' };
state.fetched.accounts = false;
await loadCollection('accounts', '/wp-json/captaincore/v1/accounts/', true);
showToast('Account added.');
} catch (error) {
state.newAccount.loading = false;
state.newAccount.error = error.message || 'Unable to add account.';
render();
}
}
function currentDomainDetail() {
const id = routeId('domains');
return state.domainDetails[id] || null;
}
function findDnsRecord(recordId) {
const data = currentDomainDetail();
return asArray(data?.records).find(record => String(dnsRecordId(record)) === String(recordId));
}
function openDnsRecordForm(recordId) {
const id = routeId('domains');
const record = recordId ? findDnsRecord(recordId) : null;
state.domainRecordForm = {
mode: record ? 'edit' : 'create',
domainId: id,
recordId: record ? dnsRecordId(record) : '',
type: record ? dnsRecordType(record) : 'A',
name: record ? dnsRecordName(record) : '',
value: record ? dnsRecordFormValue(record) : '',
ttl: String(record ? dnsRecordTtl(record) : 3600),
loading: false,
error: '',
};
state.modal = 'dns-record';
render();
}
async function saveDnsRecord(form) {
const recordForm = state.domainRecordForm;
const domainId = recordForm.domainId || routeId('domains');
const type = String(form.type.value || 'A').toUpperCase();
const valueText = form.value.value.trim();
if (!domainId || !valueText) {
state.domainRecordForm.error = 'Record value is required.';
render();
return;
}
state.domainRecordForm = Object.assign({}, recordForm, {
type,
name: form.name.value.trim(),
value: form.value.value,
ttl: form.ttl.value || '3600',
loading: true,
error: '',
});
render();
const payload = {
type,
name: state.domainRecordForm.name,
value: parseDnsRecordValue(type, valueText),
ttl: Number(state.domainRecordForm.ttl) || 3600,
};
const editing = recordForm.mode === 'edit' && recordForm.recordId;
const endpoint = '/wp-json/captaincore/v1/dns/' + encodeURIComponent(domainId) + '/records' + (editing ? '/' + encodeURIComponent(recordForm.recordId) : '');
try {
await apiFetch(endpoint, {
method: editing ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
state.domainRecordForm.loading = false;
await loadDomainDetail(domainId, true);
showToast(editing ? 'DNS record updated.' : 'DNS record added.');
} catch (error) {
state.domainRecordForm.error = error.message || 'Unable to save DNS record.';
state.domainRecordForm.loading = false;
render();
}
}
async function deleteDnsRecord(recordId) {
const domainId = routeId('domains');
const record = findDnsRecord(recordId);
if (!domainId || !recordId || !window.confirm('Delete DNS record ' + dnsRecordType(record) + ' ' + (dnsRecordName(record) || '@') + '?')) return;
const loadingKey = 'dns-delete:' + recordId;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/dns/' + encodeURIComponent(domainId) + '/records/' + encodeURIComponent(recordId), {
method: 'DELETE',
});
await loadDomainDetail(domainId, true);
showToast('DNS record deleted.');
} catch (error) {
showToast(error.message || 'Unable to delete DNS record.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
async function activateDnsZone(domainId) {
const id = domainId || routeId('domains');
if (!id) return;
const loadingKey = 'dns-zone-activate:' + id;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/domain/' + encodeURIComponent(id) + '/activate-dns-zone', { method: 'POST' });
await loadDomainDetail(id, true);
state.fetched.domains = false;
showToast('DNS zone activated.');
} catch (error) {
showToast(error.message || 'Unable to activate DNS zone.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
async function deleteDnsZone(domainId) {
const id = domainId || routeId('domains');
if (!id || !window.confirm('Delete this DNS zone? DNS records for the zone will be removed from the DNS provider.')) return;
const loadingKey = 'dns-zone-delete:' + id;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/domain/' + encodeURIComponent(id) + '/dns-zone', { method: 'DELETE' });
await loadDomainDetail(id, true);
state.fetched.domains = false;
showToast('DNS zone deleted.');
} catch (error) {
showToast(error.message || 'Unable to delete DNS zone.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
async function deleteDomain(domainId) {
const id = domainId || routeId('domains');
const data = state.domainDetails[id];
const name = data?.domain?.name || 'this domain';
if (!id || !window.confirm('Delete ' + name + '? This removes the domain and its DNS records.')) return;
try {
await apiFetch('/wp-json/captaincore/v1/domains/' + encodeURIComponent(id), { method: 'DELETE' });
delete state.domainDetails[id];
state.fetched.domains = false;
await loadCollection('domains', '/wp-json/captaincore/v1/domains/', true);
navTo('/domains');
showToast('Domain deleted.');
} catch (error) {
showToast(error.message || 'Unable to delete domain.');
}
}
async function fetchDomainAuthCode(domainId) {
const id = domainId || routeId('domains');
if (!id) return;
const loadingKey = 'domain-auth-code:' + id;
state.actionLoading[loadingKey] = true;
render();
try {
const payload = await apiFetch('/wp-json/captaincore/v1/domain/' + encodeURIComponent(id) + '/auth_code');
state.revealedSecrets[loadingKey] = typeof payload === 'string' ? payload : JSON.stringify(payload);
} catch (error) {
showToast(error.message || 'Unable to retrieve auth code.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
function openAccountEdit() {
const id = routeId('accounts');
const data = state.accountDetails[id] || {};
const account = data.account || data || {};
state.accountForm = {
accountId: id,
name: account.name || '',
billing_user_id: firstDefined(account.billing_user_id, account.plan?.billing_user_id, ''),
loading: false,
error: '',
};
state.modal = 'account-edit';
render();
}
async function saveAccount(form) {
const id = state.accountForm.accountId || routeId('accounts');
if (!id || !form.name.value.trim()) {
state.accountForm.error = 'Account name is required.';
render();
return;
}
state.accountForm = Object.assign({}, state.accountForm, {
name: form.name.value.trim(),
billing_user_id: form.billing_user_id.value,
loading: true,
error: '',
});
render();
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id), {
method: 'PUT',
body: JSON.stringify({
account: {
name: state.accountForm.name,
billing_user_id: state.accountForm.billing_user_id,
},
}),
});
state.modal = null;
state.accountForm.loading = false;
state.fetched.accounts = false;
await loadAccountDetail(id, true);
showToast('Account updated.');
} catch (error) {
state.accountForm.error = error.message || 'Unable to update account.';
state.accountForm.loading = false;
render();
}
}
function openAccountInvite() {
const id = routeId('accounts');
state.accountInviteForm = { accountId: id, email: '', level: 'full', loading: false, error: '' };
state.modal = 'account-invite';
render();
}
async function sendAccountInvite(form) {
const id = state.accountInviteForm.accountId || routeId('accounts');
const email = form.email.value.trim();
const level = form.level.value || 'full';
if (!id || !email) {
state.accountInviteForm.error = 'Email is required.';
render();
return;
}
state.accountInviteForm = { accountId: id, email, level, loading: true, error: '' };
render();
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id) + '/invites', {
method: 'POST',
body: JSON.stringify({ invite: email, level }),
});
state.modal = null;
state.accountInviteForm.loading = false;
await loadAccountDetail(id, true);
showToast('Invite sent.');
} catch (error) {
state.accountInviteForm.error = error.message || 'Unable to send invite.';
state.accountInviteForm.loading = false;
render();
}
}
async function deleteAccountInvite(inviteId) {
const id = routeId('accounts');
if (!id || !inviteId || !window.confirm('Delete this pending invite?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id) + '/invites/' + encodeURIComponent(inviteId), {
method: 'DELETE',
});
await loadAccountDetail(id, true);
showToast('Invite deleted.');
} catch (error) {
showToast(error.message || 'Unable to delete invite.');
}
}
function currentAccountDetail() {
const id = routeId('accounts');
return state.accountDetails[id] || null;
}
function findAccountUser(userId) {
const data = currentAccountDetail();
return asArray(data?.users).find(user => String(accountUserId(user)) === String(userId));
}
async function updateAccountUserLevel(userId, level) {
const id = routeId('accounts');
if (!id || !userId || !level) return;
const loadingKey = 'account-user-level:' + userId;
state.actionLoading[loadingKey] = true;
render();
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id) + '/users/' + encodeURIComponent(userId) + '/level', {
method: 'PUT',
body: JSON.stringify({ level }),
});
await loadAccountDetail(id, true);
showToast(level === 'full-billing' ? 'Ownership transferred.' : 'Access level updated.');
} catch (error) {
showToast(error.message || 'Unable to update access level.');
} finally {
state.actionLoading[loadingKey] = false;
render();
}
}
function transferAccountOwner(userId) {
const user = findAccountUser(userId);
const label = [user?.name || user?.login, user?.email].filter(Boolean).join(' ') || 'this user';
if (!window.confirm('Transfer account ownership to ' + label + '?')) return;
updateAccountUserLevel(userId, 'full-billing');
}
async function removeAccountUser(userId) {
const id = routeId('accounts');
const user = findAccountUser(userId);
const label = [user?.name || user?.login, user?.email].filter(Boolean).join(' ') || 'this user';
if (!id || !userId || !window.confirm('Remove access for ' + label + '?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id) + '/users/' + encodeURIComponent(userId), {
method: 'DELETE',
});
await loadAccountDetail(id, true);
state.fetched.accounts = false;
showToast('User access removed.');
} catch (error) {
showToast(error.message || 'Unable to remove user.');
}
}
async function deleteAccount(accountId) {
const id = accountId || routeId('accounts');
const data = state.accountDetails[id];
const account = data?.account || data || {};
const name = account.name || 'this account';
if (!id || !window.confirm('Delete account ' + name + '?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
delete state.accountDetails[id];
state.fetched.accounts = false;
await loadCollection('accounts', '/wp-json/captaincore/v1/accounts/', true);
navTo('/accounts');
showToast('Account deleted.');
} catch (error) {
showToast(error.message || 'Unable to delete account.');
}
}
async function scheduleTerminalScript(form) {
const code = state.terminal.command.trim();
const targets = state.terminal.selectedTargets;
if (!code || !targets.length) {
state.terminal.schedule.error = 'A command and at least one target are required.';
render();
return;
}
state.terminal.schedule.date = form.date.value;
state.terminal.schedule.time = form.time.value;
state.terminal.schedule.loading = true;
state.terminal.schedule.error = '';
render();
try {
await Promise.all(targets.map(target => apiFetch('/wp-json/captaincore/v1/scripts/schedule', {
method: 'POST',
body: JSON.stringify({
environment_id: target.environment_id,
code,
run_at: {
date: state.terminal.schedule.date,
time: state.terminal.schedule.time,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
}),
})));
state.modal = null;
state.terminal.command = '';
showToast('Script scheduled.');
const { id } = currentSiteContext();
if (id) loadSiteDetail(id, true);
} catch (error) {
state.terminal.schedule.error = error.message || 'Unable to schedule script.';
} finally {
state.terminal.schedule.loading = false;
render();
}
}
async function saveTerminalRecipe(form) {
const title = form.title.value.trim();
const content = form.content.value;
if (!title || !content.trim()) {
state.terminal.saveRecipe.error = 'Name and content are required.';
render();
return;
}
state.terminal.saveRecipe.title = title;
state.terminal.saveRecipe.public = form.public && form.public.checked ? 1 : 0;
state.terminal.saveRecipe.loading = true;
state.terminal.saveRecipe.error = '';
render();
try {
const payload = await apiFetch('/wp-json/captaincore/v1/recipes', {
method: 'POST',
body: JSON.stringify({
title,
content,
public: state.terminal.saveRecipe.public,
}),
});
state.recipes = Array.isArray(payload) ? payload : state.recipes;
state.fetched.recipes = true;
state.modal = null;
state.terminal.saveRecipe = { title: '', public: 1, loading: false, error: '' };
showToast('Recipe saved.');
} catch (error) {
state.terminal.saveRecipe.error = error.message || 'Unable to save recipe.';
} finally {
state.terminal.saveRecipe.loading = false;
render();
}
}
function selectedValues(select) {
return select ? Array.from(select.selectedOptions).map(option => option.value).filter(Boolean) : [];
}
function refreshKey(key, loader) {
state.fetched[key] = false;
return loader(true);
}
function openRecipeForm(recipeId = '') {
const recipe = recipeId ? findRecord(state.recipes, recipeId, ['recipe_id', 'id']) : null;
state.recipeForm = {
mode: recipe ? 'edit' : 'create',
recipeId: recipeId || '',
title: recipe?.title || '',
content: recipe?.content || '',
public: recipe ? !!Number(recipe.public) || recipe.public === true : true,
loading: false,
error: '',
};
state.modal = 'recipe-form';
render();
}
async function saveRecipe(form) {
const payload = {
title: form.title.value.trim(),
content: form.content.value,
public: form.public && form.public.checked ? 1 : 0,
};
if (!payload.title || !payload.content.trim()) {
state.recipeForm.error = 'Title and content are required.';
render();
return;
}
state.recipeForm.loading = true;
state.recipeForm.error = '';
render();
try {
const endpoint = state.recipeForm.mode === 'edit'
? '/wp-json/captaincore/v1/recipes/' + encodeURIComponent(state.recipeForm.recipeId)
: '/wp-json/captaincore/v1/recipes';
const data = await apiFetch(endpoint, {
method: state.recipeForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
if (Array.isArray(data)) state.recipes = data;
else await refreshKey('recipes', force => loadCollection('recipes', '/wp-json/captaincore/v1/recipes', force));
state.modal = null;
showToast('Recipe saved.');
} catch (error) {
state.recipeForm.error = error.message || 'Unable to save recipe.';
} finally {
state.recipeForm.loading = false;
render();
}
}
async function deleteRecipe(recipeId) {
const recipe = findRecord(state.recipes, recipeId, ['recipe_id', 'id']);
if (!recipeId || !window.confirm('Delete recipe "' + (recipe?.title || recipeId) + '"?')) return;
try {
const data = await apiFetch('/wp-json/captaincore/v1/recipes/' + encodeURIComponent(recipeId), { method: 'DELETE' });
if (Array.isArray(data)) state.recipes = data;
else state.recipes = state.recipes.filter(item => String(recordId(item, ['recipe_id', 'id'])) !== String(recipeId));
showToast('Recipe deleted.');
render();
} catch (error) {
showToast(error.message || 'Unable to delete recipe.');
}
}
function loadRecipeTerminal(recipeId) {
const recipe = findRecord(state.recipes, recipeId, ['recipe_id', 'id']);
if (!recipe) return;
state.terminal.command = recipe.content || '';
state.terminal.open = true;
state.terminal.show = true;
render();
}
function providerCredentialsForForm(provider) {
const credentials = asArray(provider?.credentials);
return credentials.length ? JSON.stringify(credentials, null, 2) : '[\n { "name": "", "value": "" }\n]';
}
function openProviderForm(providerId = '') {
const provider = providerId ? findRecord(state.providers, providerId, ['provider_id', 'id']) : null;
state.providerForm = {
mode: provider ? 'edit' : 'create',
providerId: providerId || '',
name: provider?.name || '',
provider: provider?.provider || '',
credentials: providerCredentialsForForm(provider),
loading: false,
error: '',
};
state.modal = 'provider-form';
render();
}
function parseProviderCredentials(value) {
const source = String(value || '').trim();
if (!source) return [];
const parsed = JSON.parse(source);
if (!Array.isArray(parsed)) throw new Error('Credentials must be a JSON array.');
return parsed.filter(item => item && item.name && item.value);
}
async function saveProvider(form) {
let credentials = [];
try {
credentials = parseProviderCredentials(form.credentials.value);
} catch (error) {
state.providerForm.error = error.message;
render();
return;
}
const payload = {
provider: {
name: form.name.value.trim(),
provider: form.provider.value,
credentials,
},
};
if (!payload.provider.name || !payload.provider.provider) {
state.providerForm.error = 'Name and provider type are required.';
render();
return;
}
state.providerForm.loading = true;
state.providerForm.error = '';
render();
try {
await apiFetch(state.providerForm.mode === 'edit' ? '/wp-json/captaincore/v1/providers/' + encodeURIComponent(state.providerForm.providerId) : '/wp-json/captaincore/v1/providers', {
method: state.providerForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
await refreshKey('providers', force => loadCollection('providers', '/wp-json/captaincore/v1/providers', force));
showToast('Provider saved.');
} catch (error) {
state.providerForm.error = error.message || 'Unable to save provider.';
} finally {
state.providerForm.loading = false;
render();
}
}
async function deleteProvider(providerId) {
const provider = findRecord(state.providers, providerId, ['provider_id', 'id']);
if (!providerId || !window.confirm('Delete provider "' + (provider?.name || provider?.provider || providerId) + '"?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/providers/' + encodeURIComponent(providerId), { method: 'DELETE' });
state.providers = state.providers.filter(item => String(recordId(item, ['provider_id', 'id'])) !== String(providerId));
state.fetched.providers = false;
showToast('Provider deleted.');
render();
} catch (error) {
showToast(error.message || 'Unable to delete provider.');
}
}
async function openProviderImport(providerId) {
const provider = findRecord(state.providers, providerId, ['provider_id', 'id']);
state.providerImport = { providerId, accountId: '', remoteSites: [], selected: new Set(), loading: true, importing: false, error: '' };
state.modal = 'provider-import';
render();
try {
const data = await apiFetch('/wp-json/captaincore/v1/providers/' + encodeURIComponent(providerId) + '/remote-sites');
state.providerImport.remoteSites = asArray(data);
if (!provider && !state.providerImport.remoteSites.length) state.providerImport.error = 'Provider not found or returned no remote sites.';
} catch (error) {
state.providerImport.error = error.message || 'Unable to load remote sites.';
} finally {
state.providerImport.loading = false;
render();
}
}
function remoteSiteKey(site, index) {
return String(site?.id || site?.site_id || site?.name || site?.domain || index);
}
function toggleRemoteSite(index) {
const site = state.providerImport.remoteSites[Number(index)];
if (!site) return;
const key = remoteSiteKey(site, index);
if (state.providerImport.selected.has(key)) state.providerImport.selected.delete(key);
else state.providerImport.selected.add(key);
render();
}
async function importProviderSites() {
const form = state.providerImport;
const selected = asArray(form.remoteSites).filter((site, index) => form.selected.has(remoteSiteKey(site, index)));
if (!form.providerId || !selected.length) return;
form.importing = true;
form.error = '';
render();
try {
await apiFetch('/wp-json/captaincore/v1/providers/' + encodeURIComponent(form.providerId) + '/import', {
method: 'POST',
body: JSON.stringify({ sites: selected, account_id: form.accountId }),
});
state.modal = null;
state.fetched.sites = false;
loadCollection('sites', '/wp-json/captaincore/v1/sites', true);
showToast('Import started.');
} catch (error) {
form.error = error.message || 'Unable to import sites.';
} finally {
form.importing = false;
render();
}
}
async function saveDefaults(form) {
let users = [];
try {
users = JSON.parse(form.users.value || '[]');
if (!Array.isArray(users)) throw new Error('Default users must be a JSON array.');
} catch (error) {
state.error.defaults = error.message;
render();
return;
}
const payload = {
email: form.email.value.trim(),
timezone: form.timezone.value.trim(),
recipes: selectedValues(form.recipes),
users,
};
state.loading.defaultsSave = true;
state.error.defaults = '';
render();
try {
state.defaults = await apiFetch('/wp-json/captaincore/v1/defaults/global', {
method: 'PUT',
body: JSON.stringify(payload),
});
state.fetched.defaults = true;
showToast('Defaults saved.');
} catch (error) {
state.error.defaults = error.message || 'Unable to save defaults.';
} finally {
state.loading.defaultsSave = false;
render();
}
}
async function saveConfigurations(form) {
const current = Object.assign({}, CC.configurations || {}, state.configurationDetails || {});
const colors = Object.assign({}, CC.colors || {}, current.colors || {});
if (form.name) {
['primary', 'accent', 'success', 'warning', 'error', 'info'].forEach(key => {
const textInput = form.elements['color_text_' + key];
const colorInput = form.elements['color_' + key];
colors[key] = (textInput && textInput.value) || (colorInput && colorInput.value) || colors[key] || '';
});
Object.assign(current, {
name: form.name.value.trim(),
logo: form.logo.value.trim(),
logo_width: form.logo_width.value,
logo_only: !!(form.logo_only && form.logo_only.checked),
dns_introduction: form.dns_introduction.value,
dns_nameservers: form.dns_nameservers.value,
colors,
});
}
if (form.stripe_publishable_key) {
current.stripe_publishable_key = form.stripe_publishable_key.value.trim();
current.currency = form.currency.value.trim() || 'usd';
}
state.loading.configurationsSave = true;
state.error.configurationDetails = '';
render();
try {
state.configurationDetails = await apiFetch('/wp-json/captaincore/v1/configurations/global', {
method: 'PUT',
body: JSON.stringify(current),
});
state.fetched.configurationDetails = true;
showToast('Configurations saved.');
} catch (error) {
state.error.configurationDetails = error.message || 'Unable to save configurations.';
} finally {
state.loading.configurationsSave = false;
render();
}
}
function openKeyForm(keyId = '') {
const key = keyId ? findRecord(state.keys, keyId, ['key_id', 'id']) : null;
state.keyForm = {
mode: key ? 'edit' : 'create',
keyId: keyId || '',
title: key?.title || '',
key: key?.key || '',
loading: false,
error: '',
};
state.modal = 'key-form';
render();
}
async function saveKey(form) {
const payload = { title: form.title.value.trim(), key: form.key.value.trim() };
if (!payload.title) {
state.keyForm.error = 'Title is required.';
render();
return;
}
state.keyForm.loading = true;
state.keyForm.error = '';
render();
try {
const data = await apiFetch(state.keyForm.mode === 'edit' ? '/wp-json/captaincore/v1/keys/' + encodeURIComponent(state.keyForm.keyId) : '/wp-json/captaincore/v1/keys', {
method: state.keyForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
if (data && !Array.isArray(data)) {
const id = recordId(data, ['key_id', 'id']);
state.keys = state.keys.filter(item => String(recordId(item, ['key_id', 'id'])) !== String(id));
state.keys.push(data);
} else {
await refreshKey('keys', force => loadCollection('keys', '/wp-json/captaincore/v1/keys', force));
}
state.modal = null;
showToast('Key saved.');
} catch (error) {
state.keyForm.error = error.message || 'Unable to save key.';
} finally {
state.keyForm.loading = false;
render();
}
}
async function deleteKey(keyId) {
const key = findRecord(state.keys, keyId, ['key_id', 'id']);
if (!keyId || !window.confirm('Delete SSH key "' + (key?.title || keyId) + '"?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/keys/' + encodeURIComponent(keyId), { method: 'DELETE' });
state.keys = state.keys.filter(item => String(recordId(item, ['key_id', 'id'])) !== String(keyId));
state.fetched.keys = false;
showToast('Key deleted.');
render();
} catch (error) {
showToast(error.message || 'Unable to delete key.');
}
}
async function setPrimaryKey(keyId) {
if (!keyId) return;
try {
await apiFetch('/wp-json/captaincore/v1/keys/' + encodeURIComponent(keyId) + '/primary', { method: 'PUT', body: JSON.stringify({}) });
await refreshKey('keys', force => loadCollection('keys', '/wp-json/captaincore/v1/keys', force));
showToast('Primary key updated.');
} catch (error) {
showToast(error.message || 'Unable to update primary key.');
}
}
async function openUserForm(userId = '') {
const user = userId ? findRecord(state.users, userId, ['user_id', 'ID', 'id']) : null;
state.userForm = {
mode: user ? 'edit' : 'create',
userId: userId || '',
first_name: user?.first_name || '',
last_name: user?.last_name || '',
email: user?.email || '',
login: userLogin(user),
account_ids: asArray(user?.account_ids),
loading: false,
errors: [],
};
state.modal = 'user-form';
render();
if (userId) {
try {
const details = await apiFetch('/wp-json/captaincore/v1/users/' + encodeURIComponent(userId));
state.userForm = Object.assign(state.userForm, {
first_name: details.first_name || state.userForm.first_name,
last_name: details.last_name || state.userForm.last_name,
email: details.email || state.userForm.email,
login: userLogin(details) || state.userForm.login,
account_ids: asArray(details.account_ids || state.userForm.account_ids),
});
render();
} catch (error) {}
}
}
async function saveUser(form) {
const payload = {
user_id: state.userForm.userId,
first_name: form.first_name.value.trim(),
last_name: form.last_name.value.trim(),
email: form.email.value.trim(),
login: form.login.value.trim(),
name: [form.first_name.value.trim(), form.last_name.value.trim()].filter(Boolean).join(' ') || form.login.value.trim() || form.email.value.trim(),
account_ids: selectedValues(form.account_ids),
};
state.userForm.loading = true;
state.userForm.errors = [];
render();
try {
const data = await apiFetch(state.userForm.mode === 'edit' ? '/wp-json/captaincore/v1/users/' + encodeURIComponent(state.userForm.userId) : '/wp-json/captaincore/v1/users', {
method: state.userForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
if (data?.errors) {
state.userForm.errors = data.errors;
return;
}
state.modal = null;
await refreshKey('users', force => loadCollection('users', '/wp-json/captaincore/v1/users', force));
showToast('User saved.');
} catch (error) {
state.userForm.errors = [error.message || 'Unable to save user.'];
} finally {
state.userForm.loading = false;
render();
}
}
function openProcessForm(processId = '') {
const process = processId ? findRecord(state.processes, processId, ['process_id', 'id']) || state.processView.process : null;
state.processForm = {
mode: process ? 'edit' : 'create',
processId: processId || '',
name: process?.name || process?.title || '',
time_estimate: process?.time_estimate || '',
repeat_interval: process?.repeat_interval || 'as-needed',
repeat_quantity: process?.repeat_quantity || '',
roles: process?.roles || '',
description: process?.description || '',
loading: false,
error: '',
};
state.modal = 'process-form';
render();
}
async function viewProcess(processId) {
const process = findRecord(state.processes, processId, ['process_id', 'id']);
state.processView = { processId, process: process || null, loading: true, error: '' };
state.modal = 'process-view';
render();
try {
state.processView.process = await apiFetch('/wp-json/captaincore/v1/processes/' + encodeURIComponent(processId));
} catch (error) {
state.processView.error = error.message || 'Unable to load process.';
} finally {
state.processView.loading = false;
render();
}
}
async function saveProcess(form) {
const payload = {
name: form.name.value.trim(),
time_estimate: form.time_estimate.value.trim(),
repeat_interval: form.repeat_interval.value,
repeat_quantity: form.repeat_quantity.value.trim(),
roles: form.roles.value.trim(),
description: form.description.value,
};
if (!payload.name) {
state.processForm.error = 'Name is required.';
render();
return;
}
state.processForm.loading = true;
state.processForm.error = '';
render();
try {
await apiFetch(state.processForm.mode === 'edit' ? '/wp-json/captaincore/v1/processes/' + encodeURIComponent(state.processForm.processId) : '/wp-json/captaincore/v1/processes', {
method: state.processForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
await refreshKey('processes', force => loadCollection('processes', '/wp-json/captaincore/v1/processes', force));
showToast('Process saved.');
} catch (error) {
state.processForm.error = error.message || 'Unable to save process.';
} finally {
state.processForm.loading = false;
render();
}
}
function openArchiveStore() {
state.archiveStoreForm = { url: '', loading: false, error: '' };
state.modal = 'archive-store';
render();
}
async function storeArchive(form) {
const url = form.url.value.trim();
state.archiveStoreForm.url = url;
state.archiveStoreForm.loading = true;
state.archiveStoreForm.error = '';
render();
try {
await apiFetch('/wp-json/captaincore/v1/archive/store', {
method: 'POST',
body: JSON.stringify({ url }),
});
state.modal = null;
showToast('Archive storage started.');
} catch (error) {
state.archiveStoreForm.error = error.message || 'Unable to store archive.';
} finally {
state.archiveStoreForm.loading = false;
render();
}
}
async function shareArchive(path) {
if (!path) return;
try {
const data = await apiFetch('/wp-json/captaincore/v1/archive/share', {
method: 'POST',
body: JSON.stringify({ file: path }),
});
const link = data?.link || data?.url || (typeof data === 'string' ? data : '');
if (link) copyValue(link);
showToast(link ? 'Archive link copied.' : 'Archive link created.');
} catch (error) {
showToast(error.message || 'Unable to create archive link.');
}
}
function openReportForm(reportId = '') {
const report = reportId ? findRecord(state.scheduledReports, reportId, ['scheduled_report_id', 'id']) : null;
state.reportForm = {
mode: report ? 'edit' : 'create',
reportId: reportId || '',
recipient: report?.recipient || '',
interval: report?.interval || 'monthly',
account_id: report?.account_id || '',
site_ids: asArray(report?.site_ids).join(','),
loading: false,
error: '',
};
state.modal = 'report-form';
render();
}
async function saveReport(form) {
const payload = {
recipient: form.recipient.value.trim(),
interval: form.interval.value,
account_id: form.account_id.value,
site_ids: selectedValues(form.site_ids),
};
if (payload.account_id) payload.site_ids = [];
if (!payload.recipient) {
state.reportForm.error = 'Recipient is required.';
render();
return;
}
state.reportForm.loading = true;
state.reportForm.error = '';
render();
try {
await apiFetch(state.reportForm.mode === 'edit' ? '/wp-json/captaincore/v1/scheduled-reports/' + encodeURIComponent(state.reportForm.reportId) : '/wp-json/captaincore/v1/scheduled-reports', {
method: state.reportForm.mode === 'edit' ? 'PUT' : 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
await refreshKey('scheduledReports', force => loadCollection('scheduledReports', '/wp-json/captaincore/v1/scheduled-reports', force));
showToast('Report saved.');
} catch (error) {
state.reportForm.error = error.message || 'Unable to save report.';
} finally {
state.reportForm.loading = false;
render();
}
}
async function deleteReport(reportId) {
if (!reportId || !window.confirm('Delete this scheduled report?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/scheduled-reports/' + encodeURIComponent(reportId), { method: 'DELETE' });
state.scheduledReports = state.scheduledReports.filter(item => String(recordId(item, ['scheduled_report_id', 'id'])) !== String(reportId));
state.fetched.scheduledReports = false;
showToast('Report deleted.');
render();
} catch (error) {
showToast(error.message || 'Unable to delete report.');
}
}
function openSiteAuditReport(auditId) {
if (!auditId) return;
safeOpen(window.location.origin + '/wp-json/captaincore/v1/site-audits/' + encodeURIComponent(auditId) + '/html?_wpnonce=' + encodeURIComponent(state.nonce));
}
async function publishSiteAudit(auditId, publish = true) {
if (!auditId) return;
const loadingKey = 'site-audit-publish:' + auditId;
if (state.actionLoading[loadingKey]) return;
const index = state.siteAudits.findIndex(audit => String(recordId(audit, ['site_audit_id', 'id'])) === String(auditId));
const original = index >= 0 ? Object.assign({}, state.siteAudits[index]) : null;
state.actionLoading[loadingKey] = true;
renderPreservingScroll();
try {
const data = await apiFetch('/wp-json/captaincore/v1/site-audits/' + encodeURIComponent(auditId) + '/publish', { method: publish ? 'POST' : 'DELETE', body: JSON.stringify({}) });
if (index >= 0) {
if (publish) {
state.siteAudits[index] = Object.assign({}, state.siteAudits[index], {
report_path: data?.report_path || state.siteAudits[index].report_path || '',
report_url: data?.report_url || state.siteAudits[index].report_url || '',
});
} else {
state.siteAudits[index] = Object.assign({}, state.siteAudits[index], {
report_path: null,
report_url: '',
});
}
}
showToast(publish ? 'Audit published.' : 'Audit unpublished.');
} catch (error) {
if (index >= 0 && original) state.siteAudits[index] = original;
showToast(error.message || 'Unable to update audit publishing.');
} finally {
state.actionLoading[loadingKey] = false;
renderPreservingScroll();
}
}
function openSiteAuditRequest() {
state.siteAuditRequest = { site_id: '', environment_id: '', report_type: 'security_audit', notes: '', environments: [], loading: false, submitting: false, error: '' };
state.modal = 'site-audit-request';
render();
}
async function loadAuditRequestEnvironments(siteId) {
state.siteAuditRequest.site_id = siteId;
state.siteAuditRequest.environment_id = '';
state.siteAuditRequest.environments = [];
if (!siteId) {
render();
return;
}
state.siteAuditRequest.loading = true;
state.siteAuditRequest.error = '';
render();
try {
const envs = await apiFetch('/wp-json/captaincore/v1/sites/' + encodeURIComponent(siteId) + '/environments');
state.siteAuditRequest.environments = asArray(envs);
const first = state.siteAuditRequest.environments[0];
state.siteAuditRequest.environment_id = first ? (first.environment_id || first.id || '') : '';
} catch (error) {
state.siteAuditRequest.error = error.message || 'Unable to load environments.';
} finally {
state.siteAuditRequest.loading = false;
render();
}
}
async function requestSiteAudit(form) {
const payload = {
site_id: form.site_id.value,
environment_id: form.environment_id.value,
report_type: form.report_type.value,
notes: form.notes.value,
};
if (!payload.site_id || !payload.environment_id) {
state.siteAuditRequest.error = 'Site and environment are required.';
render();
return;
}
state.siteAuditRequest = Object.assign(state.siteAuditRequest, payload, { submitting: true, error: '' });
render();
try {
await apiFetch('/wp-json/captaincore/v1/site-audits/request', {
method: 'POST',
body: JSON.stringify(payload),
});
state.modal = null;
await refreshKey('siteAudits', force => loadCollection('siteAudits', '/wp-json/captaincore/v1/site-audits', force));
showToast('Audit request submitted.');
} catch (error) {
state.siteAuditRequest.error = error.message || 'Unable to request audit.';
} finally {
state.siteAuditRequest.submitting = false;
render();
}
}
async function cancelSiteAudit(auditId) {
if (!auditId || !window.confirm('Cancel this audit request?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/site-audits/' + encodeURIComponent(auditId) + '/cancel', { method: 'POST', body: JSON.stringify({}) });
await refreshKey('siteAudits', force => loadCollection('siteAudits', '/wp-json/captaincore/v1/site-audits', force));
showToast('Audit request cancelled.');
} catch (error) {
showToast(error.message || 'Unable to cancel audit.');
}
}
function applyActivityFilters(form) {
state.activityLogs.filters = {
action: form.action_filter.value.trim(),
entity_type: form.entity_type.value.trim(),
date_from: form.date_from.value,
date_to: form.date_to.value,
};
state.activityLogs.page = 1;
state.fetched.activityLogs = false;
loadActivityLogs(true);
}
function clearActivityFilters() {
state.activityLogs.filters = { action: '', entity_type: '', date_from: '', date_to: '' };
state.activityLogs.page = 1;
state.fetched.activityLogs = false;
loadActivityLogs(true);
}
async function setPrimaryPayment(token) {
if (!token) return;
try {
await apiFetch('/wp-json/captaincore/v1/billing/payment-methods/' + encodeURIComponent(token) + '/primary', { method: 'PUT', body: JSON.stringify({}) });
await loadResource('billingDetails', '/wp-json/captaincore/v1/billing', true, value => value || {});
showToast('Payment method updated.');
} catch (error) {
showToast(error.message || 'Unable to update payment method.');
}
}
async function deletePayment(token) {
if (!token || !window.confirm('Remove this payment method?')) return;
try {
await apiFetch('/wp-json/captaincore/v1/billing/payment-methods/' + encodeURIComponent(token), { method: 'DELETE' });
await loadResource('billingDetails', '/wp-json/captaincore/v1/billing', true, value => value || {});
showToast('Payment method removed.');
} catch (error) {
showToast(error.message || 'Unable to remove payment method.');
}
}
async function saveProfile(form) {
const payload = Object.assign({}, state.profileForm, {
display_name: form.display_name.value.trim(),
email: form.email.value.trim(),
new_password: form.new_password.value,
});
state.profileForm.loading = true;
state.profileForm.errors = [];
state.profileForm.success = '';
render();
try {
const data = await apiFetch('/wp-json/captaincore/v1/me/profile', {
method: 'PUT',
body: JSON.stringify(payload),
});
if (data?.errors) {
state.profileForm.errors = data.errors;
} else {
state.profileForm.display_name = data?.profile?.display_name || payload.display_name;
state.profileForm.email = data?.profile?.email || payload.email;
state.profileForm.new_password = '';
state.profileForm.success = 'Account updated.';
showToast('Profile saved.');
}
} catch (error) {
state.profileForm.errors = [error.message || 'Unable to save profile.'];
} finally {
state.profileForm.loading = false;
render();
}
}
async function destroySession(hash, allOthers = false) {
try {
const payload = allOthers ? { all_others: 1 } : { hash };
const data = await apiFetch('/wp-json/captaincore/v1/sessions', {
method: 'DELETE',
body: JSON.stringify(payload),
});
state.profileForm.sessions = asArray(data?.sessions);
state.fetched.profileSessionsPayload = false;
showToast('Session updated.');
render();
} catch (error) {
showToast(error.message || 'Unable to update sessions.');
}
}
async function acceptInvite(form) {
state.invite.password = form.password.value;
state.invite.accepting = true;
state.invite.error = '';
render();
try {
await apiFetch('/wp-json/captaincore/v1/invites/accept', {
method: 'POST',
body: JSON.stringify({
account: state.invite.account,
token: state.invite.token,
password: state.invite.password,
}),
});
state.fetched.accounts = false;
navTo('/accounts');
showToast('Invite accepted.');
} catch (error) {
state.invite.error = error.message || 'Unable to accept invite.';
} finally {
state.invite.accepting = false;
render();
}
}
document.addEventListener('click', function (event) {
const route = event.target.closest('[data-route]');
if (route) {
event.preventDefault();
state.modal = null;
state.drawer = null;
navTo(route.dataset.route);
return;
}
const actionEl = event.target.closest('[data-action]');
if (!actionEl) {
if (state.popover && !event.target.closest('.chip-wrap')) {
state.popover = '';
render();
}
return;
}
const action = actionEl.dataset.action;
if (action === 'focus-search') {
openGlobalSearch();
}
if (action === 'search-scope') {
state.globalSearch.scope = actionEl.dataset.scope || 'all';
state.globalSearch.activeIndex = 0;
render();
}
if (action === 'toggle-theme') {
state.theme = state.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('captaincore-v2-theme', state.theme);
document.documentElement.dataset.theme = state.theme;
}
if (action === 'sign-out') signOut();
if (action === 'set-view') {
state.viewMode = actionEl.dataset.view;
localStorage.setItem('captaincore-v2-view', state.viewMode);
render();
}
if (action === 'set-sort') {
setSort(actionEl.dataset.pageKey, actionEl.dataset.sortKey, actionEl.dataset.sortInitialDir || 'asc');
}
if (action === 'detail-tab') {
state.detailTabs[actionEl.dataset.detailType] = actionEl.dataset.tab;
render();
if (actionEl.dataset.detailType === 'site' && isSiteFeatureTab(actionEl.dataset.tab)) {
const { id, env } = currentSiteContext();
loadSiteFeature(actionEl.dataset.tab, id, env);
}
}
if (action === 'select-detail-env') {
state.detailEnvironments[actionEl.dataset.siteId] = actionEl.dataset.envId;
render();
const active = state.detailTabs.site;
if (isSiteFeatureTab(active)) {
const { id, env } = currentSiteContext();
loadSiteFeature(active, id, env);
}
}
if (action === 'env-filter') {
state.envFilter = actionEl.dataset.filter;
state.page = 1;
state.pages = {};
render();
}
if (action === 'toggle-popover') {
state.popover = state.popover === actionEl.dataset.popover ? '' : actionEl.dataset.popover;
render();
}
if (action === 'toggle-filter') {
const type = actionEl.dataset.filterType;
const index = Number(actionEl.dataset.filterIndex);
const options = getFilterOptions(type).filter(item => !state.filterSearch[type] || normalizeText(item.search || item.title || item.name).includes(normalizeText(state.filterSearch[type]))).slice(0, 80);
if (options[index]) toggleFilter(type, options[index]);
}
if (action === 'filter-logic') {
setFilterOperator('filterLogic', actionEl.dataset.value);
}
if (action === 'filter-version-mode') {
setFilterOperator('filterVersionMode', actionEl.dataset.value);
}
if (action === 'filter-version-logic') {
setFilterOperator('filterVersionLogic', actionEl.dataset.value);
}
if (action === 'filter-status-mode') {
setFilterOperator('filterStatusMode', actionEl.dataset.value);
}
if (action === 'filter-status-logic') {
setFilterOperator('filterStatusLogic', actionEl.dataset.value);
}
if (action === 'toggle-filter-version') {
toggleSecondaryFilterOption('version', actionEl.dataset.filterType, actionEl.dataset.filterName, actionEl.dataset.index);
}
if (action === 'toggle-filter-status') {
toggleSecondaryFilterOption('status', actionEl.dataset.filterType, actionEl.dataset.filterName, actionEl.dataset.index);
}
if (action === 'clear-filters') {
state.selectedCore = [];
state.selectedThemes = [];
state.selectedPlugins = [];
state.filterLogic = 'and';
state.filterVersionLogic = 'and';
state.filterStatusLogic = 'and';
state.filterVersionMode = 'include';
state.filterStatusMode = 'include';
state.siteFilterVersions = [];
state.siteFilterStatuses = [];
state.filterOptionsError = '';
state.filterRequestId++;
state.filteredSiteIds = null;
state.filteredEnvIds = null;
state.popover = '';
state.page = 1;
state.pages = {};
render();
}
if (action === 'toggle-select') {
event.stopPropagation();
const { site, env } = findSiteAndEnv(actionEl.dataset.siteId, actionEl.dataset.envId);
const key = envKey(site, env);
if (state.selection.has(key)) state.selection.delete(key);
else state.selection.add(key);
render();
}
if (action === 'select-all') {
const sites = visibleSites().slice((state.page - 1) * state.perPage, state.page * state.perPage);
const allSelected = allVisibleSelected(sites);
sites.forEach(site => visibleEnvironments(site).forEach(env => {
const key = envKey(site, env);
if (allSelected) state.selection.delete(key);
else state.selection.add(key);
}));
render();
}
if (action === 'open-drawer') {
if (event.target.closest('.row-action') || event.target.matches('input')) return;
state.drawer = { type: 'site', siteId: actionEl.dataset.siteId, envId: actionEl.dataset.envId };
state.drawerTab = 'overview';
render();
}
if (action === 'open-user-drawer') {
openUserDrawer(actionEl.dataset.userId);
}
if (action === 'refresh-user-drawer') {
loadUserDrawerDetails(actionEl.dataset.userId || state.drawer?.userId, true);
}
if (action === 'drawer-tab') {
state.drawerTab = actionEl.dataset.tab || 'overview';
render();
}
if (action === 'close-drawer') {
state.drawer = null;
render();
}
if (action === 'open-url') {
event.stopPropagation();
safeOpen(actionEl.dataset.url);
}
if (action === 'copy-value') {
event.stopPropagation();
copyValue(actionEl.dataset.value);
}
if (action === 'toggle-site-secret') {
event.stopPropagation();
const key = actionEl.dataset.secretKey || '';
if (key) state.revealedSecrets[key] = !state.revealedSecrets[key];
render();
}
if (action === 'magic-login') {
event.stopPropagation();
magicLogin(actionEl.dataset.siteId, actionEl.dataset.envId, actionEl.dataset.userId);
}
if (action === 'open-captures') {
event.stopPropagation();
openCaptures(actionEl.dataset.siteId, actionEl.dataset.envId);
}
if (action === 'open-terminal-current') {
openTerminalForCurrentEnv();
}
if (action === 'terminal-open') {
state.terminal.open = true;
state.terminal.show = true;
loadRecipes();
renderTerminal();
}
if (action === 'terminal-minimize') {
state.terminal.open = false;
state.terminal.show = true;
renderTerminal();
}
if (action === 'terminal-hide') {
event.stopPropagation();
state.terminal.open = false;
state.terminal.show = false;
renderTerminal();
}
if (action === 'terminal-close') {
state.terminal.open = false;
state.terminal.show = false;
renderTerminal();
}
if (action === 'terminal-fullscreen') {
state.terminal.fullscreen = !state.terminal.fullscreen;
localStorage.setItem('captaincore-v2-terminal-fullscreen', state.terminal.fullscreen ? 'true' : 'false');
render();
}
if (action === 'terminal-toggle-target') {
const target = terminalTargets().find(item => String(item.environment_id) === String(actionEl.dataset.envId));
if (target) toggleTerminalTarget(target);
renderTerminal();
}
if (action === 'terminal-add-filtered') {
selectFilteredTargetsForTerminal();
}
if (action === 'terminal-clear-targets') {
state.terminal.selectedTargets = [];
renderTerminal();
}
if (action === 'terminal-tool-tab') {
state.terminal.toolTab = actionEl.dataset.tab || 'system';
if (state.terminal.toolTab === 'cookbook') loadRecipes();
renderTerminal();
}
if (action === 'terminal-system-tool') {
runTerminalSystemTool(actionEl.dataset.tool);
}
if (action === 'scripts-system-tool') {
openTerminalForCurrentEnv(false);
runTerminalSystemTool(actionEl.dataset.tool);
}
if (action === 'terminal-preview-recipe' || action === 'scripts-preview-recipe') {
const recipe = state.recipes.find(item => String(item.recipe_id) === String(actionEl.dataset.recipeId));
if (recipe) {
if (action === 'scripts-preview-recipe') openTerminalForCurrentEnv(false);
else openTerminal([], false);
state.terminal.command = recipe.content || '';
renderTerminal();
requestAnimationFrame(() => {
const input = document.querySelector('[data-input="terminal-command"]');
if (input) input.focus();
});
}
}
if (action === 'terminal-run') {
executeTerminalCommand();
}
if (action === 'terminal-cancel-job') {
cancelTerminalJob(actionEl.dataset.jobClientId);
}
if (action === 'terminal-copy-job') {
copyJobStream(actionEl.dataset.jobClientId);
}
if (action === 'terminal-schedule-open') {
if (!state.terminal.command.trim()) return;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
state.terminal.schedule.date = tomorrow.toISOString().slice(0, 10);
state.terminal.schedule.time = '05:00';
state.terminal.schedule.error = '';
state.modal = 'terminal-schedule';
render();
}
if (action === 'terminal-save-open') {
if (!state.terminal.command.trim()) return;
state.terminal.saveRecipe.error = '';
state.modal = 'terminal-save-recipe';
render();
}
if (action === 'backup-download') {
runBackupAction('backup_download', actionEl.dataset.backupId);
}
if (action === 'backup-restore') {
runBackupAction('backup_restore', actionEl.dataset.backupId);
}
if (action === 'toggle-quicksave-detail') {
toggleQuicksaveDetail(actionEl.dataset.hash);
}
if (action === 'load-quicksave-detail') {
loadQuicksaveDetail(actionEl.dataset.hash);
}
if (action === 'toggle-update-log') {
toggleUpdateLog(actionEl.dataset.hashBefore, actionEl.dataset.hashAfter);
}
if (action === 'load-update-log-detail') {
loadUpdateLogDetail(actionEl.dataset.hashBefore, actionEl.dataset.hashAfter);
}
if (action === 'toggle-quicksave-files') {
toggleQuicksaveFiles(actionEl.dataset.hash);
}
if (action === 'load-quicksave-files') {
loadQuicksaveFiles(actionEl.dataset.hash, true);
}
if (action === 'toggle-component-files') {
toggleComponentFiles(actionEl.dataset.hash, actionEl.dataset.match, actionEl.dataset.detailKey);
}
if (action === 'load-component-files') {
loadComponentFiles(actionEl.dataset.hash, actionEl.dataset.match, actionEl.dataset.detailKey, true);
}
if (action === 'toggle-file-group') {
toggleFileGroup(actionEl.dataset.hash, actionEl.dataset.groupKey);
}
if (action === 'quicksave-diff') {
openQuicksaveDiff(actionEl.dataset.hash, actionEl.dataset.file);
}
if (action === 'quicksave-rollback') {
rollbackQuicksave(actionEl.dataset.hash);
}
if (action === 'open-dns-record') {
openDnsRecordForm(actionEl.dataset.recordId || '');
}
if (action === 'edit-dns-record') {
event.stopPropagation();
openDnsRecordForm(actionEl.dataset.recordId || '');
}
if (action === 'delete-dns-record') {
event.stopPropagation();
deleteDnsRecord(actionEl.dataset.recordId);
}
if (action === 'activate-dns-zone') {
activateDnsZone(actionEl.dataset.domainId);
}
if (action === 'delete-dns-zone') {
deleteDnsZone(actionEl.dataset.domainId);
}
if (action === 'delete-domain') {
deleteDomain(actionEl.dataset.domainId);
}
if (action === 'fetch-domain-auth-code') {
fetchDomainAuthCode(actionEl.dataset.domainId);
}
if (action === 'open-account-edit') {
openAccountEdit();
}
if (action === 'open-account-invite') {
openAccountInvite();
}
if (action === 'delete-account-invite') {
deleteAccountInvite(actionEl.dataset.inviteId);
}
if (action === 'transfer-account-owner') {
transferAccountOwner(actionEl.dataset.userId);
}
if (action === 'remove-account-user') {
removeAccountUser(actionEl.dataset.userId);
}
if (action === 'delete-account') {
deleteAccount(actionEl.dataset.accountId);
}
if (action === 'prev-page' && state.page > 1) {
state.page--;
render();
}
if (action === 'next-page') {
state.page++;
render();
}
if (action === 'set-page') {
const pageKey = actionEl.dataset.pageKey;
const page = Number(actionEl.dataset.page);
if (pageKey && Number.isFinite(page)) {
state.pages[pageKey] = Math.max(1, page);
render();
}
}
if (action === 'refresh-sites') loadCollection('sites', '/wp-json/captaincore/v1/sites', true);
if (action === 'refresh-site-detail') loadSiteDetail(routeId('sites'), true);
if (action === 'refresh-site-feature') {
const { id, env } = currentSiteContext();
loadSiteFeature(actionEl.dataset.feature || state.detailTabs.site, id, env, true);
}
if (action === 'load-log-file') {
const { id, env } = currentSiteContext();
fetchSiteLogFile(id, env, actionEl.dataset.file);
}
if (action === 'load-selected-log-file') {
const { id, env } = currentSiteContext();
const bucket = siteFeature(id, env || {});
fetchSiteLogFile(id, env, bucket.selectedLog);
}
if (action === 'stats-timeframe') {
applyStatsTimeframe(actionEl.dataset.range || '12m');
}
if (action === 'stats-share') {
shareStats(actionEl.dataset.sharing || 'none');
}
if (action === 'stats-share-save') {
shareStats('private');
}
if (action === 'capture-config-toggle') {
syncCaptureFormState();
state.captures.showConfig = !state.captures.showConfig;
render();
}
if (action === 'capture-add-page') {
syncCaptureFormState();
state.captures.pages.push({ page: '' });
render();
}
if (action === 'capture-remove-page') {
syncCaptureFormState();
const index = Number(actionEl.dataset.index);
if (Number.isFinite(index) && state.captures.pages.length > 1) state.captures.pages.splice(index, 1);
render();
}
if (action === 'capture-check') {
requestNewCapture();
}
if (action === 'refresh-domain-detail') loadDomainDetail(routeId('domains'), true);
if (action === 'refresh-account-detail') loadAccountDetail(routeId('accounts'), true);
if (action === 'refresh-current') {
state.fetched = {};
loadForRoute();
}
if (action === 'open-recipe-form') openRecipeForm(actionEl.dataset.recipeId || '');
if (action === 'delete-recipe') deleteRecipe(actionEl.dataset.recipeId);
if (action === 'load-recipe-terminal') loadRecipeTerminal(actionEl.dataset.recipeId);
if (action === 'open-provider-form') openProviderForm(actionEl.dataset.providerId || '');
if (action === 'delete-provider') deleteProvider(actionEl.dataset.providerId);
if (action === 'open-provider-import') openProviderImport(actionEl.dataset.providerId);
if (action === 'toggle-remote-site') {
event.stopPropagation();
toggleRemoteSite(actionEl.dataset.remoteIndex);
}
if (action === 'import-provider-sites') importProviderSites();
if (action === 'open-key-form') openKeyForm(actionEl.dataset.keyId || '');
if (action === 'delete-key') deleteKey(actionEl.dataset.keyId);
if (action === 'set-primary-key') setPrimaryKey(actionEl.dataset.keyId);
if (action === 'open-user-form') openUserForm(actionEl.dataset.userId || '');
if (action === 'open-process-form') openProcessForm(actionEl.dataset.processId || '');
if (action === 'view-process') viewProcess(actionEl.dataset.processId);
if (action === 'open-archive-store') openArchiveStore();
if (action === 'share-archive') shareArchive(actionEl.dataset.archivePath);
if (action === 'open-report-form') openReportForm(actionEl.dataset.reportId || '');
if (action === 'delete-report') deleteReport(actionEl.dataset.reportId);
if (action === 'open-site-audit') openSiteAuditReport(actionEl.dataset.auditId);
if (action === 'publish-site-audit') publishSiteAudit(actionEl.dataset.auditId, true);
if (action === 'unpublish-site-audit') publishSiteAudit(actionEl.dataset.auditId, false);
if (action === 'copy-site-audit-link') copyValue(actionEl.dataset.url);
if (action === 'cancel-site-audit') cancelSiteAudit(actionEl.dataset.auditId);
if (action === 'open-site-audit-request') openSiteAuditRequest();
if (action === 'security-tab') {
state.security.tab = actionEl.dataset.tab || 'vulnerabilities';
render();
}
if (action === 'activity-page') {
const page = Number(actionEl.dataset.page);
if (Number.isFinite(page) && page >= 1) {
state.activityLogs.page = page;
state.fetched.activityLogs = false;
loadActivityLogs(true);
}
}
if (action === 'clear-activity-filters') clearActivityFilters();
if (action === 'set-primary-payment') setPrimaryPayment(actionEl.dataset.token);
if (action === 'delete-payment') deletePayment(actionEl.dataset.token);
if (action === 'destroy-session') destroySession(actionEl.dataset.sessionHash);
if (action === 'destroy-other-sessions') destroySession('', true);
if (action === 'clear-selection') {
state.selection.clear();
render();
}
if (action === 'bulk-terminal') {
const targets = selectedRows().map(({ site, env }) => normalizeTarget(site, env)).filter(Boolean);
openTerminal(targets);
}
if (action === 'bulk-open') selectedRows().forEach(({ env }) => safeOpen(env.home_url));
if (action === 'bulk-login') selectedRows().forEach(({ site, env }) => magicLogin(site.site_id, env.environment_id || env.environment));
if (action === 'open-new-site') {
state.modal = 'new-site';
render();
}
if (action === 'open-new-domain') {
openNewDomain();
}
if (action === 'open-new-account') {
openNewAccount();
}
if (action === 'close-modal') {
state.modal = null;
render();
}
if (action === 'refresh-recipes') {
loadRecipes(true);
}
});
document.addEventListener('input', function (event) {
const input = event.target.closest('[data-input]');
if (!input) return;
const cursor = input.selectionStart;
if (input.dataset.input === 'search') {
state.search = input.value;
state.page = 1;
state.pages = {};
render();
requestAnimationFrame(() => {
const next = document.getElementById('cc-v2-search');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'global-search') {
state.globalSearch.query = input.value;
state.globalSearch.activeIndex = 0;
render();
requestAnimationFrame(() => {
const next = document.getElementById('cc-global-search');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'stats-filter') {
state.siteStats[input.dataset.field] = input.value;
state.siteStats.timeframe = '';
const { id, env } = currentSiteContext();
const bucket = siteFeature(id, env || {});
bucket.statsLoaded = false;
}
if (input.dataset.input === 'stats-share-password') {
state.siteStats.sharePassword = input.value;
}
if (input.dataset.input === 'site-user-search') {
state.userSearch = input.value;
state.pages = {};
render();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="site-user-search"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'site-log-search') {
state.logSearch = input.value;
render();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="site-log-search"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'site-log-limit') {
const { id, env } = currentSiteContext();
const bucket = siteFeature(id, env || {});
bucket.logLimit = String(Math.max(10, Math.min(10000, Number(input.value) || 1000)));
}
if (input.dataset.input === 'quicksave-search') {
state.quicksaveSearch = input.value;
state.pages = {};
render();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="quicksave-search"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'filter-search') {
const type = input.dataset.filterType;
state.filterSearch[type] = input.value;
render();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="filter-search"][data-filter-type="' + type + '"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'terminal-command') {
state.terminal.command = input.value;
}
if (input.dataset.input === 'terminal-target-search') {
state.terminal.targetSearch = input.value;
renderTerminal();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="terminal-target-search"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
if (input.dataset.input === 'terminal-tool-search') {
state.terminal.toolSearch = input.value;
renderTerminal();
requestAnimationFrame(() => {
const next = document.querySelector('[data-input="terminal-tool-search"]');
if (next) {
next.focus();
next.setSelectionRange(cursor, cursor);
}
});
}
});
document.addEventListener('submit', function (event) {
const form = event.target;
const action = form.dataset.action;
if (!action) return;
event.preventDefault();
if (action === 'sign-in') signIn(form);
if (action === 'create-site') createSite(form);
if (action === 'create-domain') createDomain(form);
if (action === 'create-account') createAccount(form);
if (action === 'schedule-terminal-script') scheduleTerminalScript(form);
if (action === 'save-terminal-recipe') saveTerminalRecipe(form);
if (action === 'save-capture-config') saveCaptureConfig(form);
if (action === 'save-dns-record') saveDnsRecord(form);
if (action === 'save-account') saveAccount(form);
if (action === 'send-account-invite') sendAccountInvite(form);
if (action === 'save-recipe') saveRecipe(form);
if (action === 'save-provider') saveProvider(form);
if (action === 'save-defaults') saveDefaults(form);
if (action === 'save-configurations') saveConfigurations(form);
if (action === 'save-key') saveKey(form);
if (action === 'save-user') saveUser(form);
if (action === 'save-process') saveProcess(form);
if (action === 'store-archive') storeArchive(form);
if (action === 'save-report') saveReport(form);
if (action === 'apply-activity-filters') applyActivityFilters(form);
if (action === 'request-site-audit') requestSiteAudit(form);
if (action === 'save-profile') saveProfile(form);
if (action === 'accept-invite') acceptInvite(form);
});
document.addEventListener('change', function (event) {
const input = event.target.closest('[data-input]');
if (!input) return;
if (input.dataset.input === 'stats-filter') {
state.siteStats[input.dataset.field] = input.value;
state.siteStats.timeframe = '';
if (input.dataset.field === 'grouping') {
adjustStatsRangeForGrouping(input.value);
}
refreshStatsForCurrent();
}
if (input.dataset.input === 'site-user-role') {
state.userRoleFilter = input.value || 'all';
state.pages = {};
render();
}
if (input.dataset.input === 'site-log-file') {
const { id, env } = currentSiteContext();
fetchSiteLogFile(id, env, input.value);
}
if (input.dataset.input === 'site-log-limit') {
const { id, env } = currentSiteContext();
const bucket = siteFeature(id, env || {});
bucket.logLimit = String(Math.max(10, Math.min(10000, Number(input.value) || 1000)));
if (bucket.selectedLog) fetchSiteLogFile(id, env, bucket.selectedLog);
}
if (input.dataset.input === 'account-user-level') {
updateAccountUserLevel(input.dataset.userId, input.value);
}
if (input.dataset.input === 'provider-import-account') {
state.providerImport.accountId = input.value;
render();
}
if (input.dataset.input === 'site-audit-site') {
loadAuditRequestEnvironments(input.value);
}
if (input.dataset.input === 'capture-select') {
state.captures.selectedCaptureId = input.value;
const capture = selectedCapture();
const pages = normalizedCapturePages(capture);
state.captures.selectedPageKey = pages.length ? capturePageKey(pages[0], 0) : '';
render();
}
if (input.dataset.input === 'capture-page-select') {
state.captures.selectedPageKey = input.value;
render();
}
});
document.addEventListener('keydown', function (event) {
if ((event.key === 'Enter' || event.key === ' ') && event.target.classList?.contains('site-row')) {
event.preventDefault();
event.target.click();
return;
}
if ((event.key === 'Enter' || event.key === ' ') && event.target.classList?.contains('stack-row') && event.target.classList.contains('clickable')) {
event.preventDefault();
event.target.click();
return;
}
if (event.key === 'Escape') {
state.drawer = null;
state.modal = null;
state.popover = '';
render();
}
if (state.modal === 'global-search') {
const count = Object.values(groupedGlobalSearchResults()).flat().length;
if (event.key === 'ArrowDown' && count) {
event.preventDefault();
state.globalSearch.activeIndex = Math.min(count - 1, state.globalSearch.activeIndex + 1);
render();
}
if (event.key === 'ArrowUp' && count) {
event.preventDefault();
state.globalSearch.activeIndex = Math.max(0, state.globalSearch.activeIndex - 1);
render();
}
if (event.key === 'Enter') {
event.preventDefault();
openActiveGlobalSearchResult();
}
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
openGlobalSearch();
}
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && event.target.closest('[data-input="terminal-command"]')) {
event.preventDefault();
executeTerminalCommand();
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'j') {
event.preventDefault();
state.terminal.open = !state.terminal.open;
state.terminal.show = true;
if (state.terminal.open) loadRecipes();
renderTerminal();
}
});
window.addEventListener('popstate', function () {
state.route = routeFromLocation();
loadForRoute();
render();
});
loadForRoute();
render();
})();
</script>
</body>
</html>