@kedem/okdb 1.8.14 → 1.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kedem/okdb",
3
- "version": "1.8.14",
3
+ "version": "1.8.16",
4
4
  "description": "A fast, type-oriented database — strong consistency and rich indexing at the core, with sync, vector embeddings, full-text search, and AI tooling built in. Designed for the AI era.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "engines": {
@@ -1 +1 @@
1
- <script type="module">import e from"../components/env-picker.ok.js";import n from"../sections/db/modals/create-env-modal.ok.js";import o from"../sections/db/modals/demo-env-modal.ok.js";import s from"../stores/events-store.js";export default{tag:"default-layout",unwrap:!0,dependencies:[e,n,o],context:e=>({noticesExpanded:!1,dismissNotices(n){n&&(n.stopPropagation(),n.preventDefault()),e.$ok.stores.auth.dismissNotices()},hasEnvOption:n=>!!n&&(e.$ok.stores.okdb.envs||[]).some(e=>e?.name===n),syncEnvSearch(n){const o=e.$vars.$router;if(!o)return;const s=n??null;if((o._search?.env??null)===s)return;const t={...o._search||{}};s?t.env=s:delete t.env,o.replace(o.resolve(o._path||"",t,o._anchor||""))},applyEnvFromSearch(){const n=e.$vars.$router,o=n?._search?.env??null;return!(!o||!this.hasEnvOption(o)||(e.$ok.stores.okdb.selectedEnv!==o&&e.$ok.stores.okdb.selectEnv(o),0))},goToSection(n){const o=e.$vars.$router;if(!o)return;const s={},t=e.$ok.stores.okdb.selectedEnv;t&&(s.env=t);const r="system"===n?"/system/overview":`/${n}`;o.push(o.resolve(r,s))},async switchEnv(n){e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.info?.(`Environment: ${n}`)},async openCreateEnvModal(){await e.$ok.shared.modal.open({owner:e,title:"Create Environment",content:'<create-env-modal :modal="modal" :on-done="onDone" />',context:n=>({modal:n,onDone:async n=>{await e.$ok.stores.okdb.reloadEnvs(),e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.success?.(`Environment created: ${n}`)}})})},async openDemoModal(){await e.$ok.shared.modal.open({owner:e,title:'<span style="background:linear-gradient(135deg,#4FC1FF,#c084fc 50%,#4ade80);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-weight:700;font-size:15px;">✦ Demo Environment</span>',canDrag:!0,content:'<demo-env-modal :modal="modal" :on-done="onDone" />',context:n=>({modal:n,onDone:async n=>{await e.$ok.stores.okdb.reloadEnvs(),e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.success?.(`Demo environment "${n}" ready`)}})})}}),init(e){const n=()=>e.$ok.shared?.toast;e.$cache._unsubs=[s.on("type:registered",e=>n()?.info?.(`Type registered: ${e?.type??""}`)),s.on("type:drop",e=>n()?.info?.(`Type dropped: ${e?.type??""}`)),s.on("index:registered",e=>n()?.info?.(`Index registered on ${e?.type??""}: ${e?.key??""}`)),s.on("index:drop",e=>n()?.info?.(`Index dropped on ${e?.type??""}: ${e?.key??""}`)),s.on("fts:registered",e=>n()?.info?.(`FTS index created on ${e?.type??""}: ${e?.key??""}`)),s.on("fts:drop",e=>n()?.info?.(`FTS index dropped on ${e?.type??""}: ${e?.key??""}`)),s.on("env:opened",e=>n()?.info?.(`Environment ready: ${e?.name??""}`)),s.on("env:removed",e=>n()?.info?.(`Environment removed: ${e?.name??""}`)),s.on("engine:started",e=>n()?.success?.(`Engine started: ${e?.name??""}`)),s.on("engine:stopped",e=>n()?.info?.(`Engine stopped: ${e?.name??""}`)),s.on("engine:error",e=>n()?.error?.(`Engine error: ${e?.name??""}: ${e?.error??""}`)),s.on("engine:installed",e=>n()?.success?.(`Engine installed: ${e?.name??""}`)),s.on("engine:uninstalled",e=>n()?.info?.(`Engine uninstalled: ${e?.name??""}`)),s.on("embeddings:pipeline:ready",e=>n()?.success?.(`Pipeline ready: ${e?.name??""}`)),s.on("embeddings:indexer:flushed",e=>n()?.success?.(`Embeddings flushed: ${e?.name??""}`))],e.$ext("unmount",()=>{for(const n of e.$cache._unsubs||[])n();e.$cache._unsubs=[]}),this.applyEnvFromSearch()||this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null),e.$watch(()=>e.$vars.$router?._search?.env,()=>{const n=e.$vars.$router?._search?.env??null;n&&this.hasEnvOption(n)?e.$ok.stores.okdb.selectedEnv!==n&&e.$ok.stores.okdb.selectEnv(n):this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null)}),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null))}};</script><template><div class="layout"><aside class="layout__sidebar"><div class="layout__sidebar-cube"><component import="./okdb-cube.ok.html"></component><span class="layout__version">v{{ $ok.stores.okdb.info?.version ?? '…' }}</span></div><div class="layout__env-picker"><env-picker :selected="$ok.stores.okdb.selectedEnv" :envs="$ok.stores.okdb.envs || []" @change="switchEnv($event.detail)" @create="openCreateEnvModal()"/></div><div class="layout__sidebar-body"><if :="$ok.stores.okdb.selectedEnv"><slot name="sidebar"></slot></if></div><div class="layout__sidebar-footer"><div class="layout__auth-chip layout__auth-chip--sidebar" :title="$ok.stores.auth?.modeLabel || 'Authentication'"><div class="layout__auth-meta"><div class="layout__auth-userblock"><div class="layout__auth-user">{{ $ok.stores.auth?.username || 'anonymous' }}</div><if :="$ok.stores.auth?.rolesLabel"><div class="layout__auth-roles">{{ $ok.stores.auth.rolesLabel }}</div></if></div></div><if :="!$ok.stores.auth?.openAccess"><button class="layout__auth-logout" @click="$ok.stores.auth.logout()">Logout</button></if></div></div></aside><div class="layout__right-col"><nav class="layout__section-nav"><button :class="{ active: $route.params.section === 'db' }" :disabled="!$ok.stores.auth.canSection('db')" :title="$ok.stores.auth.canSection('db') ? '' : 'Requires data:read or env:read permission'" @click="$ok.stores.auth.canSection('db') && goToSection('db')"><span class="layout__nav-icon">⬡</span> DB<if :="!$ok.stores.auth.canSection('db')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'engines' || $route.params.section === 'embeddings' }" :disabled="!$ok.stores.auth.canSection('engines')" :title="$ok.stores.auth.canSection('engines') ? '' : 'Requires env:read permission'" @click="$ok.stores.auth.canSection('engines') && goToSection('engines')"><span class="layout__nav-icon">⛓</span> Engines<if :="!$ok.stores.auth.canSection('engines')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'files' }" :disabled="!$ok.stores.auth.canSection('files')" :title="$ok.stores.auth.canSection('files') ? '' : 'Requires files:read permission'" @click="$ok.stores.auth.canSection('files') && goToSection('files')"><span class="layout__nav-icon">⬚</span> Files<if :="!$ok.stores.auth.canSection('files')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'queue' }" :disabled="!$ok.stores.auth.canSection('queue')" :title="$ok.stores.auth.canSection('queue') ? '' : 'Requires queue:read permission'" @click="$ok.stores.auth.canSection('queue') && goToSection('queue')"><span class="layout__nav-icon">⇶</span> Queue<if :="!$ok.stores.auth.canSection('queue')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'functions' }" :disabled="!$ok.stores.auth.canSection('functions')" :title="$ok.stores.auth.canSection('functions') ? '' : 'Requires functions:read permission'" @click="$ok.stores.auth.canSection('functions') && goToSection('functions')"><span class="layout__nav-icon">ƒ</span> Functions<if :="!$ok.stores.auth.canSection('functions')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'views' }" @click="goToSection('views')"><span class="layout__nav-icon">⊞</span> Views</button> <button :class="{ active: $route.params.section === 'system' }" :disabled="!$ok.stores.auth.canSection('system')" :title="$ok.stores.auth.canSection('system') ? '' : 'Requires system:read permission'" @click="$ok.stores.auth.canSection('system') && goToSection('system')"><span class="layout__nav-icon">⚙</span> System<if :="!$ok.stores.auth.canSection('system')"><span class="layout__nav-lock">🔒</span></if></button><div class="layout__live-indicator" :title="$ok.stores.events?.connected ? 'Event stream live' : 'Event stream disconnected'"><span :class="$ok.stores.events?.connected ? 'layout__live-dot layout__live-dot--on' : 'layout__live-dot layout__live-dot--off'"></span> <span class="layout__live-label">{{ $ok.stores.events?.connected ? 'Live' : 'Off' }}</span></div><div class="layout__security-pill" :title="($ok.stores.auth?.modeLabel || 'Authentication') + ' · ' + ($ok.stores.auth?.username || 'anonymous')"><span class="layout__security-dot" :class="$ok.stores.auth?.openAccess ? 'layout__security-dot layout__security-dot--open' : 'layout__security-dot layout__security-dot--secure'"></span> <span class="layout__security-label">{{ $ok.stores.auth?.openAccess ? 'Open access' : 'Secure' }}</span></div></nav><div class="layout__main"><if :="$ok.stores.auth?.visibleNotices?.length > 0"><div class="layout__notices"><div class="layout__notices-bar" @click="noticesExpanded = !noticesExpanded"><span class="layout__notices-icon">⚠</span> <span class="layout__notices-summary">{{ $ok.stores.auth.visibleNotices.length }} security notice{{ $ok.stores.auth.visibleNotices.length > 1 ? 's' : '' }}<if :="$ok.stores.auth.visibleNotices[0]">· {{ $ok.stores.auth.visibleNotices[0].title }}<if :="$ok.stores.auth.visibleNotices.length > 1">and {{ $ok.stores.auth.visibleNotices.length - 1 }} more</if></if></span><span class="layout__notices-chevron">{{ noticesExpanded ? '▴' : '▾' }}</span> <button class="layout__notices-dismiss" @click="dismissNotices($event)" title="Dismiss for this session">✕</button></div><if :="noticesExpanded"><div class="layout__notices-detail"><each :of="$ok.stores.auth.visibleNotices" let:item="n"><div class="layout__notice-item"><div class="layout__notice-title">{{ n.title }}</div><div class="layout__notice-msg">{{ n.message }}</div><if :="n.hint"><div class="layout__notice-hint">{{ n.hint }}</div></if><if :="n.action"><a class="layout__notice-action" :href="n.action.href">{{ n.action.label }} →</a></if></div></each></div></if></div></if><header class="layout__header"><slot name="header"></slot></header><main class="layout__content"><if :="$ok.stores.okdb.selectedEnv"><slot></slot></if><if :="!$ok.stores.okdb.selectedEnv"><div class="layout__no-env"><div class="layout__no-env-icon">⬡</div><div class="layout__no-env-title">No environment selected</div><div class="layout__no-env-sub">Create a new environment or load the demo to get started.</div><div class="layout__no-env-actions"><button @click="openCreateEnvModal()" class="layout__no-env-btn">+ Create environment</button> <button @click="openDemoModal()" class="layout__no-env-btn layout__no-env-btn--demo">✦ Load demo</button></div></div></if></main></div></div></div></template><style>.layout{display:flex;flex-direction:row;height:100%;width:100%;min-height:0}.layout__sidebar{display:flex;flex-direction:column;width:210px;flex-shrink:0;border-right:1px solid var(--okdb-app-surface-2);background:var(--okdb-app-bg-deep);overflow:visible}.layout__sidebar-cube{height:150px;flex-shrink:0;display:flex;align-items:center;justify-content:center;position:relative}.layout__version{position:absolute;bottom:8px;right:10px;font-size:10px;font-family:monospace;color:var(--okdb-app-fg-faint);letter-spacing:.04em;pointer-events:none;user-select:none;transition:color .2s}.layout__sidebar-cube:hover .layout__version{color:var(--okdb-app-fg-subtle)}.layout__env-picker{flex-shrink:0;padding:8px 10px 6px;border-top:1px solid var(--okdb-app-surface-2);border-bottom:1px solid var(--okdb-app-surface-2)}.layout__sidebar-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:3px;padding:10px;border-top:1px solid var(--okdb-app-surface-2);overflow-y:auto;overflow-x:visible}.layout__sidebar-footer{flex-shrink:0;padding:8px 10px 10px;border-top:1px solid var(--okdb-app-surface-2);background:var(--okdb-app-bg-deep)}.layout__right-col{flex:1;min-width:0;display:flex;flex-direction:column;min-height:0}.layout__section-nav{display:flex;flex-direction:row;align-items:flex-end;height:150px;padding:0 8px;flex-shrink:0;background:var(--okdb-app-bg-deep);border-bottom:1px solid var(--okdb-app-surface-2)}.layout__section-nav button{display:flex;align-items:center;gap:7px;padding:0 16px;height:44px;border-radius:0;font-size:13px;font-weight:500;background:0 0;border:none;border-bottom:2px solid transparent;color:var(--okdb-app-fg-subtle);cursor:pointer;white-space:nowrap;letter-spacing:.01em;transition:color .15s,border-color .15s,background .15s;margin-bottom:-1px}.layout__section-nav button:hover{background:rgba(255,255,255,.03);border-color:transparent;color:var(--okdb-app-fg-muted);border-bottom-color:var(--okdb-app-border-subtle);transform:none;box-shadow:none}.layout__section-nav button:disabled{opacity:.35;cursor:not-allowed;background:0 0!important;border-color:transparent!important;border-bottom-color:transparent!important;color:var(--okdb-app-fg-faint)!important}.layout__section-nav button:disabled:hover{background:0 0;transform:none;box-shadow:none}.layout__section-nav button.active{background:0 0;border-color:transparent;color:var(--okdb-accent);border-bottom-color:var(--okdb-accent)}.layout__nav-icon{font-size:14px;opacity:.7;flex-shrink:0}.layout__nav-lock{font-size:10px;margin-left:4px;opacity:.7;flex-shrink:0;vertical-align:middle}.layout__section-nav button:hover .layout__nav-icon{opacity:.9}.layout__section-nav button.active .layout__nav-icon{opacity:1}.layout__live-indicator{margin-left:auto;margin-right:8px;margin-bottom:11px;display:flex;align-items:center;gap:5px;cursor:default}.layout__live-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.layout__live-dot--on{background:var(--okdb-success);box-shadow:0 0 5px color-mix(in srgb,var(--okdb-success) 40%,transparent);animation:nav-pulse 2.5s ease-in-out infinite}.layout__live-dot--off{background:var(--okdb-danger);opacity:.6}.layout__live-label{font-size:10px;color:var(--okdb-app-fg-faint);letter-spacing:.04em;text-transform:uppercase}@keyframes nav-pulse{0%,100%{opacity:1}50%{opacity:.35}}.layout__security-pill{margin-bottom:9px;display:inline-flex;align-items:center;gap:7px;padding:6px 10px;border-radius:999px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-bg);color:var(--okdb-app-fg-muted)}.layout__security-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.layout__security-dot--secure{background:var(--okdb-accent);box-shadow:0 0 6px var(--okdb-accent-soft-border)}.layout__security-dot--open{background:var(--okdb-warning);box-shadow:0 0 6px color-mix(in srgb,var(--okdb-warning) 33%,transparent)}.layout__security-label{font-size:10px;line-height:1;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.layout__auth-chip{margin-bottom:9px;display:inline-flex;align-items:center;gap:10px;padding:6px 10px;border-radius:10px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-bg)}.layout__auth-chip--sidebar{width:100%;margin-bottom:0;align-items:center;justify-content:space-between;gap:12px;padding:0;border-radius:0;background:0 0;border:none;box-shadow:none}.layout__auth-meta{display:flex;align-items:center;gap:0;min-width:0;flex:1}.layout__auth-userblock{display:flex;flex-direction:column;min-width:0}.layout__auth-badge{font-size:10px;line-height:1;text-transform:uppercase;letter-spacing:.05em;padding:4px 7px;border-radius:999px;border:1px solid transparent;white-space:nowrap}.layout__auth-badge--open{color:var(--okdb-success);background:#4ade8011;border-color:#4ade8033}.layout__auth-badge--users{color:var(--okdb-accent);background:#4fc1FF11;border-color:#4fc1FF33}.layout__auth-user{font-size:11px;font-weight:600;color:var(--okdb-app-fg);line-height:1.1;text-transform:uppercase;letter-spacing:.05em}.layout__auth-roles{font-size:10px;color:var(--okdb-app-fg-muted);line-height:1.1;margin-top:2px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.layout__auth-logout{font-size:11px;padding:0;border-radius:0;border:none;background:0 0;color:var(--okdb-danger);cursor:pointer;white-space:nowrap;align-self:center;letter-spacing:.04em;text-transform:uppercase;opacity:.88}.layout__auth-logout:hover{background:0 0;color:var(--okdb-danger-hover);opacity:1;transform:none;box-shadow:none}.layout__main{flex:1;display:flex;flex-direction:column;min-height:0;overflow:auto;padding:20px 24px 6px}.layout__header{flex-shrink:0}.layout__content{flex:1;min-height:0;min-width:0}.layout__content>*{animation:layout-fade-in .2s ease backwards}.layout__sidebar-body>*{animation:layout-fade-in .18s ease backwards}@keyframes layout-fade-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}.layout__no-env{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;animation:layout-fade-in .25s ease backwards}.layout__no-env-icon{font-size:48px;opacity:.12;line-height:1;margin-bottom:4px}.layout__no-env-title{font-size:18px;font-weight:600;color:var(--okdb-app-fg-muted)}.layout__no-env-sub{font-size:13px;color:var(--okdb-app-fg-subtle);text-align:center;max-width:320px;line-height:1.6}.layout__no-env-actions{display:flex;gap:10px;margin-top:8px}.layout__no-env-btn{padding:8px 18px;font-size:13px;border-radius:7px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-surface);color:var(--okdb-app-fg-subtle);cursor:pointer;transition:background .12s,border-color .12s,color .12s}.layout__no-env-btn:hover{background:var(--okdb-app-surface-2);border-color:var(--okdb-app-border-strong);color:var(--okdb-app-fg-muted);transform:none;box-shadow:none}.layout__no-env-btn--demo{border-color:var(--okdb-accent-2-soft-border);background:var(--okdb-accent-2-soft-bg);color:var(--okdb-accent-2)}.layout__no-env-btn--demo:hover{background:var(--okdb-accent-2-soft-bg);border-color:var(--okdb-accent-2-soft-border);color:var(--okdb-accent-2);transform:none;box-shadow:none}.layout__notices{flex-shrink:0;margin:-20px -24px 16px;border-bottom:1px solid color-mix(in srgb,var(--okdb-warning) 22%,transparent);background:color-mix(in srgb,var(--okdb-warning) 5%,transparent)}.layout__notices-bar{display:flex;align-items:center;gap:10px;padding:9px 16px;cursor:pointer;user-select:none;transition:background .12s}.layout__notices-bar:hover{background:color-mix(in srgb,var(--okdb-warning) 9%,transparent)}.layout__notices-icon{font-size:15px;color:var(--okdb-warning);flex-shrink:0}.layout__notices-summary{font-size:12px;color:var(--okdb-warning-text);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.layout__notices-chevron{font-size:14px;color:var(--okdb-warning-text);flex-shrink:0;line-height:1}.layout__notices-dismiss{font-size:14px;color:var(--okdb-warning-text);background:0 0;border:none;cursor:pointer;padding:3px 7px;border-radius:4px;flex-shrink:0;line-height:1;transition:color .1s,background .1s}.layout__notices-dismiss:hover{color:var(--okdb-warning);background:color-mix(in srgb,var(--okdb-warning) 15%,transparent)}.layout__notices-detail{padding:0 16px 14px;display:flex;flex-direction:column;gap:10px}.layout__notice-item{padding:12px 14px;border:1px solid color-mix(in srgb,var(--okdb-warning) 20%,transparent);border-radius:6px;background:color-mix(in srgb,var(--okdb-warning) 5%,transparent);display:flex;flex-direction:column;gap:6px}.layout__notice-title{font-size:13px;font-weight:600;color:var(--okdb-warning)}.layout__notice-msg{font-size:12px;color:var(--okdb-warning-text);line-height:1.55}.layout__notice-hint{font-size:12px;color:var(--okdb-warning-text);line-height:1.55;font-style:italic}.layout__notice-action{font-size:12px;color:var(--okdb-accent);text-decoration:none;margin-top:2px;display:inline-block}.layout__notice-action:hover{text-decoration:underline}</style>
1
+ <script type="module">import e from"../components/env-picker.ok.js";import n from"../sections/db/modals/create-env-modal.ok.js";import o from"../sections/db/modals/demo-env-modal.ok.js";import s from"../stores/events-store.js";export default{tag:"default-layout",unwrap:!0,dependencies:[e,n,o],context:e=>({noticesExpanded:!1,dismissNotices(n){n&&(n.stopPropagation(),n.preventDefault()),e.$ok.stores.auth.dismissNotices()},hasEnvOption:n=>!!n&&(e.$ok.stores.okdb.envs||[]).some(e=>e?.name===n),syncEnvSearch(n){const o=e.$vars.$router;if(!o)return;const s=n??null;if((o._search?.env??null)===s)return;const t={...o._search||{}};s?t.env=s:delete t.env,o.replace(o.resolve(o._path||"",t,o._anchor||""))},applyEnvFromSearch(){const n=e.$vars.$router,o=n?._search?.env??null;return!(!o||!this.hasEnvOption(o)||(e.$ok.stores.okdb.selectedEnv!==o&&e.$ok.stores.okdb.selectEnv(o),0))},goToSection(n){const o=e.$vars.$router;if(!o)return;const s={},t=e.$ok.stores.okdb.selectedEnv;t&&(s.env=t);const r="system"===n?"/system/overview":`/${n}`;o.push(o.resolve(r,s))},async switchEnv(n){e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.info?.(`Environment: ${n}`)},async openCreateEnvModal(){await e.$ok.shared.modal.open({owner:e,title:"Create Environment",content:'<create-env-modal :modal="modal" :on-done="onDone" />',context:n=>({modal:n,onDone:async n=>{await e.$ok.stores.okdb.reloadEnvs(),e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.success?.(`Environment created: ${n}`)}})})},async openDemoModal(){await e.$ok.shared.modal.open({owner:e,title:'<span style="background:linear-gradient(135deg,#4FC1FF,#c084fc 50%,#4ade80);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-weight:700;font-size:15px;">✦ Demo Environment</span>',canDrag:!0,content:'<demo-env-modal :modal="modal" :on-done="onDone" />',context:n=>({modal:n,onDone:async n=>{await e.$ok.stores.okdb.reloadEnvs(),e.$ok.stores.okdb.selectEnv(n),e.$ok.shared?.toast?.success?.(`Demo environment "${n}" ready`)}})})}}),init(e){const n=()=>e.$ok.shared?.toast;e.$cache._unsubs=[s.on("type:registered",e=>n()?.info?.(`Type registered: ${e?.type??""}`)),s.on("type:drop",e=>n()?.info?.(`Type dropped: ${e?.type??""}`)),s.on("index:registered",e=>n()?.info?.(`Index registered on ${e?.type??""}: ${e?.key??""}`)),s.on("index:drop",e=>n()?.info?.(`Index dropped on ${e?.type??""}: ${e?.key??""}`)),s.on("fts:registered",e=>n()?.info?.(`FTS index created on ${e?.type??""}: ${e?.key??""}`)),s.on("fts:drop",e=>n()?.info?.(`FTS index dropped on ${e?.type??""}: ${e?.key??""}`)),s.on("env:opened",async o=>{n()?.info?.(`Environment ready: ${o?.name??""}`),await e.$ok.stores.okdb.reloadEnvs()}),s.on("env:removed",async o=>{n()?.info?.(`Environment removed: ${o?.name??""}`),await e.$ok.stores.okdb.reloadEnvs();const s=e.$ok.stores.okdb._pickBestEnv();s!==e.$ok.stores.okdb.selectedEnv&&e.$ok.stores.okdb.selectEnv(s)}),s.on("engine:started",e=>n()?.success?.(`Engine started: ${e?.name??""}`)),s.on("engine:stopped",e=>n()?.info?.(`Engine stopped: ${e?.name??""}`)),s.on("engine:error",e=>n()?.error?.(`Engine error: ${e?.name??""}: ${e?.error??""}`)),s.on("engine:installed",e=>n()?.success?.(`Engine installed: ${e?.name??""}`)),s.on("engine:uninstalled",e=>n()?.info?.(`Engine uninstalled: ${e?.name??""}`)),s.on("embeddings:pipeline:ready",e=>n()?.success?.(`Pipeline ready: ${e?.name??""}`)),s.on("embeddings:indexer:flushed",e=>n()?.success?.(`Embeddings flushed: ${e?.name??""}`))],e.$ext("unmount",()=>{for(const n of e.$cache._unsubs||[])n();e.$cache._unsubs=[]}),this.applyEnvFromSearch()||this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null),e.$watch(()=>e.$vars.$router?._search?.env,()=>{const n=e.$vars.$router?._search?.env??null;n&&this.hasEnvOption(n)?e.$ok.stores.okdb.selectedEnv!==n&&e.$ok.stores.okdb.selectEnv(n):this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null)}),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>this.syncEnvSearch(e.$ok.stores.okdb.selectedEnv??null))}};</script><template><div class="layout"><aside class="layout__sidebar"><div class="layout__sidebar-cube"><component import="./okdb-cube.ok.html"></component><span class="layout__version">v{{ $ok.stores.okdb.info?.version ?? '…' }}</span></div><div class="layout__env-picker"><env-picker :selected="$ok.stores.okdb.selectedEnv" :envs="$ok.stores.okdb.envs || []" @change="switchEnv($event.detail)" @create="openCreateEnvModal()"/></div><div class="layout__sidebar-body"><if :="$ok.stores.okdb.selectedEnv"><slot name="sidebar"></slot></if></div><div class="layout__sidebar-footer"><div class="layout__auth-chip layout__auth-chip--sidebar" :title="$ok.stores.auth?.modeLabel || 'Authentication'"><div class="layout__auth-meta"><div class="layout__auth-userblock"><div class="layout__auth-user">{{ $ok.stores.auth?.username || 'anonymous' }}</div><if :="$ok.stores.auth?.rolesLabel"><div class="layout__auth-roles">{{ $ok.stores.auth.rolesLabel }}</div></if></div></div><if :="!$ok.stores.auth?.openAccess"><button class="layout__auth-logout" @click="$ok.stores.auth.logout()">Logout</button></if></div></div></aside><div class="layout__right-col"><nav class="layout__section-nav"><button :class="{ active: $route.params.section === 'db' }" :disabled="!$ok.stores.auth.canSection('db')" :title="$ok.stores.auth.canSection('db') ? '' : 'Requires data:read or env:read permission'" @click="$ok.stores.auth.canSection('db') && goToSection('db')"><span class="layout__nav-icon">⬡</span> DB<if :="!$ok.stores.auth.canSection('db')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'engines' || $route.params.section === 'embeddings' }" :disabled="!$ok.stores.auth.canSection('engines')" :title="$ok.stores.auth.canSection('engines') ? '' : 'Requires env:read permission'" @click="$ok.stores.auth.canSection('engines') && goToSection('engines')"><span class="layout__nav-icon">⛓</span> Engines<if :="!$ok.stores.auth.canSection('engines')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'files' }" :disabled="!$ok.stores.auth.canSection('files')" :title="$ok.stores.auth.canSection('files') ? '' : 'Requires files:read permission'" @click="$ok.stores.auth.canSection('files') && goToSection('files')"><span class="layout__nav-icon">⬚</span> Files<if :="!$ok.stores.auth.canSection('files')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'queue' }" :disabled="!$ok.stores.auth.canSection('queue')" :title="$ok.stores.auth.canSection('queue') ? '' : 'Requires queue:read permission'" @click="$ok.stores.auth.canSection('queue') && goToSection('queue')"><span class="layout__nav-icon">⇶</span> Queue<if :="!$ok.stores.auth.canSection('queue')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'functions' }" :disabled="!$ok.stores.auth.canSection('functions')" :title="$ok.stores.auth.canSection('functions') ? '' : 'Requires functions:read permission'" @click="$ok.stores.auth.canSection('functions') && goToSection('functions')"><span class="layout__nav-icon">ƒ</span> Functions<if :="!$ok.stores.auth.canSection('functions')"><span class="layout__nav-lock">🔒</span></if></button> <button :class="{ active: $route.params.section === 'views' }" @click="goToSection('views')"><span class="layout__nav-icon">⊞</span> Views</button> <button :class="{ active: $route.params.section === 'system' }" :disabled="!$ok.stores.auth.canSection('system')" :title="$ok.stores.auth.canSection('system') ? '' : 'Requires system:read permission'" @click="$ok.stores.auth.canSection('system') && goToSection('system')"><span class="layout__nav-icon">⚙</span> System<if :="!$ok.stores.auth.canSection('system')"><span class="layout__nav-lock">🔒</span></if></button><div class="layout__live-indicator" :title="$ok.stores.events?.connected ? 'Event stream live' : 'Event stream disconnected'"><span :class="$ok.stores.events?.connected ? 'layout__live-dot layout__live-dot--on' : 'layout__live-dot layout__live-dot--off'"></span> <span class="layout__live-label">{{ $ok.stores.events?.connected ? 'Live' : 'Off' }}</span></div><div class="layout__security-pill" :title="($ok.stores.auth?.modeLabel || 'Authentication') + ' · ' + ($ok.stores.auth?.username || 'anonymous')"><span class="layout__security-dot" :class="$ok.stores.auth?.openAccess ? 'layout__security-dot layout__security-dot--open' : 'layout__security-dot layout__security-dot--secure'"></span> <span class="layout__security-label">{{ $ok.stores.auth?.openAccess ? 'Open access' : 'Secure' }}</span></div></nav><div class="layout__main"><if :="$ok.stores.auth?.visibleNotices?.length > 0"><div class="layout__notices"><div class="layout__notices-bar" @click="noticesExpanded = !noticesExpanded"><span class="layout__notices-icon">⚠</span> <span class="layout__notices-summary">{{ $ok.stores.auth.visibleNotices.length }} security notice{{ $ok.stores.auth.visibleNotices.length > 1 ? 's' : '' }}<if :="$ok.stores.auth.visibleNotices[0]">· {{ $ok.stores.auth.visibleNotices[0].title }}<if :="$ok.stores.auth.visibleNotices.length > 1">and {{ $ok.stores.auth.visibleNotices.length - 1 }} more</if></if></span><span class="layout__notices-chevron">{{ noticesExpanded ? '▴' : '▾' }}</span> <button class="layout__notices-dismiss" @click="dismissNotices($event)" title="Dismiss for this session">✕</button></div><if :="noticesExpanded"><div class="layout__notices-detail"><each :of="$ok.stores.auth.visibleNotices" let:item="n"><div class="layout__notice-item"><div class="layout__notice-title">{{ n.title }}</div><div class="layout__notice-msg">{{ n.message }}</div><if :="n.hint"><div class="layout__notice-hint">{{ n.hint }}</div></if><if :="n.action"><a class="layout__notice-action" :href="n.action.href">{{ n.action.label }} →</a></if></div></each></div></if></div></if><header class="layout__header"><slot name="header"></slot></header><main class="layout__content"><if :="$ok.stores.okdb.selectedEnv"><slot></slot></if><if :="!$ok.stores.okdb.selectedEnv"><div class="layout__no-env"><div class="layout__no-env-icon">⬡</div><div class="layout__no-env-title">No environment selected</div><div class="layout__no-env-sub">Create a new environment or load the demo to get started.</div><div class="layout__no-env-actions"><button @click="openCreateEnvModal()" class="layout__no-env-btn">+ Create environment</button> <button @click="openDemoModal()" class="layout__no-env-btn layout__no-env-btn--demo">✦ Load demo</button></div></div></if></main></div></div></div></template><style>.layout{display:flex;flex-direction:row;height:100%;width:100%;min-height:0}.layout__sidebar{display:flex;flex-direction:column;width:210px;flex-shrink:0;border-right:1px solid var(--okdb-app-surface-2);background:var(--okdb-app-bg-deep);overflow:visible}.layout__sidebar-cube{height:150px;flex-shrink:0;display:flex;align-items:center;justify-content:center;position:relative}.layout__version{position:absolute;bottom:8px;right:10px;font-size:10px;font-family:monospace;color:var(--okdb-app-fg-faint);letter-spacing:.04em;pointer-events:none;user-select:none;transition:color .2s}.layout__sidebar-cube:hover .layout__version{color:var(--okdb-app-fg-subtle)}.layout__env-picker{flex-shrink:0;padding:8px 10px 6px;border-top:1px solid var(--okdb-app-surface-2);border-bottom:1px solid var(--okdb-app-surface-2)}.layout__sidebar-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:3px;padding:10px;border-top:1px solid var(--okdb-app-surface-2);overflow-y:auto;overflow-x:visible}.layout__sidebar-footer{flex-shrink:0;padding:8px 10px 10px;border-top:1px solid var(--okdb-app-surface-2);background:var(--okdb-app-bg-deep)}.layout__right-col{flex:1;min-width:0;display:flex;flex-direction:column;min-height:0}.layout__section-nav{display:flex;flex-direction:row;align-items:flex-end;height:150px;padding:0 8px;flex-shrink:0;background:var(--okdb-app-bg-deep);border-bottom:1px solid var(--okdb-app-surface-2)}.layout__section-nav button{display:flex;align-items:center;gap:7px;padding:0 16px;height:44px;border-radius:0;font-size:13px;font-weight:500;background:0 0;border:none;border-bottom:2px solid transparent;color:var(--okdb-app-fg-subtle);cursor:pointer;white-space:nowrap;letter-spacing:.01em;transition:color .15s,border-color .15s,background .15s;margin-bottom:-1px}.layout__section-nav button:hover{background:rgba(255,255,255,.03);border-color:transparent;color:var(--okdb-app-fg-muted);border-bottom-color:var(--okdb-app-border-subtle);transform:none;box-shadow:none}.layout__section-nav button:disabled{opacity:.35;cursor:not-allowed;background:0 0!important;border-color:transparent!important;border-bottom-color:transparent!important;color:var(--okdb-app-fg-faint)!important}.layout__section-nav button:disabled:hover{background:0 0;transform:none;box-shadow:none}.layout__section-nav button.active{background:0 0;border-color:transparent;color:var(--okdb-accent);border-bottom-color:var(--okdb-accent)}.layout__nav-icon{font-size:14px;opacity:.7;flex-shrink:0}.layout__nav-lock{font-size:10px;margin-left:4px;opacity:.7;flex-shrink:0;vertical-align:middle}.layout__section-nav button:hover .layout__nav-icon{opacity:.9}.layout__section-nav button.active .layout__nav-icon{opacity:1}.layout__live-indicator{margin-left:auto;margin-right:8px;margin-bottom:11px;display:flex;align-items:center;gap:5px;cursor:default}.layout__live-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.layout__live-dot--on{background:var(--okdb-success);box-shadow:0 0 5px color-mix(in srgb,var(--okdb-success) 40%,transparent);animation:nav-pulse 2.5s ease-in-out infinite}.layout__live-dot--off{background:var(--okdb-danger);opacity:.6}.layout__live-label{font-size:10px;color:var(--okdb-app-fg-faint);letter-spacing:.04em;text-transform:uppercase}@keyframes nav-pulse{0%,100%{opacity:1}50%{opacity:.35}}.layout__security-pill{margin-bottom:9px;display:inline-flex;align-items:center;gap:7px;padding:6px 10px;border-radius:999px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-bg);color:var(--okdb-app-fg-muted)}.layout__security-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.layout__security-dot--secure{background:var(--okdb-accent);box-shadow:0 0 6px var(--okdb-accent-soft-border)}.layout__security-dot--open{background:var(--okdb-warning);box-shadow:0 0 6px color-mix(in srgb,var(--okdb-warning) 33%,transparent)}.layout__security-label{font-size:10px;line-height:1;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.layout__auth-chip{margin-bottom:9px;display:inline-flex;align-items:center;gap:10px;padding:6px 10px;border-radius:10px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-bg)}.layout__auth-chip--sidebar{width:100%;margin-bottom:0;align-items:center;justify-content:space-between;gap:12px;padding:0;border-radius:0;background:0 0;border:none;box-shadow:none}.layout__auth-meta{display:flex;align-items:center;gap:0;min-width:0;flex:1}.layout__auth-userblock{display:flex;flex-direction:column;min-width:0}.layout__auth-badge{font-size:10px;line-height:1;text-transform:uppercase;letter-spacing:.05em;padding:4px 7px;border-radius:999px;border:1px solid transparent;white-space:nowrap}.layout__auth-badge--open{color:var(--okdb-success);background:#4ade8011;border-color:#4ade8033}.layout__auth-badge--users{color:var(--okdb-accent);background:#4fc1FF11;border-color:#4fc1FF33}.layout__auth-user{font-size:11px;font-weight:600;color:var(--okdb-app-fg);line-height:1.1;text-transform:uppercase;letter-spacing:.05em}.layout__auth-roles{font-size:10px;color:var(--okdb-app-fg-muted);line-height:1.1;margin-top:2px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.layout__auth-logout{font-size:11px;padding:0;border-radius:0;border:none;background:0 0;color:var(--okdb-danger);cursor:pointer;white-space:nowrap;align-self:center;letter-spacing:.04em;text-transform:uppercase;opacity:.88}.layout__auth-logout:hover{background:0 0;color:var(--okdb-danger-hover);opacity:1;transform:none;box-shadow:none}.layout__main{flex:1;display:flex;flex-direction:column;min-height:0;overflow:auto;padding:20px 24px 6px}.layout__header{flex-shrink:0}.layout__content{flex:1;min-height:0;min-width:0}.layout__content>*{animation:layout-fade-in .2s ease backwards}.layout__sidebar-body>*{animation:layout-fade-in .18s ease backwards}@keyframes layout-fade-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}.layout__no-env{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;animation:layout-fade-in .25s ease backwards}.layout__no-env-icon{font-size:48px;opacity:.12;line-height:1;margin-bottom:4px}.layout__no-env-title{font-size:18px;font-weight:600;color:var(--okdb-app-fg-muted)}.layout__no-env-sub{font-size:13px;color:var(--okdb-app-fg-subtle);text-align:center;max-width:320px;line-height:1.6}.layout__no-env-actions{display:flex;gap:10px;margin-top:8px}.layout__no-env-btn{padding:8px 18px;font-size:13px;border-radius:7px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-surface);color:var(--okdb-app-fg-subtle);cursor:pointer;transition:background .12s,border-color .12s,color .12s}.layout__no-env-btn:hover{background:var(--okdb-app-surface-2);border-color:var(--okdb-app-border-strong);color:var(--okdb-app-fg-muted);transform:none;box-shadow:none}.layout__no-env-btn--demo{border-color:var(--okdb-accent-2-soft-border);background:var(--okdb-accent-2-soft-bg);color:var(--okdb-accent-2)}.layout__no-env-btn--demo:hover{background:var(--okdb-accent-2-soft-bg);border-color:var(--okdb-accent-2-soft-border);color:var(--okdb-accent-2);transform:none;box-shadow:none}.layout__notices{flex-shrink:0;margin:-20px -24px 16px;border-bottom:1px solid color-mix(in srgb,var(--okdb-warning) 22%,transparent);background:color-mix(in srgb,var(--okdb-warning) 5%,transparent)}.layout__notices-bar{display:flex;align-items:center;gap:10px;padding:9px 16px;cursor:pointer;user-select:none;transition:background .12s}.layout__notices-bar:hover{background:color-mix(in srgb,var(--okdb-warning) 9%,transparent)}.layout__notices-icon{font-size:15px;color:var(--okdb-warning);flex-shrink:0}.layout__notices-summary{font-size:12px;color:var(--okdb-warning-text);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.layout__notices-chevron{font-size:14px;color:var(--okdb-warning-text);flex-shrink:0;line-height:1}.layout__notices-dismiss{font-size:14px;color:var(--okdb-warning-text);background:0 0;border:none;cursor:pointer;padding:3px 7px;border-radius:4px;flex-shrink:0;line-height:1;transition:color .1s,background .1s}.layout__notices-dismiss:hover{color:var(--okdb-warning);background:color-mix(in srgb,var(--okdb-warning) 15%,transparent)}.layout__notices-detail{padding:0 16px 14px;display:flex;flex-direction:column;gap:10px}.layout__notice-item{padding:12px 14px;border:1px solid color-mix(in srgb,var(--okdb-warning) 20%,transparent);border-radius:6px;background:color-mix(in srgb,var(--okdb-warning) 5%,transparent);display:flex;flex-direction:column;gap:6px}.layout__notice-title{font-size:13px;font-weight:600;color:var(--okdb-warning)}.layout__notice-msg{font-size:12px;color:var(--okdb-warning-text);line-height:1.55}.layout__notice-hint{font-size:12px;color:var(--okdb-warning-text);line-height:1.55;font-style:italic}.layout__notice-action{font-size:12px;color:var(--okdb-accent);text-decoration:none;margin-top:2px;display:inline-block}.layout__notice-action:hover{text-decoration:underline}</style>
@@ -0,0 +1 @@
1
+ import o from"@toolkit/components/ok-monaco.ok.js";async function t(o,t){if(!t)return;const e=null==o.$attr.text?"":String(o.$attr.text),n=o.$attr.lang||"json";if(t.textContent=e,t.dataset.monacoSource=e,e&&!(e.length>2e5))try{await o.$ok.componentRegistry.activate(o.$ok.componentRegistry.getRegistration("ok-monaco"),o);const e=await o.$ok.shared.monaco.load();t.setAttribute("data-lang","js"===n?"javascript":n);const a=getComputedStyle(o.$el).getPropertyValue("--ok-monaco-theme").trim()||"vs-dark";await e.editor.colorizeElement(t,{theme:a});const c=t.querySelectorAll("br");c[c.length-1]?.remove()}catch{}}export default{tag:"code-panel",dependencies:[o],attr:{lang:{default:"json"},text:{default:""},title:{default:""}},context:()=>({_codeEl:null,_copied:!1,copy(){const o=this._codeEl?.dataset.monacoSource??this._codeEl?.textContent??"";try{navigator.clipboard?.writeText(o)}catch{}this._copied=!0,setTimeout(()=>this._copied=!1,1200)}}),mounted(o){t(o,o.$context._codeEl),o.$watch(()=>[o.$attr.text,o.$attr.lang],()=>t(o,o.$context._codeEl)),o.$listen(o.$el,"transitionstart",e=>{"--ok-monaco-theme"===e.propertyName&&t(o,o.$context._codeEl)})},template:'\n <div class="ok-code-block">\n <div class="ok-code-block__header">\n <span class="lang">{{ $attr.title || $attr.lang }}</span>\n <span class="copy" @click="copy()">{{ _copied ? \'Copied!\' : \'Copy\' }}</span>\n </div>\n <pre><code :ref="_codeEl = $el"></code></pre>\n </div>\n ',style:"\n @property --ok-monaco-theme {\n syntax: '<custom-ident>';\n inherits: true;\n initial-value: vs-dark;\n }\n [tag] { display: block; transition: --ok-monaco-theme 1ms allow-discrete; }\n [tag] .ok-code-block {\n border-radius: 8px;\n overflow: hidden;\n background: var(--okdb-app-bg-deep);\n border: 1px solid var(--okdb-app-border);\n }\n [tag] .ok-code-block__header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n background: var(--okdb-app-surface);\n padding: 5px 10px;\n font-size: 11px;\n color: var(--okdb-app-fg-muted);\n font-family: var(--font-mono, monospace);\n border-bottom: 1px solid var(--okdb-app-border);\n }\n [tag] .ok-code-block .lang { opacity: .85; letter-spacing: .03em; text-transform: uppercase; }\n [tag] .ok-code-block .copy {\n cursor: pointer;\n padding: 2px 6px;\n border-radius: 4px;\n transition: .15s;\n user-select: none;\n }\n [tag] .ok-code-block .copy:hover { color: var(--okdb-app-fg); background: var(--okdb-app-surface-2); }\n [tag] .ok-code-block pre {\n margin: 0;\n padding: 8px 10px;\n max-height: 320px;\n overflow: auto;\n font-family: var(--font-mono, monospace);\n font-size: 11px;\n white-space: pre;\n line-height: 1.5;\n }\n [tag] .ok-code-block code { white-space: pre; }\n "};
@@ -0,0 +1 @@
1
+ import e from"../../../stores/events-store.js";const t={info:"var(--okdb-accent)",warn:"var(--okdb-warning)",error:"var(--okdb-danger)",debug:"var(--okdb-app-fg-muted)"},l=Number.MAX_SAFE_INTEGER;export default{tag:"job-log-panel",attr:{jobId:{default:null},env:{default:null},job:{default:null},attempt:{default:null}},context(o){const n=()=>`${__ENV__.API}/env/${encodeURIComponent(o.$attr.env||"default")}/queue`;return{entries:[],cursor:null,loading:!1,error:null,level:null,source:"all",autoScroll:!0,_listRef:null,_unsubs:[],_scrollTimer:null,get syntheticEntries(){const e=o.$attr.job;if(!e)return[];const t=e.id,n=[];n.push({key:[t,0,0],value:{level:"info",message:`enqueued · priority ${e.priority??0} · type ${e.type}`,ts:e.created,_synthetic:!0}});const s=e.tries??0;return"running"===e.status&&s>=1?n.push({key:[t,s,-1],value:{level:"info",message:`attempt ${s} running`,ts:e.updated,_synthetic:!0}}):"pending"===e.status&&s>=1?n.push({key:[t,s,l],value:{level:"warn",message:`attempt ${s} failed — retry scheduled`,ts:e.updated,_synthetic:!0}}):"failed"===e.status?n.push({key:[t,s,l],value:{level:"error",message:`failed: ${e.status_message||e.status_code||"unknown"}`,ts:e.finished,_synthetic:!0}}):"done"===e.status&&n.push({key:[t,s,l],value:{level:"info",message:"done",ts:e.finished,_synthetic:!0}}),null!=o.$attr.attempt?n.filter(e=>e.key[1]===Number(o.$attr.attempt)):n},get mergedEntries(){const e=[...this.syntheticEntries,...this.entries];return e.sort((e,t)=>e.key[1]-t.key[1]||e.key[2]-t.key[2]),e},get filteredEntries(){let e=this.mergedEntries;return"log"===this.source?e=e.filter(e=>!e.value._synthetic):"event"===this.source&&(e=e.filter(e=>e.value._synthetic)),this.level&&(e=e.filter(e=>e.value.level===this.level)),e},get hasMore(){return null!=this.cursor},async load(){if(o.$attr.jobId){this.loading=!0,this.error=null;try{const e=new URLSearchParams;null!=o.$attr.attempt&&e.set("attempt",String(o.$attr.attempt)),e.set("limit","500");const t=await fetch(`${n()}/job/${encodeURIComponent(o.$attr.jobId)}/logs?${e}`),l=await t.json();if(!t.ok)throw new Error(l?.error?.message||`HTTP ${t.status}`);this.entries=l.result||[],this.cursor=l.cursor??null}catch(e){this.error=e?.message||String(e)}finally{this.loading=!1}this._scheduleScroll()}},async loadMore(){if(this.cursor)try{const e=new URLSearchParams;null!=o.$attr.attempt&&e.set("attempt",String(o.$attr.attempt)),e.set("limit","500"),e.set("cursor",this.cursor);const t=await fetch(`${n()}/job/${encodeURIComponent(o.$attr.jobId)}/logs?${e}`),l=await t.json();if(!t.ok)throw new Error(l?.error?.message||`HTTP ${t.status}`);this.entries=[...this.entries,...l.result||[]],this.cursor=l.cursor??null}catch(e){this.error=e?.message||String(e)}},setLevel(e){this.level=this.level===e?null:e},setSource(e){this.source=e},levelColor:e=>t[e]||"var(--okdb-app-fg-muted)",fmtTime(e){if(!e)return"";const t=new Date(e);return t.toTimeString().slice(0,8)+"."+String(t.getMilliseconds()).padStart(3,"0")},fmtFields(e){try{return JSON.stringify(e)}catch{return""}},_scheduleScroll(){this.autoScroll&&this._listRef&&(clearTimeout(this._scrollTimer),this._scrollTimer=setTimeout(()=>{this._listRef&&(this._listRef.scrollTop=this._listRef.scrollHeight)},0))},onScroll(){const e=this._listRef;e&&(this.autoScroll=e.scrollTop+e.clientHeight>=e.scrollHeight-8)},_subscribeLive(){this._unsubs=[e.on("queue:log",e=>{e&&e.id===o.$attr.jobId&&(null!=o.$attr.attempt&&e.attempt!==Number(o.$attr.attempt)||(this.entries=[...this.entries,{key:[e.id,e.attempt,e.seq],value:{level:e.level,message:e.message,fields:null,ts:Date.now()}}],this._scheduleScroll()))})]}}},async init(e){await this.load(),e.$watch(()=>[e.$attr.jobId,e.$attr.attempt],()=>this.load())},mount(){this._subscribeLive()},unmount(){clearTimeout(this._scrollTimer);for(const e of this._unsubs)e();this._unsubs=[]},template:'\n <div class="joblog">\n <div class="joblog__toolbar">\n <span class="joblog__title">Logs</span>\n <div class="joblog__pills">\n <button @click="setSource(\'all\')" :class="source===\'all\' ? \'pill pill--on\' : \'pill\'">All</button>\n <button @click="setSource(\'log\')" :class="source===\'log\' ? \'pill pill--on\' : \'pill\'">Logs</button>\n <button @click="setSource(\'event\')" :class="source===\'event\' ? \'pill pill--on\' : \'pill\'">Lifecycle</button>\n </div>\n <span class="joblog__sep"></span>\n <div class="joblog__pills">\n <button @click="setLevel(null)" :class="level==null ? \'pill pill--on\' : \'pill\'">·</button>\n <button @click="setLevel(\'info\')" :class="level===\'info\' ? \'pill pill--on\' : \'pill\'" style="color:var(--okdb-accent)">info</button>\n <button @click="setLevel(\'warn\')" :class="level===\'warn\' ? \'pill pill--on\' : \'pill\'" style="color:var(--okdb-warning)">warn</button>\n <button @click="setLevel(\'error\')" :class="level===\'error\' ? \'pill pill--on\' : \'pill\'" style="color:var(--okdb-danger)">error</button>\n <button @click="setLevel(\'debug\')" :class="level===\'debug\' ? \'pill pill--on\' : \'pill\'" style="color:var(--okdb-app-fg-muted)">debug</button>\n </div>\n <span class="joblog__spacer"></span>\n <button @click="autoScroll = !autoScroll" :class="autoScroll ? \'pill pill--on\' : \'pill\'" title="Auto-scroll to newest">↓ Auto</button>\n <button @click="load()" class="pill" :disabled="loading">Refresh</button>\n <span class="joblog__count">{{ filteredEntries.length }}</span>\n </div>\n\n <if :="error">\n <div class="joblog__msgline joblog__error">{{ error }}</div>\n </if>\n <if :="!error && loading && entries.length === 0">\n <div class="joblog__msgline">Loading…</div>\n </if>\n <if :="!error && !loading && filteredEntries.length === 0">\n <div class="joblog__msgline">No log entries.</div>\n </if>\n\n <div class="joblog__list" :ref="_listRef = $el" @scroll="onScroll()">\n <each :of="filteredEntries" let:item="entry">\n <div :class="entry.value._synthetic ? \'joblog__row joblog__row--synthetic\' : \'joblog__row\'">\n <span class="joblog__ts">{{ fmtTime(entry.value.ts) }}</span>\n <span class="joblog__level" :style="\'color:\' + levelColor(entry.value.level)">{{ entry.value.level }}</span>\n <if :="$attr.attempt == null">\n <span class="joblog__attempt">#{{ entry.key[1] }}</span>\n </if>\n <span class="joblog__msg">\n <if :="entry.value._synthetic">⟡ </if>{{ entry.value.message }}\n </span>\n <if :="entry.value.fields">\n <span class="joblog__fields" :title="fmtFields(entry.value.fields)">{{ fmtFields(entry.value.fields) }}</span>\n </if>\n </div>\n </each>\n <if :="hasMore">\n <button @click="loadMore()" class="pill joblog__more">Load older…</button>\n </if>\n </div>\n </div>\n ',style:"\n [tag] { display: block; }\n .joblog {\n display: flex;\n flex-direction: column;\n min-height: 160px;\n max-height: 420px;\n background: var(--okdb-app-bg-deep);\n border: 1px solid var(--okdb-app-border);\n border-radius: 8px;\n overflow: hidden;\n }\n .joblog__toolbar {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 6px 8px;\n background: var(--okdb-app-surface);\n border-bottom: 1px solid var(--okdb-app-border);\n flex-wrap: wrap;\n flex-shrink: 0;\n }\n .joblog__title { font-weight: 600; font-size: 12px; margin-right: 2px; }\n .joblog__pills { display: flex; gap: 3px; }\n .joblog__sep { width: 1px; height: 16px; background: var(--okdb-app-border); }\n .joblog__spacer { flex: 1; }\n .joblog__count { font-size: 11px; opacity: .4; }\n\n .pill {\n border: 1px solid var(--okdb-app-border);\n background: transparent;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n cursor: pointer;\n color: var(--okdb-app-fg-faint);\n transition: border-color .12s, color .12s, background .12s;\n white-space: nowrap;\n }\n .pill:hover { border-color: var(--okdb-app-fg-muted); color: var(--okdb-app-fg-muted); }\n .pill--on { border-color: var(--okdb-accent); background: var(--okdb-app-surface-2); color: var(--okdb-accent); }\n\n .joblog__list {\n flex: 1;\n overflow-y: auto;\n font-family: var(--font-mono, monospace);\n font-size: 12px;\n padding: 2px 0;\n min-height: 0;\n }\n .joblog__row {\n display: flex;\n gap: 8px;\n padding: 2px 10px;\n border-bottom: 1px solid var(--okdb-app-bg-deep);\n align-items: baseline;\n min-height: 20px;\n }\n .joblog__row:hover { background: var(--okdb-app-surface); }\n .joblog__row--synthetic { font-style: italic; opacity: .8; }\n .joblog__ts { color: var(--okdb-app-fg-muted); flex-shrink: 0; font-size: 11px; user-select: none; width: 86px; }\n .joblog__level { width: 40px; flex-shrink: 0; font-weight: bold; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }\n .joblog__attempt { color: var(--okdb-app-fg-muted); flex-shrink: 0; font-size: 11px; width: 28px; }\n .joblog__msg { flex: 1; white-space: pre-wrap; word-break: break-word; color: var(--okdb-app-fg); min-width: 0; }\n .joblog__fields { color: var(--okdb-app-fg-muted); font-size: 11px; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; }\n .joblog__msgline { padding: 18px 12px; opacity: .5; font-style: italic; font-size: 12px; }\n .joblog__error { color: var(--okdb-danger); opacity: .8; }\n .joblog__more { margin: 8px auto; display: block; }\n "};
@@ -1 +1 @@
1
- import e from"@toolkit/elements/ok-icon.ok.js";import t from"@toolkit/elements/ok-table.ok.js";import s from"@toolkit/core/ok-overlay.ok.js";import a from"../modals/edit-job-modal.ok.js";import o from"../modals/enqueue-job-modal.ok.js";import n from"../../../stores/events-store.js";import i from"../../../stores/okdb-store.js";export default{tag:"queue-jobs",dependencies:[e,t,s,o,a],attr:{type:{}},context:e=>({query:function({apiBase:e,pageSize:t=50}){return{busy:!1,values:[],total:0,hasNext:!1,hasPrev:!1,pageSize:t,loadError:null,type:null,status:null,sort:"when",direction:"asc",_cursors:[null],_pageIdx:0,_nextCursor:null,resetPaging(){this._cursors=[null],this._pageIdx=0,this._nextCursor=null,this.hasNext=!1,this.hasPrev=!1},async load(){const t="function"==typeof e?e():e,s=this._cursors[this._pageIdx];this.busy=!0,this.loadError=null;try{const e=new URLSearchParams({limit:String(this.pageSize),sort:this.sort??"when",direction:this.direction??"asc"});this.type&&e.set("type",this.type),this.status&&e.set("status",this.status),null!=s&&e.set("cursor",JSON.stringify(s));const a=await fetch(`${t}/queue/jobs?${e}`),o=await a.json();if(!a.ok)throw new Error(o?.error?.message||`HTTP ${a.status}`);const n=o.result??o.data??o;this.values=Array.isArray(n.items)?n.items:Array.isArray(n)?n:[],this._nextCursor=n.cursor??null,this.hasNext=null!=this._nextCursor,this.hasPrev=this._pageIdx>0}catch(e){this.loadError=e?.message||String(e),this.values=[]}finally{this.busy=!1}},async nextPage(){this.hasNext&&!this.busy&&(this._pageIdx+1>=this._cursors.length&&this._cursors.push(this._nextCursor),this._pageIdx++,await this.load())},async prevPage(){this.hasPrev&&!this.busy&&(this._pageIdx--,await this.load())}}}({apiBase:()=>i.apiBase,pageSize:50}),type:null,status:null,sortMode:"when",direction:"asc",stats:null,_statsTimer:null,async loadStats(){try{const e=await fetch(i.apiBase+"/queue/stats");if(e.ok){const t=await e.json();this.stats=t.result??t.data??null}}catch{}},_scheduleStats(){clearTimeout(this._statsTimer),this._statsTimer=setTimeout(()=>this.loadStats(),400)},statusCount(e){if(!this.stats)return"";const t=this.type;return e?t?this.stats.byType?.[t]?.byStatus?.[e]??0:this.stats.byStatus?.[e]??0:t?this.stats.byType?.[t]?.total??0:this.stats.total??0},applyTypeAttr(){var t;this.type=null==(t=e.$attr.type)||""===t?null:String(t),this._applyFilterAndReload()},setStatus(e){this.status=null==e||""===e?null:e,this._applyFilterAndReload()},setSort(e){this.sortMode=e,this._applySort(),this.query.resetPaging(),this.query.load()},toggleDirection(){this.direction="asc"===this.direction?"desc":"asc",this._applySort(),this.query.resetPaging(),this.query.load()},_applySort(){this.query.sort=this.sortMode,this.query.direction=this.direction},_applyFilterAndReload(){this.query.type=this.type,this.query.status=this.status,this._applySort(),this.query.resetPaging(),this.query.load()},async refresh(){clearTimeout(this._liveTimer),this.pendingUpdates=0;try{await fetch(i.apiBase+"/queue/reconcile",{method:"POST"})}catch{}await this.query.load(),await this.loadStats(),e.$dispatch("refresh")},async openEnqueue(){const t=this;await e.$ok.shared.modal.open({owner:e,title:"Enqueue Job",content:'<enqueue-job-modal :modal="modal" :initial-type="initialType" :on-done="onDone" />',context:s=>({modal:s,initialType:t.type||"",onDone:async()=>{e.$ok.shared?.toast?.success?.("Job enqueued"),await t.refresh()}})})},async openEditJob(t){const s=this;await e.$ok.shared.modal.open({owner:e,title:`Edit Job ${t.id.slice(0,8)}`,content:'<edit-job-modal :modal="modal" :job-id="jobId" :on-done="onDone" />',context:e=>({modal:e,jobId:t.id,onDone:async()=>{await s.refresh()}})})},async deleteJob(t){if(await e.$ok.shared.modal.confirm(`Delete job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{if(!(await fetch(i.apiBase+"/queue/job/"+t.id+"/remove",{method:"POST"})).ok)throw new Error("remove failed");const s=this.query.values.indexOf(t);s>=0&&this.query.values.splice(s,1),e.$ok.shared.toast?.info("Deleted "+t.id.slice(0,8)),e.$dispatch("refresh"),this._scheduleStats()}catch{e.$ok.shared.toast?.error("Delete failed")}finally{t._busy=!1}}},async cancelJob(t){if(await e.$ok.shared.modal.confirm(`Cancel job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{if(!(await fetch(i.apiBase+"/queue/job/"+t.id+"/cancel",{method:"POST"})).ok)throw new Error("cancel failed");e.$ok.shared.toast?.info("Cancelled "+t.id.slice(0,8)),await this.refresh()}catch{e.$ok.shared.toast?.error("Cancel failed")}finally{t._busy=!1}}},async retryJob(t){if(await e.$ok.shared.modal.confirm(`Retry job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{const s=await fetch(i.apiBase+"/queue/job/"+t.id+"/retry",{method:"POST"}),a=await s.json();if(!s.ok)throw new Error(a?.error?.message||"retry failed");e.$ok.shared.toast?.success("Retried "+t.id.slice(0,8)),await this.refresh()}catch(t){e.$ok.shared.toast?.error("Retry failed: "+(t?.message||t))}finally{t._busy=!1}}},bulkBusy:!1,async retryAllFailed(){if(await e.$ok.shared.modal.confirm(`Retry all failed jobs${this.type?` for type "<b>${this.type}</b>"`:""}?`)){this.bulkBusy=!0;try{const t={limit:5e3};this.type&&(t.type=this.type);const s=await fetch(i.apiBase+"/queue/retryFailed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await s.json();if(!s.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.success?.(`Retried ${a.data.retried} failed jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Bulk retry failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async deleteAllDone(){if(await e.$ok.shared.modal.confirm(`Clear all "done" jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>This cannot be undone.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const s=await fetch(i.apiBase+"/queue/clearDone",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await s.json();if(!s.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} done jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear done failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async deleteAllFailed(){if(await e.$ok.shared.modal.confirm(`Delete all "failed" jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>This cannot be undone.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const s=await fetch(i.apiBase+"/queue/clearFailed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await s.json();if(!s.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} failed jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear failed jobs error: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async clearStuck(){if(await e.$ok.shared.modal.confirm(`Delete all stuck (expired-claim) running jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>Stuck jobs are running jobs whose TTL has expired. They will be permanently deleted, not re-queued.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const s=await fetch(i.apiBase+"/queue/clearStuck",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await s.json();if(!s.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} stuck jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear stuck failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},isStatusOn(e){return(this.status??null)===(e??null)},statusBadgeStyle(e){const t={pending:"var(--okdb-warning)",running:"var(--okdb-info)",done:"var(--okdb-success)",failed:"var(--okdb-danger)"}[e]||"#9ca3af";return`color:${t}; border-color:${t};`},formatWhen(e){if(!e)return"—";const t=e-Date.now(),s=Math.abs(t),a=Math.round(s/1e3);if(a<5)return t<=0?"now":"in a moment";if(a<60)return t<0?`${a}s ago`:`in ${a}s`;const o=Math.round(a/60);if(o<60)return t<0?`${o}m ago`:`in ${o}m`;const n=Math.round(o/60);if(n<24)return t<0?`${n}h ago`:`in ${n}h`;const i=Math.round(n/24);return t<0?`${i}d ago`:`in ${i}d`},shortId:e=>(e||"").slice(0,8),_unsubs:[],_liveTimer:null,pendingUpdates:0,_scheduleLiveReload(){this.pendingUpdates++,clearTimeout(this._liveTimer),this._liveTimer=setTimeout(async()=>{await this.query.load(),await this.loadStats(),e.$dispatch("refresh"),this.pendingUpdates=0},3e3)}}),async init(e){this.applyTypeAttr(),e.$watch(()=>e.$attr.type,()=>this.applyTypeAttr()),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>{this.stats=null,this.query.resetPaging(),Promise.all([this.query.load(),this.loadStats()])}),await Promise.all([this.query.load(),this.loadStats()])},mount(){const e=()=>this._scheduleLiveReload();this._unsubs=[n.on("queue:enqueued",e),n.on("queue:done",e),n.on("queue:failed",e),n.on("queue:retry",e),n.on("queue:removed",e),n.on("queue:reconciled",e)]},unmount(){clearTimeout(this._liveTimer),clearTimeout(this._statsTimer);for(const e of this._unsubs)e();this._unsubs=[]},template:'\n<div style="display:flex; flex-direction:column; height:100%; min-height:0; gap:10px;">\n\n \x3c!-- ── Row 1: Status filters + Enqueue ── --\x3e\n <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">\n <button @click="setStatus(null)" :class="isStatusOn(null) ? \'pill pill--on\' : \'pill\'">All<span class="pill-count">{{ statusCount(null) }}</span></button>\n <button @click="setStatus(\'pending\')" :class="isStatusOn(\'pending\') ? \'pill pill--on pill--p\' : \'pill pill--p\'">Pending<span class="pill-count">{{ statusCount(\'pending\') }}</span></button>\n <button @click="setStatus(\'running\')" :class="isStatusOn(\'running\') ? \'pill pill--on pill--r\' : \'pill pill--r\'">Running<span class="pill-count">{{ statusCount(\'running\') }}</span></button>\n <button @click="setStatus(\'failed\')" :class="isStatusOn(\'failed\') ? \'pill pill--on pill--f\' : \'pill pill--f\'">Failed<span class="pill-count">{{ statusCount(\'failed\') }}</span></button>\n <button @click="setStatus(\'done\')" :class="isStatusOn(\'done\') ? \'pill pill--on pill--d\' : \'pill pill--d\'">Done<span class="pill-count">{{ statusCount(\'done\') }}</span></button>\n\n <div style="flex:1;"></div>\n\n <select @change="setSort($el.value)" :value="sortMode" style="font-size:12px;">\n <option value="when">Sort: When</option>\n <option value="priority">Sort: Priority</option>\n </select>\n <button @click="toggleDirection" style="font-size:12px; min-width:32px;" title="Toggle sort direction">\n {{ direction === \'asc\' ? \'▲\' : \'▼\' }}\n </button>\n\n <button @click="openEnqueue"\n style="background:color-mix(in srgb, var(--okdb-accent) 13%, transparent); border-color:color-mix(in srgb, var(--okdb-accent) 33%, transparent); color:var(--okdb-accent); font-weight:600; padding:4px 14px; font-size:12px; white-space:nowrap;">\n + Enqueue\n </button>\n </div>\n\n \x3c!-- ── Row 2: Bulk actions ── --\x3e\n <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; padding:5px 0; border-top:1px solid var(--okdb-app-surface-2); border-bottom:1px solid var(--okdb-app-surface-2);">\n <button @click="retryAllFailed()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-warning); border-color:color-mix(in srgb, var(--okdb-warning) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Reset all failed jobs back to pending">\n ↺ Retry Failed\n </button>\n <div style="width:1px; height:16px; background:var(--okdb-app-border);"></div>\n <button @click="deleteAllDone()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-success); border-color:color-mix(in srgb, var(--okdb-success) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Delete all done jobs in one index pass">\n ✕ Clear Done\n </button>\n <button @click="deleteAllFailed()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-danger); border-color:color-mix(in srgb, var(--okdb-danger) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Permanently delete all failed jobs">\n ✕ Clear Failed\n </button>\n <button @click="clearStuck()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-app-fg-muted); border-color:color-mix(in srgb, var(--okdb-app-fg-muted) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Delete running jobs whose claim TTL has expired (permanently, not re-queued)">\n ✕ Clear Stuck\n </button>\n <if :="pendingUpdates > 0">\n <span style="font-size:11px; color:var(--okdb-warning); opacity:.8; margin-left:4px;">\n ⟳ {{ pendingUpdates }} pending\n </span>\n </if>\n </div>\n\n \x3c!-- Table --\x3e\n <div style="display:flex; flex-direction:column; position:relative; flex:1; min-height:0;">\n <if :="query.busy">\n <ok-overlay>\n <div class="loader-center">\n <ok-icon name="loader" class="spin" style="width:32px;height:32px;"></ok-icon>\n </div>\n </ok-overlay>\n </if>\n\n <if :="query.loadError && !query.busy">\n <div style="color:var(--okdb-danger); padding:12px 14px; border:1px solid color-mix(in srgb, var(--okdb-danger) 33%, transparent); border-radius:8px; margin-bottom:8px; font-size:13px; display:flex; align-items:center; gap:10px;">\n <span style="flex:1;">{{ query.loadError }}</span>\n <button @click="query.load()" style="font-size:11px; color:var(--okdb-danger); border-color:color-mix(in srgb, var(--okdb-danger) 33%, transparent); padding:3px 10px;">Retry</button>\n </div>\n </if>\n\n <ok-table :data="query.values" no-scroll style="flex:1; min-height:0; overflow:auto;">\n <columns>\n <column width="80px" no-resize>\n <header>Status</header>\n <cell let:row>\n <span class="status-badge" :style="statusBadgeStyle(row.status)">{{ row.status }}</span>\n </cell>\n </column>\n <column width="1fr">\n <header>Type + Metadata</header>\n <cell let:row>\n <div style="display:flex; flex-direction:column; gap:2px;">\n <div>{{ row.type }}</div>\n <div style="display:flex; gap:4px; flex-wrap:wrap;">\n <if :="row.tags && row.tags.length > 0">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7;">{{ row.tags.join(\', \') }}</span>\n </if>\n <if :="row.progress">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7;">{{ row.progress }}</span>\n </if>\n <if :="row.cron">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7; color:var(--okdb-indigo);">cron: {{ row.cron }}</span>\n </if>\n <if :="row.idempotency_key">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7; color:#34d399;">key: {{ row.idempotency_key }}</span>\n </if>\n </div>\n </div>\n </cell>\n </column>\n <column width="130px" no-resize>\n <header>When</header>\n <cell let:row>{{ formatWhen(row.when) }}</cell>\n </column>\n <column width="70px" no-resize>\n <header>Tries</header>\n <cell let:row>\n <span :style="row.tries >= row.max_tries && row.max_tries > 0 ? \'color:var(--okdb-danger);\' : \'\'">\n {{ row.tries }} / {{ row.max_tries }}\n </span>\n </cell>\n </column>\n <column width="180px" no-resize>\n <header>Actions</header>\n <cell let:row>\n <button @click="openEditJob(row)" :disabled="row._busy" style="font-size:11px;">Edit</button>\n <button @click="retryJob(row)" :disabled="row._busy" style="font-size:11px;">Retry</button>\n <if :="row.status === \'pending\' || row.status === \'running\'">\n <button @click="cancelJob(row)" :disabled="row._busy" style="font-size:11px;">Cancel</button>\n </if>\n <button @click="deleteJob(row)" :disabled="row._busy" style="color:#ff6c6b; font-size:11px;">Delete</button>\n </cell>\n </column>\n </columns>\n\n <expanded let:row>\n <div style="padding:10px; font-size:12px; display:flex; flex-direction:column; gap:6px;">\n <if :="row.error_stack || row.status_message">\n <div>\n <if :="row.status_message">\n <div style="white-space:pre-wrap; opacity:.9; margin-bottom:6px;">{{ row.status_message }}</div>\n </if>\n <if :="row.error_stack">\n <pre style="white-space:pre-wrap; opacity:.85; max-height:220px; overflow:auto; background:var(--okdb-app-bg-deep); padding:8px; border-radius:4px;">{{ row.error_stack }}</pre>\n </if>\n </div>\n </if>\n <div style="display:flex; flex-wrap:wrap; gap:12px; opacity:.8; font-size:11px;">\n <span>ID: <b>{{ row.id }}</b></span>\n <span>Status code: <b>{{ row.status_code || \'—\' }}</b></span>\n <span>Max tries: <b>{{ row.max_tries ?? \'—\' }}</b></span>\n <span>Retry delay: <b>{{ row.retry_delay ?? 0 }}ms</b></span>\n <span>Backoff: <b>{{ row.backoff_multiplier ?? 1 }}</b></span>\n <span>Priority: <b>{{ row.priority ?? 0 }}</b></span>\n <span :="row.bucket" style="display:inline;">Bucket: <b>{{ row.bucket || \'—\' }}</b></span>\n <span>Claim expires: <b>{{ formatWhen(row.claim_expires) }}</b></span>\n </div>\n <details style="margin-top:6px;">\n <summary style="cursor:pointer; opacity:.8; font-size:11px;">Raw job</summary>\n <pre style="white-space:pre-wrap; opacity:.85; max-height:260px; overflow:auto; margin-top:4px; background:var(--okdb-app-bg-deep); padding:8px; border-radius:4px; font-size:10px;">{{ row }}</pre>\n </details>\n </div>\n </expanded>\n </ok-table>\n\n \x3c!-- Pagination --\x3e\n <div class="ok-pager">\n <button @click="query.prevPage()" :disabled="!query.hasPrev || query.busy">‹ Prev</button>\n <span>{{ statusCount(status) }} total · {{ query.pageSize }}/page</span>\n <button @click="query.nextPage()" :disabled="!query.hasNext || query.busy">Next ›</button>\n </div>\n </div>\n\n</div>\n',style:".pill{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-surface);padding:4px 10px;border-radius:999px;font-size:12px;cursor:pointer;color:var(--okdb-app-fg-muted);transition:border-color .12s,color .12s}.pill:hover{border-color:#3b4754;color:var(--okdb-app-fg)}.pill--on{box-shadow:0 0 0 1px rgba(255,255,255,.07) inset}.pill--p.pill--on{color:var(--okdb-warning);border-color:var(--okdb-warning)}.pill--r.pill--on{color:var(--okdb-info);border-color:var(--okdb-info)}.pill--f.pill--on{color:var(--okdb-danger);border-color:var(--okdb-danger)}.pill--d.pill--on{color:var(--okdb-success);border-color:var(--okdb-success)}.pill-count{font-size:11px;opacity:.65;font-variant-numeric:tabular-nums}.status-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;border:1px solid;white-space:nowrap}"};
1
+ import e from"@toolkit/elements/ok-icon.ok.js";import t from"@toolkit/elements/ok-table.ok.js";import o from"@toolkit/core/ok-overlay.ok.js";import a from"../modals/edit-job-modal.ok.js";import s from"../modals/enqueue-job-modal.ok.js";import n from"./code-panel.ok.js";import i from"./job-log-panel.ok.js";import r from"../../../stores/events-store.js";import l from"../../../stores/okdb-store.js";const d=new Set;export default{tag:"queue-jobs",dependencies:[e,t,o,s,a,n,i],attr:{type:{}},context:e=>({query:function({apiBase:e,pageSize:t=50}){return{busy:!1,values:[],total:0,hasNext:!1,hasPrev:!1,pageSize:t,loadError:null,type:null,status:null,sort:"when",direction:"asc",_cursors:[null],_pageIdx:0,_nextCursor:null,resetPaging(){this._cursors=[null],this._pageIdx=0,this._nextCursor=null,this.hasNext=!1,this.hasPrev=!1},async load(){const t="function"==typeof e?e():e,o=this._cursors[this._pageIdx];this.busy=!0,this.loadError=null;try{const e=new URLSearchParams({limit:String(this.pageSize),sort:this.sort??"when",direction:this.direction??"asc"});this.type&&e.set("type",this.type),this.status&&e.set("status",this.status),null!=o&&e.set("cursor",JSON.stringify(o));const a=await fetch(`${t}/queue/jobs?${e}`),s=await a.json();if(!a.ok)throw new Error(s?.error?.message||`HTTP ${a.status}`);const n=s.result??s.data??s;this.values=Array.isArray(n.items)?n.items:Array.isArray(n)?n:[],this._nextCursor=n.cursor??null,this.hasNext=null!=this._nextCursor,this.hasPrev=this._pageIdx>0}catch(e){this.loadError=e?.message||String(e),this.values=[]}finally{this.busy=!1}},async nextPage(){this.hasNext&&!this.busy&&(this._pageIdx+1>=this._cursors.length&&this._cursors.push(this._nextCursor),this._pageIdx++,await this.load())},async prevPage(){this.hasPrev&&!this.busy&&(this._pageIdx--,await this.load())}}}({apiBase:()=>l.apiBase,pageSize:50}),type:null,status:null,sortMode:"when",direction:"asc",stats:null,_statsTimer:null,async loadStats(){try{const e=await fetch(l.apiBase+"/queue/stats");if(e.ok){const t=await e.json();this.stats=t.result??t.data??null}}catch{}},_jobCache:{},jobMetaText:e=>JSON.stringify(e,null,2),errorText:e=>JSON.stringify({status_code:e.status_code??null,status_message:e.status_message??null,error_stack:e.error_stack??null},null,2),payloadText(e){const t=this._jobCache[e.id];return void 0===t?(d.has(e.id)||(d.add(e.id),queueMicrotask(()=>this._fetchJobPayload(e.id))),""):"__error__"===t?"// failed to load payload":JSON.stringify(t?.payload??null,null,2)},async _fetchJobPayload(e){let t="__error__";try{const o=await fetch(`${l.apiBase}/queue/job/${encodeURIComponent(e)}`);if(o.ok){const e=await o.json();t=e?.result??e?.data??null}}catch{}finally{d.delete(e),this._jobCache={...this._jobCache,[e]:t}}},_scheduleStats(){clearTimeout(this._statsTimer),this._statsTimer=setTimeout(()=>this.loadStats(),400)},statusCount(e){if(!this.stats)return"";const t=this.type;return e?t?this.stats.byType?.[t]?.byStatus?.[e]??0:this.stats.byStatus?.[e]??0:t?this.stats.byType?.[t]?.total??0:this.stats.total??0},applyTypeAttr(){var t;this.type=null==(t=e.$attr.type)||""===t?null:String(t),this._applyFilterAndReload()},setStatus(e){this.status=null==e||""===e?null:e,this._applyFilterAndReload()},setSort(e){this.sortMode=e,this._applySort(),this.query.resetPaging(),this.query.load()},toggleDirection(){this.direction="asc"===this.direction?"desc":"asc",this._applySort(),this.query.resetPaging(),this.query.load()},_applySort(){this.query.sort=this.sortMode,this.query.direction=this.direction},_applyFilterAndReload(){this.query.type=this.type,this.query.status=this.status,this._applySort(),this.query.resetPaging(),this.query.load()},async refresh(){clearTimeout(this._liveTimer),this.pendingUpdates=0;try{await fetch(l.apiBase+"/queue/reconcile",{method:"POST"})}catch{}await this.query.load(),await this.loadStats(),e.$dispatch("refresh")},async openEnqueue(){const t=this;await e.$ok.shared.modal.open({owner:e,title:"Enqueue Job",content:'<enqueue-job-modal :modal="modal" :initial-type="initialType" :on-done="onDone" />',context:o=>({modal:o,initialType:t.type||"",onDone:async()=>{e.$ok.shared?.toast?.success?.("Job enqueued"),await t.refresh()}})})},async openEditJob(t){const o=this;await e.$ok.shared.modal.open({owner:e,title:`Edit Job ${t.id.slice(0,8)}`,content:'<edit-job-modal :modal="modal" :job-id="jobId" :on-done="onDone" />',context:e=>({modal:e,jobId:t.id,onDone:async()=>{await o.refresh()}})})},async deleteJob(t){if(await e.$ok.shared.modal.confirm(`Delete job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{if(!(await fetch(l.apiBase+"/queue/job/"+t.id+"/remove",{method:"POST"})).ok)throw new Error("remove failed");const o=this.query.values.indexOf(t);o>=0&&this.query.values.splice(o,1),e.$ok.shared.toast?.info("Deleted "+t.id.slice(0,8)),e.$dispatch("refresh"),this._scheduleStats()}catch{e.$ok.shared.toast?.error("Delete failed")}finally{t._busy=!1}}},async cancelJob(t){if(await e.$ok.shared.modal.confirm(`Cancel job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{if(!(await fetch(l.apiBase+"/queue/job/"+t.id+"/cancel",{method:"POST"})).ok)throw new Error("cancel failed");e.$ok.shared.toast?.info("Cancelled "+t.id.slice(0,8)),await this.refresh()}catch{e.$ok.shared.toast?.error("Cancel failed")}finally{t._busy=!1}}},async retryJob(t){if(await e.$ok.shared.modal.confirm(`Retry job <code>${t.id.slice(0,8)}</code>?`)){t._busy=!0;try{const o=await fetch(l.apiBase+"/queue/job/"+t.id+"/retry",{method:"POST"}),a=await o.json();if(!o.ok)throw new Error(a?.error?.message||"retry failed");e.$ok.shared.toast?.success("Retried "+t.id.slice(0,8)),await this.refresh()}catch(t){e.$ok.shared.toast?.error("Retry failed: "+(t?.message||t))}finally{t._busy=!1}}},bulkBusy:!1,async retryAllFailed(){if(await e.$ok.shared.modal.confirm(`Retry all failed jobs${this.type?` for type "<b>${this.type}</b>"`:""}?`)){this.bulkBusy=!0;try{const t={limit:5e3};this.type&&(t.type=this.type);const o=await fetch(l.apiBase+"/queue/retryFailed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await o.json();if(!o.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.success?.(`Retried ${a.data.retried} failed jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Bulk retry failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async deleteAllDone(){if(await e.$ok.shared.modal.confirm(`Clear all "done" jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>This cannot be undone.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const o=await fetch(l.apiBase+"/queue/clearDone",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await o.json();if(!o.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} done jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear done failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async deleteAllFailed(){if(await e.$ok.shared.modal.confirm(`Delete all "failed" jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>This cannot be undone.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const o=await fetch(l.apiBase+"/queue/clearFailed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await o.json();if(!o.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} failed jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear failed jobs error: "+(t?.message||t))}finally{this.bulkBusy=!1}}},async clearStuck(){if(await e.$ok.shared.modal.confirm(`Delete all stuck (expired-claim) running jobs${this.type?` for type "<b>${this.type}</b>"`:""}?<br><br>Stuck jobs are running jobs whose TTL has expired. They will be permanently deleted, not re-queued.`)){this.bulkBusy=!0;try{const t={limit:5e4};this.type&&(t.type=this.type);const o=await fetch(l.apiBase+"/queue/clearStuck",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await o.json();if(!o.ok)throw new Error(a?.error?.message||"failed");e.$ok.shared?.toast?.info?.(`Cleared ${a.data.deleted} stuck jobs`),await this.refresh()}catch(t){e.$ok.shared?.toast?.error?.("Clear stuck failed: "+(t?.message||t))}finally{this.bulkBusy=!1}}},isStatusOn(e){return(this.status??null)===(e??null)},statusBadgeStyle(e){const t={pending:"var(--okdb-warning)",running:"var(--okdb-info)",done:"var(--okdb-success)",failed:"var(--okdb-danger)"}[e]||"#9ca3af";return`color:${t}; border-color:${t};`},formatWhen(e){if(!e)return"—";const t=e-Date.now(),o=Math.abs(t),a=Math.round(o/1e3);if(a<5)return t<=0?"now":"in a moment";if(a<60)return t<0?`${a}s ago`:`in ${a}s`;const s=Math.round(a/60);if(s<60)return t<0?`${s}m ago`:`in ${s}m`;const n=Math.round(s/60);if(n<24)return t<0?`${n}h ago`:`in ${n}h`;const i=Math.round(n/24);return t<0?`${i}d ago`:`in ${i}d`},shortId:e=>(e||"").slice(0,8),_unsubs:[],_liveTimer:null,pendingUpdates:0,_scheduleLiveReload(){this.pendingUpdates++,clearTimeout(this._liveTimer),this._liveTimer=setTimeout(async()=>{await this.query.load(),await this.loadStats(),this._jobCache={},e.$dispatch("refresh"),this.pendingUpdates=0},3e3)}}),async init(e){this.applyTypeAttr(),e.$watch(()=>e.$attr.type,()=>this.applyTypeAttr()),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>{this.stats=null,this.query.resetPaging(),Promise.all([this.query.load(),this.loadStats()])}),await Promise.all([this.query.load(),this.loadStats()])},mount(){const e=()=>this._scheduleLiveReload();this._unsubs=[r.on("queue:enqueued",e),r.on("queue:done",e),r.on("queue:failed",e),r.on("queue:retry",e),r.on("queue:removed",e),r.on("queue:reconciled",e)]},unmount(){clearTimeout(this._liveTimer),clearTimeout(this._statsTimer);for(const e of this._unsubs)e();this._unsubs=[]},template:'\n<div style="display:flex; flex-direction:column; height:100%; min-height:0; gap:10px;">\n\n \x3c!-- ── Row 1: Status filters + Enqueue ── --\x3e\n <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">\n <button @click="setStatus(null)" :class="isStatusOn(null) ? \'pill pill--on\' : \'pill\'">All<span class="pill-count">{{ statusCount(null) }}</span></button>\n <button @click="setStatus(\'pending\')" :class="isStatusOn(\'pending\') ? \'pill pill--on pill--p\' : \'pill pill--p\'">Pending<span class="pill-count">{{ statusCount(\'pending\') }}</span></button>\n <button @click="setStatus(\'running\')" :class="isStatusOn(\'running\') ? \'pill pill--on pill--r\' : \'pill pill--r\'">Running<span class="pill-count">{{ statusCount(\'running\') }}</span></button>\n <button @click="setStatus(\'failed\')" :class="isStatusOn(\'failed\') ? \'pill pill--on pill--f\' : \'pill pill--f\'">Failed<span class="pill-count">{{ statusCount(\'failed\') }}</span></button>\n <button @click="setStatus(\'done\')" :class="isStatusOn(\'done\') ? \'pill pill--on pill--d\' : \'pill pill--d\'">Done<span class="pill-count">{{ statusCount(\'done\') }}</span></button>\n\n <div style="flex:1;"></div>\n\n <select @change="setSort($el.value)" :value="sortMode" style="font-size:12px;">\n <option value="when">Sort: When</option>\n <option value="priority">Sort: Priority</option>\n </select>\n <button @click="toggleDirection" style="font-size:12px; min-width:32px;" title="Toggle sort direction">\n {{ direction === \'asc\' ? \'▲\' : \'▼\' }}\n </button>\n\n <button @click="openEnqueue"\n style="background:color-mix(in srgb, var(--okdb-accent) 13%, transparent); border-color:color-mix(in srgb, var(--okdb-accent) 33%, transparent); color:var(--okdb-accent); font-weight:600; padding:4px 14px; font-size:12px; white-space:nowrap;">\n + Enqueue\n </button>\n </div>\n\n \x3c!-- ── Row 2: Bulk actions ── --\x3e\n <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; padding:5px 0; border-top:1px solid var(--okdb-app-surface-2); border-bottom:1px solid var(--okdb-app-surface-2);">\n <button @click="retryAllFailed()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-warning); border-color:color-mix(in srgb, var(--okdb-warning) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Reset all failed jobs back to pending">\n ↺ Retry Failed\n </button>\n <div style="width:1px; height:16px; background:var(--okdb-app-border);"></div>\n <button @click="deleteAllDone()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-success); border-color:color-mix(in srgb, var(--okdb-success) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Delete all done jobs in one index pass">\n ✕ Clear Done\n </button>\n <button @click="deleteAllFailed()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-danger); border-color:color-mix(in srgb, var(--okdb-danger) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Permanently delete all failed jobs">\n ✕ Clear Failed\n </button>\n <button @click="clearStuck()" :disabled="bulkBusy"\n style="font-size:12px; color:var(--okdb-app-fg-muted); border-color:color-mix(in srgb, var(--okdb-app-fg-muted) 20%, transparent); background:transparent; padding:3px 10px;"\n title="Delete running jobs whose claim TTL has expired (permanently, not re-queued)">\n ✕ Clear Stuck\n </button>\n <if :="pendingUpdates > 0">\n <span style="font-size:11px; color:var(--okdb-warning); opacity:.8; margin-left:4px;">\n ⟳ {{ pendingUpdates }} pending\n </span>\n </if>\n </div>\n\n \x3c!-- Table --\x3e\n <div style="display:flex; flex-direction:column; position:relative; flex:1; min-height:0;">\n <if :="query.busy">\n <ok-overlay>\n <div class="loader-center">\n <ok-icon name="loader" class="spin" style="width:32px;height:32px;"></ok-icon>\n </div>\n </ok-overlay>\n </if>\n\n <if :="query.loadError && !query.busy">\n <div style="color:var(--okdb-danger); padding:12px 14px; border:1px solid color-mix(in srgb, var(--okdb-danger) 33%, transparent); border-radius:8px; margin-bottom:8px; font-size:13px; display:flex; align-items:center; gap:10px;">\n <span style="flex:1;">{{ query.loadError }}</span>\n <button @click="query.load()" style="font-size:11px; color:var(--okdb-danger); border-color:color-mix(in srgb, var(--okdb-danger) 33%, transparent); padding:3px 10px;">Retry</button>\n </div>\n </if>\n\n <ok-table :data="query.values" no-scroll style="flex:1; min-height:0; overflow:auto;">\n <columns>\n <column width="80px" no-resize>\n <header>Status</header>\n <cell let:row>\n <span class="status-badge" :style="statusBadgeStyle(row.status)">{{ row.status }}</span>\n </cell>\n </column>\n <column width="1fr">\n <header>Type + Metadata</header>\n <cell let:row>\n <div style="display:flex; flex-direction:column; gap:2px;">\n <div>{{ row.type }}</div>\n <div style="display:flex; gap:4px; flex-wrap:wrap;">\n <if :="row.tags && row.tags.length > 0">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7;">{{ row.tags.join(\', \') }}</span>\n </if>\n <if :="row.progress">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7;">{{ row.progress }}</span>\n </if>\n <if :="row.cron">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7; color:var(--okdb-indigo);">cron: {{ row.cron }}</span>\n </if>\n <if :="row.idempotency_key">\n <span style="display:inline-block; font-size:11px; padding:1px 6px; background:var(--okdb-app-bg-deep); border-radius:3px; opacity:.7; color:#34d399;">key: {{ row.idempotency_key }}</span>\n </if>\n </div>\n </div>\n </cell>\n </column>\n <column width="130px" no-resize>\n <header>When</header>\n <cell let:row>{{ formatWhen(row.when) }}</cell>\n </column>\n <column width="70px" no-resize>\n <header>Tries</header>\n <cell let:row>\n <span :style="row.tries >= row.max_tries && row.max_tries > 0 ? \'color:var(--okdb-danger);\' : \'\'">\n {{ row.tries }} / {{ row.max_tries }}\n </span>\n </cell>\n </column>\n <column width="180px" no-resize>\n <header>Actions</header>\n <cell let:row>\n <button @click="openEditJob(row)" :disabled="row._busy" style="font-size:11px;">Edit</button>\n <button @click="retryJob(row)" :disabled="row._busy" style="font-size:11px;">Retry</button>\n <if :="row.status === \'pending\' || row.status === \'running\'">\n <button @click="cancelJob(row)" :disabled="row._busy" style="font-size:11px;">Cancel</button>\n </if>\n <button @click="deleteJob(row)" :disabled="row._busy" style="color:#ff6c6b; font-size:11px;">Delete</button>\n </cell>\n </column>\n </columns>\n\n <expanded let:row>\n <div style="padding:10px; font-size:12px; display:flex; flex-direction:column; gap:6px;">\n <if :="row.error_stack || row.status_message">\n <div>\n <if :="row.status_message">\n <div style="white-space:pre-wrap; opacity:.9; margin-bottom:6px;">{{ row.status_message }}</div>\n </if>\n <if :="row.error_stack">\n <pre style="white-space:pre-wrap; opacity:.85; max-height:220px; overflow:auto; background:var(--okdb-app-bg-deep); padding:8px; border-radius:4px;">{{ row.error_stack }}</pre>\n </if>\n </div>\n </if>\n <div style="display:flex; flex-wrap:wrap; gap:12px; opacity:.8; font-size:11px;">\n <span>ID: <b>{{ row.id }}</b></span>\n <span>Status code: <b>{{ row.status_code || \'—\' }}</b></span>\n <span>Max tries: <b>{{ row.max_tries ?? \'—\' }}</b></span>\n <span>Retry delay: <b>{{ row.retry_delay ?? 0 }}ms</b></span>\n <span>Backoff: <b>{{ row.backoff_multiplier ?? 1 }}</b></span>\n <span>Priority: <b>{{ row.priority ?? 0 }}</b></span>\n <span :="row.bucket" style="display:inline;">Bucket: <b>{{ row.bucket || \'—\' }}</b></span>\n <span>Claim expires: <b>{{ formatWhen(row.claim_expires) }}</b></span>\n </div>\n <div style="display:flex; flex-direction:column; gap:8px; margin-top:6px;">\n <code-panel title="Job" lang="json" :text="jobMetaText(row)"></code-panel>\n <code-panel title="Payload" lang="json" :text="payloadText(row)"></code-panel>\n <if :="row.status === \'failed\'">\n <code-panel title="Error" lang="json" :text="errorText(row)"></code-panel>\n </if>\n <job-log-panel :job-id="row.id" :env="$ok.stores.okdb.selectedEnv" :job="row"></job-log-panel>\n </div>\n </div>\n </expanded>\n </ok-table>\n\n \x3c!-- Pagination --\x3e\n <div class="ok-pager">\n <button @click="query.prevPage()" :disabled="!query.hasPrev || query.busy">‹ Prev</button>\n <span>{{ statusCount(status) }} total · {{ query.pageSize }}/page</span>\n <button @click="query.nextPage()" :disabled="!query.hasNext || query.busy">Next ›</button>\n </div>\n </div>\n\n</div>\n',style:".pill{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--okdb-app-border);background:var(--okdb-app-surface);padding:4px 10px;border-radius:999px;font-size:12px;cursor:pointer;color:var(--okdb-app-fg-muted);transition:border-color .12s,color .12s}.pill:hover{border-color:#3b4754;color:var(--okdb-app-fg)}.pill--on{box-shadow:0 0 0 1px rgba(255,255,255,.07) inset}.pill--p.pill--on{color:var(--okdb-warning);border-color:var(--okdb-warning)}.pill--r.pill--on{color:var(--okdb-info);border-color:var(--okdb-info)}.pill--f.pill--on{color:var(--okdb-danger);border-color:var(--okdb-danger)}.pill--d.pill--on{color:var(--okdb-success);border-color:var(--okdb-success)}.pill-count{font-size:11px;opacity:.65;font-variant-numeric:tabular-nums}.status-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;border:1px solid;white-space:nowrap}"};
@@ -0,0 +1 @@
1
+ export default{tag:"connect-peer-modal",attr:{modal:{},onDone:{}},context:n=>({busy:!1,error:null,address:"",myAddress:"",authMode:"token",token:"",username:"",password:"",dataLink:!0,get canSubmit(){return!(this.busy||!this.address.trim()||"token"===this.authMode&&!this.token.trim()||!("userpass"!==this.authMode||this.username.trim()&&this.password))},async submit(){if(this.canSubmit){this.error=null,this.busy=!0;try{const e={address:this.address.trim(),data_link:this.dataLink},t=this.myAddress.trim();t&&(e.my_address=t),"token"===this.authMode?e.token=this.token.trim():"userpass"===this.authMode&&(e.username=this.username.trim(),e.password=this.password);const o=await fetch(`${__ENV__.API}/sync/connect`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!o.ok){const n=await o.json().catch(()=>({}));throw new Error(n?.error?.message||`Connect failed (HTTP ${o.status})`)}await n.$attr.modal.close(),await(n.$attr.onDone?.())}catch(n){this.error=n?.message||String(n)}finally{this.busy=!1}}},cancel(){n.$attr.modal.close()}}),template:'\n<div style="min-width:420px; display:flex; flex-direction:column; gap:14px;">\n\n <div style="display:flex; flex-direction:column; gap:4px;">\n <label style="font-size:12px; opacity:.6;">Remote node address</label>\n <input\n type="text"\n placeholder="http://host:port"\n :value="address"\n @input="address = $el.value"\n @keydown.enter="submit()"\n autofocus\n style="font-family:monospace;"\n />\n </div>\n\n <div style="display:flex; flex-direction:column; gap:4px;">\n <label style="font-size:12px; opacity:.6;">My address <span style="opacity:.6; font-weight:normal;">— how the remote will reach this node</span></label>\n <input\n type="text"\n placeholder="auto (window.location.origin)"\n :value="myAddress"\n @input="myAddress = $el.value"\n style="font-family:monospace; font-size:12px;"\n />\n </div>\n\n <div style="display:flex; flex-direction:column; gap:6px;">\n <label style="font-size:12px; opacity:.6;">Authentication</label>\n <div style="display:flex; gap:2px; padding:2px; background:var(--okdb-app-surface); border-radius:8px; border:1px solid var(--okdb-app-border); align-self:flex-start;">\n <button\n type="button"\n @click="authMode=\'token\'"\n :style="authMode===\'token\' ? \'background:var(--okdb-app-surface-2);color:var(--okdb-accent);border-color:color-mix(in srgb, var(--okdb-accent) 27%, transparent);\' : \'background:transparent;border-color:transparent;opacity:.7;\'"\n style="padding:4px 14px;border-radius:6px;font-size:12px;">\n Join token\n </button>\n <button\n type="button"\n @click="authMode=\'userpass\'"\n :style="authMode===\'userpass\' ? \'background:var(--okdb-app-surface-2);color:var(--okdb-accent);border-color:color-mix(in srgb, var(--okdb-accent) 27%, transparent);\' : \'background:transparent;border-color:transparent;opacity:.7;\'"\n style="padding:4px 14px;border-radius:6px;font-size:12px;">\n Username / password\n </button>\n <button\n type="button"\n @click="authMode=\'none\'"\n :style="authMode===\'none\' ? \'background:var(--okdb-app-surface-2);color:var(--okdb-accent);border-color:color-mix(in srgb, var(--okdb-accent) 27%, transparent);\' : \'background:transparent;border-color:transparent;opacity:.7;\'"\n style="padding:4px 14px;border-radius:6px;font-size:12px;">\n None\n </button>\n </div>\n\n <if :="authMode === \'token\'">\n <input\n type="password"\n placeholder="Join token configured on the remote node"\n :value="token"\n @input="token = $el.value"\n @keydown.enter="submit()"\n style="font-family:monospace; font-size:12px;"\n />\n </if>\n\n <if :="authMode === \'userpass\'">\n <div style="display:flex; gap:8px;">\n <input\n type="text"\n placeholder="username"\n :value="username"\n @input="username = $el.value"\n @keydown.enter="submit()"\n style="font-family:monospace; font-size:12px; flex:1;"\n />\n <input\n type="password"\n placeholder="password"\n :value="password"\n @input="password = $el.value"\n @keydown.enter="submit()"\n style="font-family:monospace; font-size:12px; flex:1;"\n />\n </div>\n </if>\n\n <if :="authMode === \'none\'">\n <div style="font-size:11px; opacity:.5;">No credentials sent. Only works if the remote node is in open-access mode.</div>\n </if>\n </div>\n\n <label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; user-select:none;">\n <input type="checkbox" :checked="dataLink" @change="dataLink = $event.target.checked" />\n <span>Sync data immediately <span style="opacity:.5; font-size:11px;">(uncheck for topology-only link)</span></span>\n </label>\n\n <if :="error">\n <div style="color:var(--okdb-danger); font-size:13px; padding:6px 10px; background:color-mix(in srgb, var(--okdb-danger) 7%, transparent); border-radius:4px; border:1px solid color-mix(in srgb, var(--okdb-danger) 20%, transparent);">\n {{ error }}\n </div>\n </if>\n\n <div style="display:flex; gap:8px; justify-content:flex-end; padding-top:4px; border-top:1px solid var(--okdb-app-border);">\n <button @click="cancel()">Cancel</button>\n <button class="btn-primary" @click="submit()" :disabled="!canSubmit">\n {{ busy ? \'Connecting…\' : \'Connect\' }}\n </button>\n </div>\n\n</div>'};
@@ -1 +1 @@
1
- export default{tag:"sync-topology",unwrap:!0,context:e=>({w:900,h:500,layout:{cx:0,cy:0,r:0,pos:{}},_ro:null,_tick:null,now:Date.now(),selectedNodeId:null,selectedLinkKey:null,hoveredLinkKey:null,hoveredNodeId:null,lastUpdatedAt:null,autoRefresh:!0,autoRefreshIntervalMs:5e3,_autoRefreshTimer:null,loadingTopology:!1,editingLink:null,linkEditDir:"both",linkEditEnvs:null,linkEditEnabled:!0,savingLink:!1,panX:0,panY:0,zoom:1,_drag:null,_customPos:{},_svgEl:null,_groupEl:null,_applyTransform(){this._groupEl||(this._groupEl=this._svgEl?.querySelector(".topo-g")),this._groupEl&&this._groupEl.setAttribute("transform",`translate(${this.panX},${this.panY}) scale(${this.zoom})`)},_px2vb(){if(!this._svgEl)return{sx:1,sy:1};const e=this._svgEl.getBoundingClientRect();return{sx:this.w/(e.width||1),sy:this.h/(e.height||1)}},_onWheel(e){if(!this._svgEl)return;const t=this._svgEl.getBoundingClientRect(),{sx:i,sy:n}=this._px2vb(),s=(e.clientX-t.left)*i,o=(e.clientY-t.top)*n,l=e.deltaY<0?1.12:1/1.12,r=Math.max(.1,Math.min(8,this.zoom*l)),a=r/this.zoom;this.panX=s+(this.panX-s)*a,this.panY=o+(this.panY-o)*a,this.zoom=r,this._applyTransform()},_onSvgMousedown(e){if(0!==e.button)return;e.preventDefault();let t=null,i=e.target;for(;i&&i!==this._svgEl;){if(i.id&&i.id.startsWith("nd-")){t=i.id.slice(3);break}i=i.parentElement}if(t){const i=this._svgEl.getBoundingClientRect(),{sx:n,sy:s}=this._px2vb(),o=(e.clientX-i.left)*n,l=(e.clientY-i.top)*s,r=(o-this.panX)/this.zoom,a=(l-this.panY)/this.zoom,d=this._effectivePos(t)||{x:r,y:a};this._drag={type:"node",id:t,ox:r-d.x,oy:a-d.y}}else this._drag={type:"pan",sx:e.clientX,sy:e.clientY,px:this.panX,py:this.panY};this._svgEl&&(this._svgEl.style.cursor="grabbing")},_onMousemove(e){if(!this._drag)return;const{sx:t,sy:i}=this._px2vb();if("pan"===this._drag.type)this.panX=this._drag.px+(e.clientX-this._drag.sx)*t,this.panY=this._drag.py+(e.clientY-this._drag.sy)*i,this._applyTransform();else{const n=this._svgEl.getBoundingClientRect(),s=(e.clientX-n.left)*t,o=(e.clientY-n.top)*i,l=(s-this.panX)/this.zoom,r=(o-this.panY)/this.zoom;this._customPos={...this._customPos,[this._drag.id]:{x:l-this._drag.ox,y:r-this._drag.oy}},this._savePositions()}},_onMouseup(){this._drag=null,this._svgEl&&(this._svgEl.style.cursor="grab")},get externalSelectedNodeId(){return e.$attr.selected_node_id??null},get selfId(){return e.$attr.self_id},get topology(){return e.$attr.topology||{}},get availableEnvs(){return e.$attr.available_envs||[]},get envButtons(){const t=this.linkEditEnvs;return(e.$attr.available_envs||[]).map(e=>({name:e,style:null===t||Array.isArray(t)&&t.includes(e)?"border-color:var(--accent);color:var(--accent);background:var(--bg)":""}))},get dirButtons(){const e=this.editingLink;if(!e)return[];const t=e.lo.slice(0,6),i=e.hi.slice(0,6),n=this.linkEditDir;return[{canon:"both",label:t+" ⇄ "+i},{canon:"lo_to_hi",label:t+" → "+i},{canon:"hi_to_lo",label:i+" → "+t}].map(e=>({...e,style:e.canon===n?"border-color:var(--accent);color:var(--accent)":""}))},get nodes(){return Object.values(this.topology||{}).filter(e=>e&&null!=e.id).sort((e,t)=>String(e.id).localeCompare(String(t.id)))},get nodeList(){return this.nodes.map(e=>{const t=this._customPos[e.id]||this.layout.pos?.[e.id]||{x:this.w/2,y:this.h/2};return{...e,px:t.x,py:t.y}})},get links(){const e=this.topology||{},t=e[this.selfId]||Object.values(e).find(e=>null!=e?.links),i=t?.links||{},n=new Map,s=(e,t)=>({status:e.status||"idle",backlog:Math.max(0,Number(t??0)-Number(e.clock??0)),last_seen:e.last_seen??null,last_error:e.last_error??null});for(const t in e){const i=e[t];if(i?.peers)for(const o in i.peers){const l=t<o?t:o,r=t<o?o:t,a=l+":"+r;n.has(a)||n.set(a,{lo:l,hi:r,lo_pull:null,hi_pull:null});const d=n.get(a),c=i.peers[o];t===l?d.lo_pull=s(c,e[o]?.clock):d.hi_pull=s(c,e[o]?.clock)}}for(const e in i)if(i[e]&&!n.has(e)){const t=i[e];n.set(e,{lo:t.node_lo,hi:t.node_hi,lo_pull:null,hi_pull:null})}return[...n.entries()].map(([e,t])=>({key:e,lo:t.lo,hi:t.hi,link:i[e]||null,lo_pull:t.lo_pull,hi_pull:t.hi_pull}))},_linkConfig(e){const t=(this.topology||{})[this.selfId];return t?.links?.[e]||null},_linkHealth(e){if(e.link&&!e.link.enabled)return"disabled";const t=e.link?.direction??"both",i=[];"both"!==t&&"lo_to_hi"!==t||i.push(e.hi_pull),"both"!==t&&"hi_to_lo"!==t||i.push(e.lo_pull);const n=["error","lag-high","lag-med","behind","reconciling","stale","ok"];let s="stale";for(const e of i){const t=this._pullHealth(e);n.indexOf(t)<n.indexOf(s)&&(s=t)}return s},_pullHealth(e){if(!e)return"stale";if("reconciling"===e.status)return"reconciling";if(e.last_seen){const t=this.now-Number(e.last_seen);return e.backlog>50?"lag-high":e.backlog>5?"lag-med":e.backlog>0?"behind":t>3e4?"stale":"ok"}return e.last_error?"error":"stale"},_healthMarker:e=>"error"===e?"err":"reconciling"===e?"rec":"lag-high"===e?"lag-high":"lag-med"===e||"behind"===e||"stale"===e?"lag-med":"ok",_linkDirLabel(e){if(!e.link)return e.lo.slice(0,6)+" ⇄ "+e.hi.slice(0,6)+" (mesh)";if(!e.link.enabled)return"disabled";const t=e.lo.slice(0,6),i=e.hi.slice(0,6);switch(e.link.direction){case"both":return t+" ⇄ "+i;case"lo_to_hi":return t+" → "+i;case"hi_to_lo":return i+" → "+t;default:return e.link.direction}},get selection(){if(this.selectedLinkKey){const e=this.links.find(e=>e.key===this.selectedLinkKey)||null;if(e)return{type:"link",data:e}}if(this.selectedNodeId){const e=this.topology[this.selectedNodeId]||null;return e?{type:"node",id:this.selectedNodeId,data:e}:null}return null},get freshnessLabel(){if(!this.lastUpdatedAt)return"never";const e=this.now-this.lastUpdatedAt;return e<1500?"now":e<5e3?"few seconds ago":e<15e3?"recent":Math.floor(e/1e3)+"s ago"},get freshnessClass(){if(!this.lastUpdatedAt)return"fresh-stale";const e=this.now-this.lastUpdatedAt;return e<5e3?"fresh-now":e<15e3?"fresh-ok":"fresh-stale"},_recomputeLayout(){const e=this.nodes,t=this.w/2,i=this.h/2,n=Math.max(80,Math.min(t,i)-this.nodeRadius()-60),s={},o=e.length||1;e.forEach((e,l)=>{if(1===o)return void(s[e.id]={x:t,y:i});const r=2===o?0===l?Math.PI:0:l/o*Math.PI*2-Math.PI/2;s[e.id]={x:t+n*Math.cos(r),y:i+n*Math.sin(r)}}),this.layout={cx:t,cy:i,r:n,pos:s}},_measure(){const t=(this._svgEl||e.$el).getBoundingClientRect();this.w=Math.max(300,t.width||900),this.h=Math.max(200,t.height||500),this._recomputeLayout()},_effectivePos(e){return this._customPos[e]||this.layout.pos?.[e]||null},fitView(){this._measure();const e=this.nodes;if(!e.length)return;let t=1/0,i=1/0,n=-1/0,s=-1/0;for(const o of e){const e=this._effectivePos(o.id);e&&(t=Math.min(t,e.x),i=Math.min(i,e.y),n=Math.max(n,e.x),s=Math.max(s,e.y))}if(!isFinite(t))return;const o=this.nodeRadius()+60;t-=o,i-=o,n+=o,s+=o;const l=n-t,r=s-i,a=.88*Math.min(this.w/l,this.h/r,3);this.zoom=a,this.panX=this.w/2-(t+l/2)*a,this.panY=this.h/2-(i+r/2)*a,this._applyTransform()},resetLayout(){this._customPos={};try{localStorage.removeItem("okdb-topo-pos")}catch{}this.fitView()},_savePositions(){try{localStorage.setItem("okdb-topo-pos",JSON.stringify(this._customPos))}catch{}},_loadPositions(){try{const e=localStorage.getItem("okdb-topo-pos");e&&(this._customPos=JSON.parse(e))}catch{}},nodeRadius:()=>22,linkPath(e){const t=this._effectivePos(e.lo),i=this._effectivePos(e.hi);if(!t||!i)return"";const n=i.x-t.x,s=i.y-t.y,o=Math.sqrt(n*n+s*s)||1,l=this.nodeRadius();return"M "+(t.x+n/o*l)+" "+(t.y+s/o*l)+" L "+(i.x-n/o*l)+" "+(i.y-s/o*l)},reconciling:!1,reconcilingTarget:null,async refreshTopology(){if(!this.loadingTopology){this.loadingTopology=!0;try{const t=await fetch("/api/sync/topology"),i=await t.json(),n=i.data??i;e.$attr.topology=n.topology??n,e.$attr.available_envs=n.availableEnvs??[],this.lastUpdatedAt=Date.now()}catch(e){console.error("refresh failed",e)}finally{this.loadingTopology=!1}}},async reconcileAll(){if(!this.reconciling){this.reconciling=!0,this.reconcilingTarget="all";try{await fetch("/api/sync/reconcile-all",{method:"POST"}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}}},async reconcileNode(e){if(!this.reconciling){this.reconciling=!0,this.reconcilingTarget=e;try{e===this.selfId?await fetch("/api/sync/reconcile",{method:"POST"}):await fetch(this.topology[e]?.address+"/api/sync/reconcile",{method:"POST"}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}}},async reconcileLink(e){if(this.reconciling)return;this.reconciling=!0,this.reconcilingTarget=e.key;const t=e.lo===this.selfId?e.hi:e.lo;try{await fetch("/api/sync/reconcile-peer",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:t})}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}},openLinkEditor(e){this.editingLink=e,this.linkEditDir=e.link?.direction??"both",this.linkEditEnvs=e.link?.envs??null,this.linkEditEnabled=!1!==e.link?.enabled},closeLinkEditor(){this.editingLink=null},toggleLinkEnv(e){if(null===this.linkEditEnvs)this.linkEditEnvs=this.availableEnvs.filter(t=>t!==e);else{const t=new Set(this.linkEditEnvs);t.has(e)?t.delete(e):t.add(e);const i=[...t];this.linkEditEnvs=i.length===this.availableEnvs.length?null:i}},async saveLink(){if(!this.editingLink||this.savingLink)return;this.savingLink=!0;const e=this.editingLink;try{const t=await fetch("/api/sync/links/"+e.lo+"/"+e.hi,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({direction:this.linkEditDir,envs:this.linkEditEnvs,enabled:this.linkEditEnabled})});if(!t.ok){const e=await t.json();throw new Error(e?.error?.message||"Save failed")}this.closeLinkEditor(),await this.refreshTopology()}catch(e){alert(e.message)}finally{this.savingLink=!1}},async deleteLink(){if(!this.editingLink||this.savingLink)return;if(!confirm("Remove this data link? Nodes stay connected but data will stop syncing."))return;this.savingLink=!0;const e=this.editingLink;try{await fetch("/api/sync/links/"+e.lo+"/"+e.hi,{method:"DELETE"}),this.closeLinkEditor(),await this.refreshTopology()}catch(e){alert(e.message)}finally{this.savingLink=!1}},deselect(){this.selectedNodeId=null,this.selectedLinkKey=null,this.hoveredLinkKey=null,this.hoveredNodeId=null,this.editingLink=null,e.$dispatch?.("select",null),e.$dispatch?.("selectLink",null)},pickNode(t){this.selectedNodeId=t,this.selectedLinkKey=null,this.editingLink=null,e.$dispatch?.("select",t),e.$dispatch?.("selectLink",null)},pickLink(t){const i=this.links.find(e=>e.key===t)||null;if(i&&(!this.selectedNodeId||i.lo===this.selectedNodeId||i.hi===this.selectedNodeId)){if(this.selectedLinkKey===t)return this.selectedLinkKey=null,this.editingLink=null,void e.$dispatch?.("selectLink",null);this.selectedLinkKey=t,this.selectedNodeId||(this.selectedNodeId=i.lo,e.$dispatch?.("select",this.selectedNodeId)),e.$dispatch?.("selectLink",t)}},async copy(e){try{await navigator.clipboard.writeText(String(e??""))}catch(e){}},_sPointerEventsFor(e){return this.selectedNodeId&&e.lo!==this.selectedNodeId&&e.hi!==this.selectedNodeId?"pointer-events:none":"pointer-events:stroke;cursor:pointer"},_sEnabledBadge:e=>e?"color:var(--ok)":"color:var(--dis);opacity:0.7",_sEnabledBtn:e=>e?"border-color:var(--ok);color:var(--ok)":"border-color:var(--dis);color:var(--dis)",nodeHealth(e){const t=this.links.filter(t=>t.lo===e||t.hi===e);if(!t.length)return"ok";const i=t.map(e=>this._linkHealth(e));return i.includes("error")?"error":i.includes("lag-high")?"critical":i.includes("lag-med")?"lagging":i.includes("behind")?"behind":i.every(e=>"stale"===e||"disabled"===e)?"stale":"ok"},isLinkInteractable(e){return!this.selectedNodeId||e.lo===this.selectedNodeId||e.hi===this.selectedNodeId},isActiveLink(e){return this.selectedLinkKey===e.key||this.hoveredLinkKey===e.key&&this.isLinkInteractable(e)},nodeClass(e){let t="nd";const i=this.selectedNodeId,n=this.hoveredNodeId;if(i&&i!==e.id&&(t+=" nd-dim"),i===e.id&&(t+=" nd-sel"),this.selectedLinkKey){const n=this.links.find(e=>e.key===this.selectedLinkKey);!n||n.lo!==e.id&&n.hi!==e.id||i===e.id||(t+=" nd-sel2")}i||n!==e.id||(t+=" nd-hover");const s=this.nodeHealth(e.id);return"error"===s?t+=" nd-err":"critical"===s?t+=" nd-critical":"lagging"===s?t+=" nd-lag":"behind"===s?t+=" nd-behind":"stale"===s&&(t+=" nd-stale"),t},linkClass(e){let t="ln";const i=this.selectedLinkKey===e.key,n=this.hoveredLinkKey===e.key&&this.isLinkInteractable(e);switch(this.isLinkInteractable(e)||i||(t+=" ln-dim"),i?t+=" ln-selected":n&&(t+=" ln-hover"),this._linkHealth(e)){case"disabled":t+=" ln-disabled";break;case"error":t+=" ln-err";break;case"reconciling":t+=" ln-rec";break;case"lag-high":t+=" ln-lag-high";break;case"lag-med":t+=" ln-lag-med";break;case"behind":t+=" ln-behind-light";break;case"stale":t+=" ln-stale";break;default:t+=" ln-ok"}return t},linkMarker(e){const t=e.link?.direction??"both";return"both"!==t&&"lo_to_hi"!==t?"":e.link&&!e.link.enabled?"url(#arrow-disabled)":"url(#arrow-"+this._healthMarker(this._pullHealth(e.hi_pull))+")"},linkStartMarker(e){const t=e.link?.direction??"both";return"both"!==t&&"hi_to_lo"!==t?"":e.link&&!e.link.enabled?"url(#arrow-rev-disabled)":"url(#arrow-rev-"+this._healthMarker(this._pullHealth(e.lo_pull))+")"}}),mount(e){this._svgEl=e.$el.querySelector("svg"),this._groupEl=this._svgEl?.querySelector(".topo-g"),this._svgEl&&(this._svgEl.style.cursor="grab"),this._measure(),this._loadPositions(),this._ro=new ResizeObserver(()=>this.fitView()),this._ro.observe(e.$el),this._tick=setInterval(()=>{this.now=Date.now()},1e3),this.refreshTopology().then(()=>this.fitView()),this._wheelCb=e=>{e.preventDefault(),this._onWheel(e)},this._svgEl&&this._svgEl.addEventListener("wheel",this._wheelCb,{passive:!1}),this._mousedownCb=e=>this._onSvgMousedown(e),this._svgEl&&this._svgEl.addEventListener("mousedown",this._mousedownCb),this._mousemoveCb=e=>this._onMousemove(e),this._mouseupCb=()=>this._onMouseup(),window.addEventListener("mousemove",this._mousemoveCb),window.addEventListener("mouseup",this._mouseupCb),this._visibilityHandler=()=>{document.hidden||this.refreshTopology()},document.addEventListener("visibilitychange",this._visibilityHandler),this.autoRefresh&&(this._autoRefreshTimer=setInterval(()=>this.refreshTopology(),this.autoRefreshIntervalMs)),this.selectedNodeId=this.externalSelectedNodeId,e.$watch(()=>e.$attr.selected_node_id,e=>{this.selectedNodeId=e??null,e||(this.selectedLinkKey=null)})},unmount(){this._ro?.disconnect(),clearInterval(this._tick),clearInterval(this._autoRefreshTimer),this._svgEl&&(this._svgEl.removeEventListener("wheel",this._wheelCb),this._svgEl.removeEventListener("mousedown",this._mousedownCb)),window.removeEventListener("mousemove",this._mousemoveCb),window.removeEventListener("mouseup",this._mouseupCb),document.removeEventListener("visibilitychange",this._visibilityHandler)},template:'<div style="--bg:var(--okdb-app-bg-deep); --panel:var(--okdb-app-surface); --panel2:var(--okdb-app-bg-deep); --border:var(--okdb-app-border); --border2:var(--okdb-app-surface-2);\n --text:var(--okdb-app-fg); --muted:var(--okdb-app-fg-muted); --accent:var(--okdb-accent); --ok:var(--okdb-success); --rec:var(--okdb-accent);\n --warn:var(--okdb-warning); --bad:var(--okdb-danger); --err:var(--okdb-danger); --dis:var(--okdb-app-fg-subtle);\n border:1px solid var(--border); border-radius:12px; background:var(--bg); color:var(--text);\n display:flex; flex-direction:column; flex:1; position:relative; overflow:hidden;\n font-family: ui-monospace, monospace;"><div style="padding:12px 14px;display:flex;justify-content:space-between;border-bottom:1px solid var(--border2);background:var(--panel);align-items:center;"><div style="display:flex;align-items:center;gap:10px;"><div style="font-weight:700;letter-spacing:0.8px;color:var(--accent);">NETWORK_TOPOLOGY</div><button @click="reconcileAll()" :disabled="reconciling" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;"><span style="display:flex;align-items:center;gap:6px;"><span :style="reconcilingTarget===\'all\'?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===\'all\'?\'RECONCILING\':\'RECONCILE ALL\'}}</span></button> <button @click="refreshTopology()" :disabled="loadingTopology" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;">REFRESH</button> <button @click="fitView()" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;" title="Fit all nodes into view">FIT</button> <button @click="resetLayout()" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;" title="Reset node positions">RESET</button> <span :style="{opacity:loadingTopology?1:0}" class="spinner"/></div><div style="display:flex;align-items:center;gap:12px;font-size:11px;"><span :class="freshnessClass">updated: {{freshnessLabel}}</span> <span style="opacity:0.75;">{{nodes.length}} NODES // {{links.length}} LINKS</span></div></div><div style="flex:1;position:relative;"><svg :viewBox="\'0 0 \'+w+\' \'+h" style="width:100%;height:100%;"><rect :width="w" :height="h" fill="transparent" style="pointer-events:all" @click="deselect()"/><defs><marker id="arrow-ok" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--ok)"/></marker><marker id="arrow-rec" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--rec)"/></marker><marker id="arrow-err" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--err)"/></marker><marker id="arrow-lag-med" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--warn)"/></marker><marker id="arrow-lag-high" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--bad)"/></marker><marker id="arrow-disabled" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--dis)"/></marker><marker id="arrow-rev-ok" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--ok)"/></marker><marker id="arrow-rev-rec" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--rec)"/></marker><marker id="arrow-rev-err" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--err)"/></marker><marker id="arrow-rev-lag-med" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--warn)"/></marker><marker id="arrow-rev-lag-high" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--bad)"/></marker><marker id="arrow-rev-disabled" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--dis)"/></marker></defs><style>.ln { fill:none; stroke-width:1.9; opacity:0.70; transition:opacity 0.14s,stroke-width 0.14s,filter 0.14s; }\n .ln-ok { stroke:var(--ok); }\n .ln-behind-light { stroke:var(--okdb-info); opacity:0.8; }\n .ln-rec { stroke:var(--rec); stroke-dasharray:4 3; animation:dash 1.1s linear infinite; filter:drop-shadow(0 0 4px rgba(77,163,255,0.5)); }\n .ln-lag-med { stroke:var(--warn); }\n .ln-lag-high { stroke:var(--bad); }\n .ln-err { stroke:var(--err); }\n .ln-stale { stroke:var(--okdb-app-fg-subtle); opacity:0.5; }\n .ln-disabled { stroke:var(--dis); stroke-dasharray:6 4; opacity:0.45; }\n .ln-dim { opacity:0.08; filter:none !important; }\n .ln-hover { stroke-width:3.0; opacity:1; }\n .ln-selected { stroke:var(--accent) !important; stroke-width:5.2 !important; opacity:1 !important;\n filter:drop-shadow(0 0 4px rgba(77,163,255,0.9)) drop-shadow(0 0 10px rgba(77,163,255,0.35)); }\n\n .nd circle { fill:var(--bg); stroke:var(--border); stroke-width:2; transition:stroke 0.14s,stroke-width 0.14s,filter 0.14s,opacity 0.14s; }\n .nd text { fill:var(--muted); font-size:11px; pointer-events:none; }\n .nd-hover circle { stroke:rgba(77,163,255,0.8); }\n .nd-sel circle { stroke:var(--accent); stroke-width:3.4; filter:drop-shadow(0 0 4px rgba(77,163,255,0.75)) drop-shadow(0 0 10px rgba(77,163,255,0.25)); }\n .nd-sel2 circle { stroke:rgba(77,163,255,0.75); stroke-width:3.0; filter:drop-shadow(0 0 6px rgba(77,163,255,0.25)); }\n .nd-dim { opacity:0.22; }\n .nd-err circle { stroke:var(--err); }\n .nd-stale circle { stroke:var(--okdb-app-fg-subtle); stroke-width:2; opacity:0.7; }\n .nd-behind circle { stroke:var(--okdb-info); stroke-width:2.5; }\n .nd-lag circle { stroke:var(--warn); stroke-width:3; }\n .nd-critical circle { stroke:var(--bad); stroke-width:3.5; filter:drop-shadow(0 0 6px rgba(255,77,77,0.5)); }\n .fresh-now { color:var(--ok); }\n .fresh-ok { color:var(--okdb-info); }\n .fresh-stale { color:var(--warn); }\n .spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(77,163,255,0.2); border-top:2px solid var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; }\n @keyframes dash { to { stroke-dashoffset:-14; } }\n @keyframes spin { to { transform:rotate(360deg); } }</style><g class="topo-g"><for :="links" let:l><if :="!isActiveLink(l)"><g><path :d="linkPath(l)" stroke="transparent" stroke-width="14" fill="none" :style="_sPointerEventsFor(l)" @mouseenter="isLinkInteractable(l)?(hoveredLinkKey=l.key):null" @mouseleave="hoveredLinkKey=null" @click="pickLink(l.key)"/><path :d="linkPath(l)" :class="linkClass(l)" :marker-end="linkMarker(l)" :marker-start="linkStartMarker(l)"><title>{{l.lo.slice(0,8)}} ↔ {{l.hi.slice(0,8)}} direction: {{l.link ? l.link.direction : \'mesh\'}} enabled: {{l.link ? l.link.enabled : true}} envs: {{l.link && l.link.envs ? l.link.envs.join(\', \') : \'all\'}}</title></path></g></if></for><for :="nodeList" let:n><g :id="\'nd-\'+n.id" :transform="\'translate(\'+n.px+\',\'+n.py+\')\'" :class="nodeClass(n)" @mouseenter="hoveredNodeId=n.id" @mouseleave="hoveredNodeId=null" @click="pickNode(n.id)"><circle :r="nodeRadius()"/><text text-anchor="middle" dy="4">{{n.id.slice(0,6)}}</text><title>{{n.id}} clock: {{n.clock ?? \'--\'}} addr: {{n.address ?? \'—\'}}</title></g></for><for :="links" let:l><if :="isActiveLink(l)"><g><path :d="linkPath(l)" stroke="transparent" stroke-width="16" fill="none" :style="_sPointerEventsFor(l)" @mouseenter="isLinkInteractable(l)?(hoveredLinkKey=l.key):null" @mouseleave="hoveredLinkKey=null" @click="pickLink(l.key)"/><path :d="linkPath(l)" :class="linkClass(l)" :marker-end="linkMarker(l)" :marker-start="linkStartMarker(l)"/></g></if></for></g></svg><if :="selection || editingLink"><div style="position:absolute;top:18px;right:18px;width:320px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px;box-shadow:0 16px 34px rgba(0,0,0,0.45);border-left:4px solid var(--accent);"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><span style="font-size:10px;background:var(--bg);padding:3px 7px;border-radius:6px;color:var(--accent);letter-spacing:0.6px;">{{editingLink ? \'LINK_CONFIG\' : selection.type.toUpperCase()}} </span><span @click="editingLink ? closeLinkEditor() : deselect()" style="cursor:pointer;opacity:0.7;font-size:16px;line-height:12px;">×</span></div><if :="!editingLink && selection && selection.type === \'node\'"><div style="font-size:13px;"><div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;"><div style="color:var(--text);font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{selection.id}}</div><button @click="copy(selection.id)" style="border:1px solid var(--border);background:var(--panel2);color:var(--muted);font-size:11px;padding:4px 8px;border-radius:8px;cursor:pointer;">copy</button></div><div style="display:grid;grid-template-columns:92px 1fr;row-gap:8px;column-gap:10px;"><div style="opacity:0.65;">Clock</div><div>{{selection.data.clock??\'--\'}}</div><div style="opacity:0.65;">Addr</div><div style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{selection.data.address||\'N/A\'}}</div><div style="opacity:0.65;">Status</div><div :style="selection.data.error?\'color:var(--err)\':\'color:var(--ok)\'">{{selection.data.error?(\'ERR: \'+selection.data.error):\'OK\'}}</div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Tags</div><div style="display:flex;flex-wrap:wrap;gap:6px;"><if :="!(selection.data.tags || []).length"><span style="font-size:11px;opacity:0.45;">No tags</span></if><for :="selection.data.tags || []" let:tag><span style="font-size:10px;padding:2px 8px;border-radius:999px;border:1px solid var(--okdb-accent-soft-border);color:var(--okdb-accent);background:var(--okdb-accent-soft-bg);">{{tag}}</span></for></div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Available envs</div><div style="font-size:11px;color:var(--muted);line-height:1.5;word-break:break-word;">{{(selection.data.availableEnvs || []).join(\', \') || \'—\'}}</div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Outbound peers</div><div style="display:flex;flex-wrap:wrap;gap:6px;"><for :="Object.keys(selection.data.peers||{})" let:pid><span style="font-size:11px;padding:3px 7px;border:1px solid var(--border);background:var(--panel2);border-radius:999px;color:var(--muted);">{{pid.slice(0,8)}}</span></for></div></div><div style="margin-top:14px;"><button @click="reconcileNode(selection.id)" :disabled="reconciling" style="width:100%;box-sizing:border-box;padding:6px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:6px;"><span :style="reconcilingTarget===selection.id?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===selection.id?\'RECONCILING\':\'RECONCILE NODE\'}}</span></button></div></div></if><if :="!editingLink && selection && selection.type === \'link\'"><div style="font-size:13px;"><div style="margin-bottom:12px;"><div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"><span style="font-family:monospace;font-size:12px;font-weight:700;">{{selection.data.lo.slice(0,8)}}</span> <span style="font-size:16px;color:var(--accent);">{{selection.data.link ? (selection.data.link.direction === \'lo_to_hi\' ? \'→\' : selection.data.link.direction === \'hi_to_lo\' ? \'←\' : \'⇄\') : \'⇄\'}} </span><span style="font-family:monospace;font-size:12px;font-weight:700;">{{selection.data.hi.slice(0,8)}}</span><if :="selection.data.link"><span style="font-size:11px;margin-left:4px;" :style="_sEnabledBadge(selection.data.link.enabled)">{{selection.data.link.enabled ? \'● on\' : \'○ off\'}}</span></if></div><if :="selection.data.link && selection.data.link.envs"><div style="font-size:11px;opacity:0.45;">envs: {{selection.data.link.envs.join(\', \')}}</div></if><if :="!selection.data.link"><div style="font-size:11px;opacity:0.45;">no link config — mesh (legacy)</div></if></div><div style="border:1px solid var(--border2);border-radius:8px;overflow:hidden;margin-bottom:12px;font-size:11px;"><if :="!selection.data.link || selection.data.link.direction === \'both\' || selection.data.link.direction === \'lo_to_hi\'"><div style="padding:9px 11px;border-bottom:1px solid var(--bg);"><div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;"><span style="font-family:monospace;opacity:0.7;">{{selection.data.lo.slice(0,6)}}</span> <span style="color:var(--accent);">→</span> <span style="font-family:monospace;opacity:0.7;">{{selection.data.hi.slice(0,6)}}</span> <span style="opacity:0.4;">(hi receives from lo)</span></div><div style="display:grid;grid-template-columns:70px 1fr;row-gap:4px;gap:6px;opacity:0.85;"><span style="opacity:0.5;">status</span> <span>{{selection.data.hi_pull ? selection.data.hi_pull.status : \'—\'}}</span> <span style="opacity:0.5;">backlog</span> <span>{{selection.data.hi_pull ? selection.data.hi_pull.backlog : \'—\'}}</span> <span style="opacity:0.5;">last seen</span> <span>{{selection.data.hi_pull && selection.data.hi_pull.last_seen ? (Math.round((now - selection.data.hi_pull.last_seen)/1000) + \'s ago\') : \'—\'}}</span><if :="selection.data.hi_pull && selection.data.hi_pull.last_error"><span style="opacity:0.5;">error</span> <span style="color:var(--err);">{{selection.data.hi_pull.last_error}}</span></if></div></div></if><if :="!selection.data.link || selection.data.link.direction === \'both\' || selection.data.link.direction === \'hi_to_lo\'"><div style="padding:9px 11px;"><div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;"><span style="font-family:monospace;opacity:0.7;">{{selection.data.hi.slice(0,6)}}</span> <span style="color:var(--accent);">→</span> <span style="font-family:monospace;opacity:0.7;">{{selection.data.lo.slice(0,6)}}</span> <span style="opacity:0.4;">(lo receives from hi)</span></div><div style="display:grid;grid-template-columns:70px 1fr;row-gap:4px;gap:6px;opacity:0.85;"><span style="opacity:0.5;">status</span> <span>{{selection.data.lo_pull ? selection.data.lo_pull.status : \'—\'}}</span> <span style="opacity:0.5;">backlog</span> <span>{{selection.data.lo_pull ? selection.data.lo_pull.backlog : \'—\'}}</span> <span style="opacity:0.5;">last seen</span> <span>{{selection.data.lo_pull && selection.data.lo_pull.last_seen ? (Math.round((now - selection.data.lo_pull.last_seen)/1000) + \'s ago\') : \'—\'}}</span><if :="selection.data.lo_pull && selection.data.lo_pull.last_error"><span style="opacity:0.5;">error</span> <span style="color:var(--err);">{{selection.data.lo_pull.last_error}}</span></if></div></div></if></div><div style="display:flex;flex-direction:column;gap:7px;"><button @click="reconcileLink(selection.data)" :disabled="reconciling" style="width:100%;box-sizing:border-box;padding:7px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:6px;"><span :style="reconcilingTarget===selection.data.key?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===selection.data.key ? \'Reconciling…\' : \'Reconcile\'}}</span></button> <button @click="openLinkEditor(selection.data)" style="width:100%;box-sizing:border-box;padding:7px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:12px;">✎ Configure Link</button></div></div></if><if :="editingLink"><div style="font-size:13px;"><div style="margin-bottom:14px;"><div style="font-family:monospace;font-size:11px;opacity:0.5;margin-bottom:2px;">link id</div><div style="font-family:monospace;font-size:11px;">{{editingLink.key}}</div></div><div style="margin-bottom:14px;"><div style="font-size:11px;opacity:0.5;margin-bottom:7px;">Direction</div><div style="display:flex;flex-direction:column;gap:5px;"><for :="dirButtons" let:btn><button @click="linkEditDir = btn.canon" :style="btn.style" style="width:100%;box-sizing:border-box;padding:7px 10px;border-radius:7px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:12px;font-family:monospace;text-align:left;">{{btn.label}}</button></for></div></div><if :="availableEnvs.length"><div style="margin-bottom:14px;"><div style="font-size:11px;opacity:0.5;margin-bottom:7px;">Data envs — {{linkEditEnvs === null ? \'all\' : linkEditEnvs.length + \' selected\'}}</div><div style="display:flex;flex-wrap:wrap;gap:5px;"><for :="envButtons" let:btn><button @click="toggleLinkEnv(btn.name)" :style="btn.style" style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:11px;">{{btn.name}}</button></for></div></div></if><div style="margin-bottom:16px;display:flex;align-items:center;gap:10px;"><span style="font-size:11px;opacity:0.5;">Enabled</span> <button @click="linkEditEnabled=!linkEditEnabled" :style="_sEnabledBtn(linkEditEnabled)" style="padding:4px 12px;border-radius:6px;border:1px solid;background:var(--panel2);cursor:pointer;font-size:11px;">{{linkEditEnabled ? \'● ON\' : \'○ OFF\'}}</button></div><div style="display:flex;gap:8px;"><button @click="saveLink()" :disabled="savingLink" style="flex:1;padding:8px;border-radius:8px;border:1px solid var(--accent);background:rgba(77,163,255,0.1);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:5px;"><span :style="savingLink?\'display:inline-block\':\'display:none\'" class="spinner"/> Save</span></button> <button @click="deleteLink()" :disabled="savingLink" style="padding:8px 14px;border-radius:8px;border:1px solid #3d1a1a;background:rgba(255,59,48,0.08);color:var(--err);cursor:pointer;font-size:12px;">Remove</button></div></div></if></div></if></div><div style="padding:9px 14px;background:var(--bg);border-top:1px solid var(--border2);display:flex;gap:16px;font-size:11px;align-items:center;flex-wrap:wrap;opacity:0.9;"><span><span style="color:var(--ok);">●</span> synced</span> <span><span style="color:var(--okdb-info);">●</span> behind</span> <span><span style="color:var(--rec);">●</span> reconciling</span> <span><span style="color:var(--warn);">●</span> lag</span> <span><span style="color:var(--err);">●</span> error</span> <span><span style="color:var(--dis);">●</span> disabled</span> <span style="opacity:0.4;">⇄ both → send ← receive -- disabled (click to inspect)</span></div></div>'};
1
+ import e from"../modals/connect-peer-modal.ok.js";export default{tag:"sync-topology",unwrap:!0,dependencies:[e],context:e=>({w:900,h:500,layout:{cx:0,cy:0,r:0,pos:{}},_ro:null,_tick:null,now:Date.now(),selectedNodeId:null,selectedLinkKey:null,hoveredLinkKey:null,hoveredNodeId:null,lastUpdatedAt:null,autoRefresh:!0,autoRefreshIntervalMs:5e3,_autoRefreshTimer:null,loadingTopology:!1,editingLink:null,linkEditDir:"both",linkEditEnvs:null,linkEditEnabled:!0,savingLink:!1,panX:0,panY:0,zoom:1,_drag:null,_customPos:{},_svgEl:null,_groupEl:null,_applyTransform(){this._groupEl||(this._groupEl=this._svgEl?.querySelector(".topo-g")),this._groupEl&&this._groupEl.setAttribute("transform",`translate(${this.panX},${this.panY}) scale(${this.zoom})`)},_px2vb(){if(!this._svgEl)return{sx:1,sy:1};const e=this._svgEl.getBoundingClientRect();return{sx:this.w/(e.width||1),sy:this.h/(e.height||1)}},_onWheel(e){if(!this._svgEl)return;const t=this._svgEl.getBoundingClientRect(),{sx:i,sy:n}=this._px2vb(),s=(e.clientX-t.left)*i,o=(e.clientY-t.top)*n,l=e.deltaY<0?1.12:1/1.12,r=Math.max(.1,Math.min(8,this.zoom*l)),a=r/this.zoom;this.panX=s+(this.panX-s)*a,this.panY=o+(this.panY-o)*a,this.zoom=r,this._applyTransform()},_onSvgMousedown(e){if(0!==e.button)return;e.preventDefault();let t=null,i=e.target;for(;i&&i!==this._svgEl;){if(i.id&&i.id.startsWith("nd-")){t=i.id.slice(3);break}i=i.parentElement}if(t){const i=this._svgEl.getBoundingClientRect(),{sx:n,sy:s}=this._px2vb(),o=(e.clientX-i.left)*n,l=(e.clientY-i.top)*s,r=(o-this.panX)/this.zoom,a=(l-this.panY)/this.zoom,d=this._effectivePos(t)||{x:r,y:a};this._drag={type:"node",id:t,ox:r-d.x,oy:a-d.y}}else this._drag={type:"pan",sx:e.clientX,sy:e.clientY,px:this.panX,py:this.panY};this._svgEl&&(this._svgEl.style.cursor="grabbing")},_onMousemove(e){if(!this._drag)return;const{sx:t,sy:i}=this._px2vb();if("pan"===this._drag.type)this.panX=this._drag.px+(e.clientX-this._drag.sx)*t,this.panY=this._drag.py+(e.clientY-this._drag.sy)*i,this._applyTransform();else{const n=this._svgEl.getBoundingClientRect(),s=(e.clientX-n.left)*t,o=(e.clientY-n.top)*i,l=(s-this.panX)/this.zoom,r=(o-this.panY)/this.zoom;this._customPos={...this._customPos,[this._drag.id]:{x:l-this._drag.ox,y:r-this._drag.oy}},this._savePositions()}},_onMouseup(){this._drag=null,this._svgEl&&(this._svgEl.style.cursor="grab")},get externalSelectedNodeId(){return e.$attr.selected_node_id??null},get selfId(){return e.$attr.self_id},get topology(){return e.$attr.topology||{}},get availableEnvs(){return e.$attr.available_envs||[]},get envButtons(){const t=this.linkEditEnvs;return(e.$attr.available_envs||[]).map(e=>({name:e,style:null===t||Array.isArray(t)&&t.includes(e)?"border-color:var(--accent);color:var(--accent);background:var(--bg)":""}))},get dirButtons(){const e=this.editingLink;if(!e)return[];const t=e.lo.slice(0,6),i=e.hi.slice(0,6),n=this.linkEditDir;return[{canon:"both",label:t+" ⇄ "+i},{canon:"lo_to_hi",label:t+" → "+i},{canon:"hi_to_lo",label:i+" → "+t}].map(e=>({...e,style:e.canon===n?"border-color:var(--accent);color:var(--accent)":""}))},get nodes(){return Object.values(this.topology||{}).filter(e=>e&&null!=e.id).sort((e,t)=>String(e.id).localeCompare(String(t.id)))},get nodeList(){return this.nodes.map(e=>{const t=this._customPos[e.id]||this.layout.pos?.[e.id]||{x:this.w/2,y:this.h/2};return{...e,px:t.x,py:t.y}})},get links(){const e=this.topology||{},t=e[this.selfId]||Object.values(e).find(e=>null!=e?.links),i=t?.links||{},n=new Map,s=(e,t)=>({status:e.status||"idle",backlog:Math.max(0,Number(t??0)-Number(e.clock??0)),last_seen:e.last_seen??null,last_error:e.last_error??null});for(const t in e){const i=e[t];if(i?.peers)for(const o in i.peers){const l=t<o?t:o,r=t<o?o:t,a=l+":"+r;n.has(a)||n.set(a,{lo:l,hi:r,lo_pull:null,hi_pull:null});const d=n.get(a),c=i.peers[o];t===l?d.lo_pull=s(c,e[o]?.clock):d.hi_pull=s(c,e[o]?.clock)}}for(const e in i)if(i[e]&&!n.has(e)){const t=i[e];n.set(e,{lo:t.node_lo,hi:t.node_hi,lo_pull:null,hi_pull:null})}return[...n.entries()].map(([e,t])=>({key:e,lo:t.lo,hi:t.hi,link:i[e]||null,lo_pull:t.lo_pull,hi_pull:t.hi_pull}))},_linkConfig(e){const t=(this.topology||{})[this.selfId];return t?.links?.[e]||null},_linkHealth(e){if(e.link&&!e.link.enabled)return"disabled";const t=e.link?.direction??"both",i=[];"both"!==t&&"lo_to_hi"!==t||i.push(e.hi_pull),"both"!==t&&"hi_to_lo"!==t||i.push(e.lo_pull);const n=["error","lag-high","lag-med","behind","reconciling","stale","ok"];let s="stale";for(const e of i){const t=this._pullHealth(e);n.indexOf(t)<n.indexOf(s)&&(s=t)}return s},_pullHealth(e){if(!e)return"stale";if("reconciling"===e.status)return"reconciling";if(e.last_seen){const t=this.now-Number(e.last_seen);return e.backlog>50?"lag-high":e.backlog>5?"lag-med":e.backlog>0?"behind":t>3e4?"stale":"ok"}return e.last_error?"error":"stale"},_healthMarker:e=>"error"===e?"err":"reconciling"===e?"rec":"lag-high"===e?"lag-high":"lag-med"===e||"behind"===e||"stale"===e?"lag-med":"ok",_linkDirLabel(e){if(!e.link)return e.lo.slice(0,6)+" ⇄ "+e.hi.slice(0,6)+" (mesh)";if(!e.link.enabled)return"disabled";const t=e.lo.slice(0,6),i=e.hi.slice(0,6);switch(e.link.direction){case"both":return t+" ⇄ "+i;case"lo_to_hi":return t+" → "+i;case"hi_to_lo":return i+" → "+t;default:return e.link.direction}},get selection(){if(this.selectedLinkKey){const e=this.links.find(e=>e.key===this.selectedLinkKey)||null;if(e)return{type:"link",data:e}}if(this.selectedNodeId){const e=this.topology[this.selectedNodeId]||null;return e?{type:"node",id:this.selectedNodeId,data:e}:null}return null},get freshnessLabel(){if(!this.lastUpdatedAt)return"never";const e=this.now-this.lastUpdatedAt;return e<1500?"now":e<5e3?"few seconds ago":e<15e3?"recent":Math.floor(e/1e3)+"s ago"},get freshnessClass(){if(!this.lastUpdatedAt)return"fresh-stale";const e=this.now-this.lastUpdatedAt;return e<5e3?"fresh-now":e<15e3?"fresh-ok":"fresh-stale"},_recomputeLayout(){const e=this.nodes,t=this.w/2,i=this.h/2,n=Math.max(80,Math.min(t,i)-this.nodeRadius()-60),s={},o=e.length||1;e.forEach((e,l)=>{if(1===o)return void(s[e.id]={x:t,y:i});const r=2===o?0===l?Math.PI:0:l/o*Math.PI*2-Math.PI/2;s[e.id]={x:t+n*Math.cos(r),y:i+n*Math.sin(r)}}),this.layout={cx:t,cy:i,r:n,pos:s}},_measure(){const t=(this._svgEl||e.$el).getBoundingClientRect();this.w=Math.max(300,t.width||900),this.h=Math.max(200,t.height||500),this._recomputeLayout()},_effectivePos(e){return this._customPos[e]||this.layout.pos?.[e]||null},fitView(){this._measure();const e=this.nodes;if(!e.length)return;let t=1/0,i=1/0,n=-1/0,s=-1/0;for(const o of e){const e=this._effectivePos(o.id);e&&(t=Math.min(t,e.x),i=Math.min(i,e.y),n=Math.max(n,e.x),s=Math.max(s,e.y))}if(!isFinite(t))return;const o=this.nodeRadius()+60;t-=o,i-=o,n+=o,s+=o;const l=n-t,r=s-i,a=.88*Math.min(this.w/l,this.h/r,3);this.zoom=a,this.panX=this.w/2-(t+l/2)*a,this.panY=this.h/2-(i+r/2)*a,this._applyTransform()},resetLayout(){this._customPos={};try{localStorage.removeItem("okdb-topo-pos")}catch{}this.fitView()},_savePositions(){try{localStorage.setItem("okdb-topo-pos",JSON.stringify(this._customPos))}catch{}},_loadPositions(){try{const e=localStorage.getItem("okdb-topo-pos");e&&(this._customPos=JSON.parse(e))}catch{}},nodeRadius:()=>22,linkPath(e){const t=this._effectivePos(e.lo),i=this._effectivePos(e.hi);if(!t||!i)return"";const n=i.x-t.x,s=i.y-t.y,o=Math.sqrt(n*n+s*s)||1,l=this.nodeRadius();return"M "+(t.x+n/o*l)+" "+(t.y+s/o*l)+" L "+(i.x-n/o*l)+" "+(i.y-s/o*l)},reconciling:!1,reconcilingTarget:null,async openConnectModal(){await e.$ok.shared.modal.open({owner:e,title:"Connect to Remote Node",content:'<connect-peer-modal :modal="modal" :on-done="onDone" />',context:t=>({modal:t,onDone:async()=>{await this.refreshTopology(),e.$ok.shared?.toast?.success?.("Peer connected")}})})},async refreshTopology(){if(!this.loadingTopology){this.loadingTopology=!0;try{const t=await fetch("/api/sync/topology"),i=await t.json(),n=i.data??i;e.$attr.topology=n.topology??n,e.$attr.available_envs=n.availableEnvs??[],this.lastUpdatedAt=Date.now()}catch(e){console.error("refresh failed",e)}finally{this.loadingTopology=!1}}},async reconcileAll(){if(!this.reconciling){this.reconciling=!0,this.reconcilingTarget="all";try{await fetch("/api/sync/reconcile-all",{method:"POST"}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}}},async reconcileNode(e){if(!this.reconciling){this.reconciling=!0,this.reconcilingTarget=e;try{e===this.selfId?await fetch("/api/sync/reconcile",{method:"POST"}):await fetch(this.topology[e]?.address+"/api/sync/reconcile",{method:"POST"}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}}},async reconcileLink(e){if(this.reconciling)return;this.reconciling=!0,this.reconcilingTarget=e.key;const t=e.lo===this.selfId?e.hi:e.lo;try{await fetch("/api/sync/reconcile-peer",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:t})}),await this.refreshTopology()}finally{this.reconciling=!1,this.reconcilingTarget=null}},openLinkEditor(e){this.editingLink=e,this.linkEditDir=e.link?.direction??"both",this.linkEditEnvs=e.link?.envs??null,this.linkEditEnabled=!1!==e.link?.enabled},closeLinkEditor(){this.editingLink=null},toggleLinkEnv(e){if(null===this.linkEditEnvs)this.linkEditEnvs=this.availableEnvs.filter(t=>t!==e);else{const t=new Set(this.linkEditEnvs);t.has(e)?t.delete(e):t.add(e);const i=[...t];this.linkEditEnvs=i.length===this.availableEnvs.length?null:i}},async saveLink(){if(!this.editingLink||this.savingLink)return;this.savingLink=!0;const e=this.editingLink;try{const t=await fetch("/api/sync/links/"+e.lo+"/"+e.hi,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({direction:this.linkEditDir,envs:this.linkEditEnvs,enabled:this.linkEditEnabled})});if(!t.ok){const e=await t.json();throw new Error(e?.error?.message||"Save failed")}this.closeLinkEditor(),await this.refreshTopology()}catch(e){alert(e.message)}finally{this.savingLink=!1}},async deleteLink(){if(!this.editingLink||this.savingLink)return;if(!confirm("Remove this data link? Nodes stay connected but data will stop syncing."))return;this.savingLink=!0;const e=this.editingLink;try{await fetch("/api/sync/links/"+e.lo+"/"+e.hi,{method:"DELETE"}),this.closeLinkEditor(),await this.refreshTopology()}catch(e){alert(e.message)}finally{this.savingLink=!1}},deselect(){this.selectedNodeId=null,this.selectedLinkKey=null,this.hoveredLinkKey=null,this.hoveredNodeId=null,this.editingLink=null,e.$dispatch?.("select",null),e.$dispatch?.("selectLink",null)},pickNode(t){this.selectedNodeId=t,this.selectedLinkKey=null,this.editingLink=null,e.$dispatch?.("select",t),e.$dispatch?.("selectLink",null)},pickLink(t){const i=this.links.find(e=>e.key===t)||null;if(i&&(!this.selectedNodeId||i.lo===this.selectedNodeId||i.hi===this.selectedNodeId)){if(this.selectedLinkKey===t)return this.selectedLinkKey=null,this.editingLink=null,void e.$dispatch?.("selectLink",null);this.selectedLinkKey=t,this.selectedNodeId||(this.selectedNodeId=i.lo,e.$dispatch?.("select",this.selectedNodeId)),e.$dispatch?.("selectLink",t)}},async copy(e){try{await navigator.clipboard.writeText(String(e??""))}catch(e){}},_sPointerEventsFor(e){return this.selectedNodeId&&e.lo!==this.selectedNodeId&&e.hi!==this.selectedNodeId?"pointer-events:none":"pointer-events:stroke;cursor:pointer"},_sEnabledBadge:e=>e?"color:var(--ok)":"color:var(--dis);opacity:0.7",_sEnabledBtn:e=>e?"border-color:var(--ok);color:var(--ok)":"border-color:var(--dis);color:var(--dis)",nodeHealth(e){const t=this.links.filter(t=>t.lo===e||t.hi===e);if(!t.length)return"ok";const i=t.map(e=>this._linkHealth(e));return i.includes("error")?"error":i.includes("lag-high")?"critical":i.includes("lag-med")?"lagging":i.includes("behind")?"behind":i.every(e=>"stale"===e||"disabled"===e)?"stale":"ok"},isLinkInteractable(e){return!this.selectedNodeId||e.lo===this.selectedNodeId||e.hi===this.selectedNodeId},isActiveLink(e){return this.selectedLinkKey===e.key||this.hoveredLinkKey===e.key&&this.isLinkInteractable(e)},nodeClass(e){let t="nd";const i=this.selectedNodeId,n=this.hoveredNodeId;if(i&&i!==e.id&&(t+=" nd-dim"),i===e.id&&(t+=" nd-sel"),this.selectedLinkKey){const n=this.links.find(e=>e.key===this.selectedLinkKey);!n||n.lo!==e.id&&n.hi!==e.id||i===e.id||(t+=" nd-sel2")}i||n!==e.id||(t+=" nd-hover");const s=this.nodeHealth(e.id);return"error"===s?t+=" nd-err":"critical"===s?t+=" nd-critical":"lagging"===s?t+=" nd-lag":"behind"===s?t+=" nd-behind":"stale"===s&&(t+=" nd-stale"),t},linkClass(e){let t="ln";const i=this.selectedLinkKey===e.key,n=this.hoveredLinkKey===e.key&&this.isLinkInteractable(e);switch(this.isLinkInteractable(e)||i||(t+=" ln-dim"),i?t+=" ln-selected":n&&(t+=" ln-hover"),this._linkHealth(e)){case"disabled":t+=" ln-disabled";break;case"error":t+=" ln-err";break;case"reconciling":t+=" ln-rec";break;case"lag-high":t+=" ln-lag-high";break;case"lag-med":t+=" ln-lag-med";break;case"behind":t+=" ln-behind-light";break;case"stale":t+=" ln-stale";break;default:t+=" ln-ok"}return t},linkMarker(e){const t=e.link?.direction??"both";return"both"!==t&&"lo_to_hi"!==t?"":e.link&&!e.link.enabled?"url(#arrow-disabled)":"url(#arrow-"+this._healthMarker(this._pullHealth(e.hi_pull))+")"},linkStartMarker(e){const t=e.link?.direction??"both";return"both"!==t&&"hi_to_lo"!==t?"":e.link&&!e.link.enabled?"url(#arrow-rev-disabled)":"url(#arrow-rev-"+this._healthMarker(this._pullHealth(e.lo_pull))+")"}}),mount(e){this._svgEl=e.$el.querySelector("svg"),this._groupEl=this._svgEl?.querySelector(".topo-g"),this._svgEl&&(this._svgEl.style.cursor="grab"),this._measure(),this._loadPositions(),this._ro=new ResizeObserver(()=>this.fitView()),this._ro.observe(e.$el),this._tick=setInterval(()=>{this.now=Date.now()},1e3),this.refreshTopology().then(()=>this.fitView()),this._wheelCb=e=>{e.preventDefault(),this._onWheel(e)},this._svgEl&&this._svgEl.addEventListener("wheel",this._wheelCb,{passive:!1}),this._mousedownCb=e=>this._onSvgMousedown(e),this._svgEl&&this._svgEl.addEventListener("mousedown",this._mousedownCb),this._mousemoveCb=e=>this._onMousemove(e),this._mouseupCb=()=>this._onMouseup(),window.addEventListener("mousemove",this._mousemoveCb),window.addEventListener("mouseup",this._mouseupCb),this._visibilityHandler=()=>{document.hidden||this.refreshTopology()},document.addEventListener("visibilitychange",this._visibilityHandler),this.autoRefresh&&(this._autoRefreshTimer=setInterval(()=>this.refreshTopology(),this.autoRefreshIntervalMs)),this.selectedNodeId=this.externalSelectedNodeId,e.$watch(()=>e.$attr.selected_node_id,e=>{this.selectedNodeId=e??null,e||(this.selectedLinkKey=null)})},unmount(){this._ro?.disconnect(),clearInterval(this._tick),clearInterval(this._autoRefreshTimer),this._svgEl&&(this._svgEl.removeEventListener("wheel",this._wheelCb),this._svgEl.removeEventListener("mousedown",this._mousedownCb)),window.removeEventListener("mousemove",this._mousemoveCb),window.removeEventListener("mouseup",this._mouseupCb),document.removeEventListener("visibilitychange",this._visibilityHandler)},template:'<div style="--bg:var(--okdb-app-bg-deep); --panel:var(--okdb-app-surface); --panel2:var(--okdb-app-bg-deep); --border:var(--okdb-app-border); --border2:var(--okdb-app-surface-2);\n --text:var(--okdb-app-fg); --muted:var(--okdb-app-fg-muted); --accent:var(--okdb-accent); --ok:var(--okdb-success); --rec:var(--okdb-accent);\n --warn:var(--okdb-warning); --bad:var(--okdb-danger); --err:var(--okdb-danger); --dis:var(--okdb-app-fg-subtle);\n border:1px solid var(--border); border-radius:12px; background:var(--bg); color:var(--text);\n display:flex; flex-direction:column; flex:1; position:relative; overflow:hidden;\n font-family: ui-monospace, monospace;"><div style="padding:12px 14px;display:flex;justify-content:space-between;border-bottom:1px solid var(--border2);background:var(--panel);align-items:center;"><div style="display:flex;align-items:center;gap:10px;"><div style="font-weight:700;letter-spacing:0.8px;color:var(--accent);">NETWORK_TOPOLOGY</div><button @click="openConnectModal()" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;" title="Connect to a remote node">+ ADD PEER</button> <button @click="reconcileAll()" :disabled="reconciling" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;"><span style="display:flex;align-items:center;gap:6px;"><span :style="reconcilingTarget===\'all\'?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===\'all\'?\'RECONCILING\':\'RECONCILE ALL\'}}</span></button> <button @click="refreshTopology()" :disabled="loadingTopology" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;">REFRESH</button> <button @click="fitView()" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;" title="Fit all nodes into view">FIT</button> <button @click="resetLayout()" style="font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;" title="Reset node positions">RESET</button> <span :style="{opacity:loadingTopology?1:0}" class="spinner"/></div><div style="display:flex;align-items:center;gap:12px;font-size:11px;"><span :class="freshnessClass">updated: {{freshnessLabel}}</span> <span style="opacity:0.75;">{{nodes.length}} NODES // {{links.length}} LINKS</span></div></div><div style="flex:1;position:relative;"><svg :viewBox="\'0 0 \'+w+\' \'+h" style="width:100%;height:100%;"><rect :width="w" :height="h" fill="transparent" style="pointer-events:all" @click="deselect()"/><defs><marker id="arrow-ok" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--ok)"/></marker><marker id="arrow-rec" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--rec)"/></marker><marker id="arrow-err" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--err)"/></marker><marker id="arrow-lag-med" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--warn)"/></marker><marker id="arrow-lag-high" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--bad)"/></marker><marker id="arrow-disabled" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--dis)"/></marker><marker id="arrow-rev-ok" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--ok)"/></marker><marker id="arrow-rev-rec" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--rec)"/></marker><marker id="arrow-rev-err" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--err)"/></marker><marker id="arrow-rev-lag-med" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--warn)"/></marker><marker id="arrow-rev-lag-high" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--bad)"/></marker><marker id="arrow-rev-disabled" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--dis)"/></marker></defs><style>.ln { fill:none; stroke-width:1.9; opacity:0.70; transition:opacity 0.14s,stroke-width 0.14s,filter 0.14s; }\n .ln-ok { stroke:var(--ok); }\n .ln-behind-light { stroke:var(--okdb-info); opacity:0.8; }\n .ln-rec { stroke:var(--rec); stroke-dasharray:4 3; animation:dash 1.1s linear infinite; filter:drop-shadow(0 0 4px rgba(77,163,255,0.5)); }\n .ln-lag-med { stroke:var(--warn); }\n .ln-lag-high { stroke:var(--bad); }\n .ln-err { stroke:var(--err); }\n .ln-stale { stroke:var(--okdb-app-fg-subtle); opacity:0.5; }\n .ln-disabled { stroke:var(--dis); stroke-dasharray:6 4; opacity:0.45; }\n .ln-dim { opacity:0.08; filter:none !important; }\n .ln-hover { stroke-width:3.0; opacity:1; }\n .ln-selected { stroke:var(--accent) !important; stroke-width:5.2 !important; opacity:1 !important;\n filter:drop-shadow(0 0 4px rgba(77,163,255,0.9)) drop-shadow(0 0 10px rgba(77,163,255,0.35)); }\n\n .nd circle { fill:var(--bg); stroke:var(--border); stroke-width:2; transition:stroke 0.14s,stroke-width 0.14s,filter 0.14s,opacity 0.14s; }\n .nd text { fill:var(--muted); font-size:11px; pointer-events:none; }\n .nd-hover circle { stroke:rgba(77,163,255,0.8); }\n .nd-sel circle { stroke:var(--accent); stroke-width:3.4; filter:drop-shadow(0 0 4px rgba(77,163,255,0.75)) drop-shadow(0 0 10px rgba(77,163,255,0.25)); }\n .nd-sel2 circle { stroke:rgba(77,163,255,0.75); stroke-width:3.0; filter:drop-shadow(0 0 6px rgba(77,163,255,0.25)); }\n .nd-dim { opacity:0.22; }\n .nd-err circle { stroke:var(--err); }\n .nd-stale circle { stroke:var(--okdb-app-fg-subtle); stroke-width:2; opacity:0.7; }\n .nd-behind circle { stroke:var(--okdb-info); stroke-width:2.5; }\n .nd-lag circle { stroke:var(--warn); stroke-width:3; }\n .nd-critical circle { stroke:var(--bad); stroke-width:3.5; filter:drop-shadow(0 0 6px rgba(255,77,77,0.5)); }\n .fresh-now { color:var(--ok); }\n .fresh-ok { color:var(--okdb-info); }\n .fresh-stale { color:var(--warn); }\n .spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(77,163,255,0.2); border-top:2px solid var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; }\n @keyframes dash { to { stroke-dashoffset:-14; } }\n @keyframes spin { to { transform:rotate(360deg); } }</style><g class="topo-g"><for :="links" let:l><if :="!isActiveLink(l)"><g><path :d="linkPath(l)" stroke="transparent" stroke-width="14" fill="none" :style="_sPointerEventsFor(l)" @mouseenter="isLinkInteractable(l)?(hoveredLinkKey=l.key):null" @mouseleave="hoveredLinkKey=null" @click="pickLink(l.key)"/><path :d="linkPath(l)" :class="linkClass(l)" :marker-end="linkMarker(l)" :marker-start="linkStartMarker(l)"><title>{{l.lo.slice(0,8)}} ↔ {{l.hi.slice(0,8)}} direction: {{l.link ? l.link.direction : \'mesh\'}} enabled: {{l.link ? l.link.enabled : true}} envs: {{l.link && l.link.envs ? l.link.envs.join(\', \') : \'all\'}}</title></path></g></if></for><for :="nodeList" let:n><g :id="\'nd-\'+n.id" :transform="\'translate(\'+n.px+\',\'+n.py+\')\'" :class="nodeClass(n)" @mouseenter="hoveredNodeId=n.id" @mouseleave="hoveredNodeId=null" @click="pickNode(n.id)"><circle :r="nodeRadius()"/><text text-anchor="middle" dy="4">{{n.id.slice(0,6)}}</text><title>{{n.id}} clock: {{n.clock ?? \'--\'}} addr: {{n.address ?? \'—\'}}</title></g></for><for :="links" let:l><if :="isActiveLink(l)"><g><path :d="linkPath(l)" stroke="transparent" stroke-width="16" fill="none" :style="_sPointerEventsFor(l)" @mouseenter="isLinkInteractable(l)?(hoveredLinkKey=l.key):null" @mouseleave="hoveredLinkKey=null" @click="pickLink(l.key)"/><path :d="linkPath(l)" :class="linkClass(l)" :marker-end="linkMarker(l)" :marker-start="linkStartMarker(l)"/></g></if></for></g></svg><if :="selection || editingLink"><div style="position:absolute;top:18px;right:18px;width:320px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px;box-shadow:0 16px 34px rgba(0,0,0,0.45);border-left:4px solid var(--accent);"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><span style="font-size:10px;background:var(--bg);padding:3px 7px;border-radius:6px;color:var(--accent);letter-spacing:0.6px;">{{editingLink ? \'LINK_CONFIG\' : selection.type.toUpperCase()}} </span><span @click="editingLink ? closeLinkEditor() : deselect()" style="cursor:pointer;opacity:0.7;font-size:16px;line-height:12px;">×</span></div><if :="!editingLink && selection && selection.type === \'node\'"><div style="font-size:13px;"><div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;"><div style="color:var(--text);font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{selection.id}}</div><button @click="copy(selection.id)" style="border:1px solid var(--border);background:var(--panel2);color:var(--muted);font-size:11px;padding:4px 8px;border-radius:8px;cursor:pointer;">copy</button></div><div style="display:grid;grid-template-columns:92px 1fr;row-gap:8px;column-gap:10px;"><div style="opacity:0.65;">Clock</div><div>{{selection.data.clock??\'--\'}}</div><div style="opacity:0.65;">Addr</div><div style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{selection.data.address||\'N/A\'}}</div><div style="opacity:0.65;">Status</div><div :style="selection.data.error?\'color:var(--err)\':\'color:var(--ok)\'">{{selection.data.error?(\'ERR: \'+selection.data.error):\'OK\'}}</div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Tags</div><div style="display:flex;flex-wrap:wrap;gap:6px;"><if :="!(selection.data.tags || []).length"><span style="font-size:11px;opacity:0.45;">No tags</span></if><for :="selection.data.tags || []" let:tag><span style="font-size:10px;padding:2px 8px;border-radius:999px;border:1px solid var(--okdb-accent-soft-border);color:var(--okdb-accent);background:var(--okdb-accent-soft-bg);">{{tag}}</span></for></div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Available envs</div><div style="font-size:11px;color:var(--muted);line-height:1.5;word-break:break-word;">{{(selection.data.availableEnvs || []).join(\', \') || \'—\'}}</div></div><div style="margin-top:12px;border-top:1px solid var(--border2);padding-top:10px;"><div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Outbound peers</div><div style="display:flex;flex-wrap:wrap;gap:6px;"><for :="Object.keys(selection.data.peers||{})" let:pid><span style="font-size:11px;padding:3px 7px;border:1px solid var(--border);background:var(--panel2);border-radius:999px;color:var(--muted);">{{pid.slice(0,8)}}</span></for></div></div><div style="margin-top:14px;"><button @click="reconcileNode(selection.id)" :disabled="reconciling" style="width:100%;box-sizing:border-box;padding:6px 10px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:6px;"><span :style="reconcilingTarget===selection.id?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===selection.id?\'RECONCILING\':\'RECONCILE NODE\'}}</span></button></div></div></if><if :="!editingLink && selection && selection.type === \'link\'"><div style="font-size:13px;"><div style="margin-bottom:12px;"><div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"><span style="font-family:monospace;font-size:12px;font-weight:700;">{{selection.data.lo.slice(0,8)}}</span> <span style="font-size:16px;color:var(--accent);">{{selection.data.link ? (selection.data.link.direction === \'lo_to_hi\' ? \'→\' : selection.data.link.direction === \'hi_to_lo\' ? \'←\' : \'⇄\') : \'⇄\'}} </span><span style="font-family:monospace;font-size:12px;font-weight:700;">{{selection.data.hi.slice(0,8)}}</span><if :="selection.data.link"><span style="font-size:11px;margin-left:4px;" :style="_sEnabledBadge(selection.data.link.enabled)">{{selection.data.link.enabled ? \'● on\' : \'○ off\'}}</span></if></div><if :="selection.data.link && selection.data.link.envs"><div style="font-size:11px;opacity:0.45;">envs: {{selection.data.link.envs.join(\', \')}}</div></if><if :="!selection.data.link"><div style="font-size:11px;opacity:0.45;">no link config — mesh (legacy)</div></if></div><div style="border:1px solid var(--border2);border-radius:8px;overflow:hidden;margin-bottom:12px;font-size:11px;"><if :="!selection.data.link || selection.data.link.direction === \'both\' || selection.data.link.direction === \'lo_to_hi\'"><div style="padding:9px 11px;border-bottom:1px solid var(--bg);"><div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;"><span style="font-family:monospace;opacity:0.7;">{{selection.data.lo.slice(0,6)}}</span> <span style="color:var(--accent);">→</span> <span style="font-family:monospace;opacity:0.7;">{{selection.data.hi.slice(0,6)}}</span> <span style="opacity:0.4;">(hi receives from lo)</span></div><div style="display:grid;grid-template-columns:70px 1fr;row-gap:4px;gap:6px;opacity:0.85;"><span style="opacity:0.5;">status</span> <span>{{selection.data.hi_pull ? selection.data.hi_pull.status : \'—\'}}</span> <span style="opacity:0.5;">backlog</span> <span>{{selection.data.hi_pull ? selection.data.hi_pull.backlog : \'—\'}}</span> <span style="opacity:0.5;">last seen</span> <span>{{selection.data.hi_pull && selection.data.hi_pull.last_seen ? (Math.round((now - selection.data.hi_pull.last_seen)/1000) + \'s ago\') : \'—\'}}</span><if :="selection.data.hi_pull && selection.data.hi_pull.last_error"><span style="opacity:0.5;">error</span> <span style="color:var(--err);">{{selection.data.hi_pull.last_error}}</span></if></div></div></if><if :="!selection.data.link || selection.data.link.direction === \'both\' || selection.data.link.direction === \'hi_to_lo\'"><div style="padding:9px 11px;"><div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;"><span style="font-family:monospace;opacity:0.7;">{{selection.data.hi.slice(0,6)}}</span> <span style="color:var(--accent);">→</span> <span style="font-family:monospace;opacity:0.7;">{{selection.data.lo.slice(0,6)}}</span> <span style="opacity:0.4;">(lo receives from hi)</span></div><div style="display:grid;grid-template-columns:70px 1fr;row-gap:4px;gap:6px;opacity:0.85;"><span style="opacity:0.5;">status</span> <span>{{selection.data.lo_pull ? selection.data.lo_pull.status : \'—\'}}</span> <span style="opacity:0.5;">backlog</span> <span>{{selection.data.lo_pull ? selection.data.lo_pull.backlog : \'—\'}}</span> <span style="opacity:0.5;">last seen</span> <span>{{selection.data.lo_pull && selection.data.lo_pull.last_seen ? (Math.round((now - selection.data.lo_pull.last_seen)/1000) + \'s ago\') : \'—\'}}</span><if :="selection.data.lo_pull && selection.data.lo_pull.last_error"><span style="opacity:0.5;">error</span> <span style="color:var(--err);">{{selection.data.lo_pull.last_error}}</span></if></div></div></if></div><div style="display:flex;flex-direction:column;gap:7px;"><button @click="reconcileLink(selection.data)" :disabled="reconciling" style="width:100%;box-sizing:border-box;padding:7px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:6px;"><span :style="reconcilingTarget===selection.data.key?\'display:inline-block\':\'display:none\'" class="spinner"/> {{reconcilingTarget===selection.data.key ? \'Reconciling…\' : \'Reconcile\'}}</span></button> <button @click="openLinkEditor(selection.data)" style="width:100%;box-sizing:border-box;padding:7px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:12px;">✎ Configure Link</button></div></div></if><if :="editingLink"><div style="font-size:13px;"><div style="margin-bottom:14px;"><div style="font-family:monospace;font-size:11px;opacity:0.5;margin-bottom:2px;">link id</div><div style="font-family:monospace;font-size:11px;">{{editingLink.key}}</div></div><div style="margin-bottom:14px;"><div style="font-size:11px;opacity:0.5;margin-bottom:7px;">Direction</div><div style="display:flex;flex-direction:column;gap:5px;"><for :="dirButtons" let:btn><button @click="linkEditDir = btn.canon" :style="btn.style" style="width:100%;box-sizing:border-box;padding:7px 10px;border-radius:7px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:12px;font-family:monospace;text-align:left;">{{btn.label}}</button></for></div></div><if :="availableEnvs.length"><div style="margin-bottom:14px;"><div style="font-size:11px;opacity:0.5;margin-bottom:7px;">Data envs — {{linkEditEnvs === null ? \'all\' : linkEditEnvs.length + \' selected\'}}</div><div style="display:flex;flex-wrap:wrap;gap:5px;"><for :="envButtons" let:btn><button @click="toggleLinkEnv(btn.name)" :style="btn.style" style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:var(--panel2);color:var(--muted);cursor:pointer;font-size:11px;">{{btn.name}}</button></for></div></div></if><div style="margin-bottom:16px;display:flex;align-items:center;gap:10px;"><span style="font-size:11px;opacity:0.5;">Enabled</span> <button @click="linkEditEnabled=!linkEditEnabled" :style="_sEnabledBtn(linkEditEnabled)" style="padding:4px 12px;border-radius:6px;border:1px solid;background:var(--panel2);cursor:pointer;font-size:11px;">{{linkEditEnabled ? \'● ON\' : \'○ OFF\'}}</button></div><div style="display:flex;gap:8px;"><button @click="saveLink()" :disabled="savingLink" style="flex:1;padding:8px;border-radius:8px;border:1px solid var(--accent);background:rgba(77,163,255,0.1);color:var(--accent);cursor:pointer;font-size:12px;"><span style="display:flex;align-items:center;justify-content:center;gap:5px;"><span :style="savingLink?\'display:inline-block\':\'display:none\'" class="spinner"/> Save</span></button> <button @click="deleteLink()" :disabled="savingLink" style="padding:8px 14px;border-radius:8px;border:1px solid #3d1a1a;background:rgba(255,59,48,0.08);color:var(--err);cursor:pointer;font-size:12px;">Remove</button></div></div></if></div></if></div><div style="padding:9px 14px;background:var(--bg);border-top:1px solid var(--border2);display:flex;gap:16px;font-size:11px;align-items:center;flex-wrap:wrap;opacity:0.9;"><span><span style="color:var(--ok);">●</span> synced</span> <span><span style="color:var(--okdb-info);">●</span> behind</span> <span><span style="color:var(--rec);">●</span> reconciling</span> <span><span style="color:var(--warn);">●</span> lag</span> <span><span style="color:var(--err);">●</span> error</span> <span><span style="color:var(--dis);">●</span> disabled</span> <span style="opacity:0.4;">⇄ both → send ← receive -- disabled (click to inspect)</span></div></div>'};