@kedem/okdb 1.8.15 → 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/bin/okdb.js +1 -1
- package/docs/manifest.json +1 -0
- package/docs/processors.md +129 -0
- package/okdb-functions-runner-child.js +1 -1
- package/okdb-processor-worker.js +1 -1
- package/okdb-queue-spawn-child.js +1 -1
- package/okdb-views-bootstrap-worker.js +1 -1
- package/okdb.js +1 -1
- package/package.json +1 -1
- package/public/layouts/_default.ok.html +1 -1
- package/public/sections/queue/parts/code-panel.ok.js +1 -0
- package/public/sections/queue/parts/job-log-panel.ok.js +1 -0
- package/public/sections/queue/parts/queue-jobs.ok.js +1 -1
- package/public/sections/sync/modals/connect-peer-modal.ok.js +1 -0
- package/public/sections/sync/parts/sync-topology.ok.js +1 -1
- package/public/sections/views/parts/view-detail.ok.js +1 -1
- package/public/stores/events-store.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kedem/okdb",
|
|
3
|
-
"version": "1.8.
|
|
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>'};
|