@kedem/okdb 1.0.4 → 1.1.0
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/README.md +13 -4
- package/bin/okdb.js +1 -1
- package/docs/OKDB-ARCHITECTURE.md +2 -1
- package/docs/manifest.json +1 -0
- package/docs/pipelines.md +2 -0
- package/docs/views.md +351 -0
- package/okdb.js +1 -1
- package/package.json +1 -1
- package/public/sections/queue/parts/queue-buckets.ok.js +1 -1
- package/public/sections/queue/parts/queue-jobs.ok.js +1 -1
- package/public/sections/views/modals/create-view-modal.ok.js +1 -1
- package/public/sections/views/parts/view-detail.ok.js +1 -1
- package/public/sections/views/parts/views-overview.ok.js +1 -1
- package/types/features/views.d.ts +142 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kedem/okdb",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
-
import e from"../modals/edit-bucket-modal.ok.js";import t from"../../../stores/okdb-store.js";export default{tag:"queue-buckets",dependencies:[e],context:e=>({busy:!1,buckets:[],error:null,async load(){this.busy=!0,this.error=null;try{const e=await fetch(`${t.apiBase}/queue/buckets`),o=await e.json();if(!e.ok)throw new Error(o?.error?.message||"Failed to load buckets");this.buckets=o.data??[]}catch(e){this.error=e?.message||String(e),this.buckets=[]}finally{this.busy=!1}},async openCreate(){await e.$ok.shared.modal.open({owner:e,title:"Add Token Bucket",content:'<edit-bucket-modal :modal="modal" :edit-mode="editMode" :initial-form="initialForm" :on-done="onDone" />',context:e=>({modal:e,editMode:!1,initialForm:{},onDone:async()=>{await this.load()}})})},async openEdit(t){await e.$ok.shared.modal.open({owner:e,title:"Edit Bucket",content:'<edit-bucket-modal :modal="modal" :edit-mode="editMode" :initial-form="initialForm" :on-done="onDone" />',context:e=>({modal:e,editMode:!0,initialForm:{id:t.id,capacity:t.capacity,tokens:t.tokens,refill_amount:t.refill_amount??0,refill_every:t.refill_every??0},onDone:async()=>{await this.load()}})})},async remove(o){if(await e.$ok.shared.modal.confirm(`Delete bucket "<b>${o.id}</b>"?`))try{if(!(await fetch(`${t.apiBase}/queue/bucket/remove/${encodeURIComponent(o.id)}`,{method:"POST"})).ok)throw new Error("Delete failed");e.$ok.shared?.toast?.info?.(`Deleted "${o.id}"`),await this.load()}catch(t){e.$ok.shared?.toast?.error?.(t?.message||"Delete failed")}},async refill(o){try{if(!(await fetch(`${t.apiBase}/queue/bucket/update/${encodeURIComponent(o.id)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tokens:o.capacity})})).ok)throw new Error("Refill failed");e.$ok.shared?.toast?.success?.(`Refilled "${o.id}"`),await this.load()}catch(t){e.$ok.shared?.toast?.error?.(t?.message||"Refill failed")}},tokenPct:e=>e.capacity?Math.min(100,Math.round(e.tokens/e.capacity*100)):0,pctColor:e=>e>60?"var(--okdb-success)":e>25?"var(--okdb-warning)":"var(--okdb-danger)",refillLabel(e){if(!e.refill_every||!e.refill_amount)return"No refill";const t=e.refill_every;let o;return o=t>=36e5?`${(t/36e5).toFixed(1)}h`:t>=6e4?`${Math.round(t/6e4)}m`:`${Math.round(t/1e3)}s`,`+${e.refill_amount} / ${o}`}}),async mount(e){await this.load(),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>this.load())},template:'\n<div style="display:flex; flex-direction:column; gap:16px; height:100%; min-height:0;">\n\n \x3c!-- Toolbar --\x3e\n <div style="display:flex; gap:8px; align-items:center;">\n <div style="font-weight:600; font-size:16px;">Token Buckets</div>\n <div style="margin-left:auto; display:flex; gap:8px;">\n <button @click="load()" :disabled="busy">↻ Refresh</button>\n <button @click="openCreate()" style="background:color-mix(in srgb, var(--okdb-accent) 13%, transparent); border-color:var(--okdb-accent); color:var(--okdb-accent);">+ Add Bucket</button>\n </div>\n </div>\n\n <if :="error">\n <div style="color:var(--okdb-danger); font-size:13px; padding:10px 12px; border:1px solid var(--okdb-danger); border-radius:8px;">{{error}}</div>\n </if>\n\n \x3c!-- Empty state --\x3e\n <if :="!busy && buckets.length === 0 && !error">\n <div style="opacity:.55; font-size:13px; line-height:1.8; padding:16px 0;">\n No token buckets defined.<br>\n Token buckets let you rate-limit queue job processing per bucket ID.\n </div>\n </if>\n\n \x3c!-- Bucket cards --\x3e\n <div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; overflow:auto; min-height:0;">\n <for :="buckets" let:b>\n <div style="\n background
|
|
1
|
+
import e from"../modals/edit-bucket-modal.ok.js";import t from"../../../stores/okdb-store.js";export default{tag:"queue-buckets",dependencies:[e],context:e=>({busy:!1,buckets:[],error:null,async load(){this.busy=!0,this.error=null;try{const e=await fetch(`${t.apiBase}/queue/buckets`),o=await e.json();if(!e.ok)throw new Error(o?.error?.message||"Failed to load buckets");this.buckets=o.data??[]}catch(e){this.error=e?.message||String(e),this.buckets=[]}finally{this.busy=!1}},async openCreate(){await e.$ok.shared.modal.open({owner:e,title:"Add Token Bucket",content:'<edit-bucket-modal :modal="modal" :edit-mode="editMode" :initial-form="initialForm" :on-done="onDone" />',context:e=>({modal:e,editMode:!1,initialForm:{},onDone:async()=>{await this.load()}})})},async openEdit(t){await e.$ok.shared.modal.open({owner:e,title:"Edit Bucket",content:'<edit-bucket-modal :modal="modal" :edit-mode="editMode" :initial-form="initialForm" :on-done="onDone" />',context:e=>({modal:e,editMode:!0,initialForm:{id:t.id,capacity:t.capacity,tokens:t.tokens,refill_amount:t.refill_amount??0,refill_every:t.refill_every??0},onDone:async()=>{await this.load()}})})},async remove(o){if(await e.$ok.shared.modal.confirm(`Delete bucket "<b>${o.id}</b>"?`))try{if(!(await fetch(`${t.apiBase}/queue/bucket/remove/${encodeURIComponent(o.id)}`,{method:"POST"})).ok)throw new Error("Delete failed");e.$ok.shared?.toast?.info?.(`Deleted "${o.id}"`),await this.load()}catch(t){e.$ok.shared?.toast?.error?.(t?.message||"Delete failed")}},async refill(o){try{if(!(await fetch(`${t.apiBase}/queue/bucket/update/${encodeURIComponent(o.id)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tokens:o.capacity})})).ok)throw new Error("Refill failed");e.$ok.shared?.toast?.success?.(`Refilled "${o.id}"`),await this.load()}catch(t){e.$ok.shared?.toast?.error?.(t?.message||"Refill failed")}},tokenPct:e=>e.capacity?Math.min(100,Math.round(e.tokens/e.capacity*100)):0,pctColor:e=>e>60?"var(--okdb-success)":e>25?"var(--okdb-warning)":"var(--okdb-danger)",refillLabel(e){if(!e.refill_every||!e.refill_amount)return"No refill";const t=e.refill_every;let o;return o=t>=36e5?`${(t/36e5).toFixed(1)}h`:t>=6e4?`${Math.round(t/6e4)}m`:`${Math.round(t/1e3)}s`,`+${e.refill_amount} / ${o}`}}),async mount(e){await this.load(),e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>this.load())},template:'\n<div style="display:flex; flex-direction:column; gap:16px; height:100%; min-height:0;">\n\n \x3c!-- Toolbar --\x3e\n <div style="display:flex; gap:8px; align-items:center;">\n <div style="font-weight:600; font-size:16px;">Token Buckets</div>\n <div style="margin-left:auto; display:flex; gap:8px;">\n <button @click="load()" :disabled="busy">↻ Refresh</button>\n <button @click="openCreate()" style="background:color-mix(in srgb, var(--okdb-accent) 13%, transparent); border-color:var(--okdb-accent); color:var(--okdb-accent);">+ Add Bucket</button>\n </div>\n </div>\n\n <if :="error">\n <div style="color:var(--okdb-danger); font-size:13px; padding:10px 12px; border:1px solid var(--okdb-danger); border-radius:8px;">{{error}}</div>\n </if>\n\n \x3c!-- Empty state --\x3e\n <if :="!busy && buckets.length === 0 && !error">\n <div style="opacity:.55; font-size:13px; line-height:1.8; padding:16px 0;">\n No token buckets defined.<br>\n Token buckets let you rate-limit queue job processing per bucket ID.\n </div>\n </if>\n\n \x3c!-- Bucket cards --\x3e\n <div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; overflow:auto; min-height:0;">\n <for :="buckets" let:b>\n <div style="\n background:var(--okdb-app-surface);\n border:1px solid var(--okdb-app-border);\n border-radius:12px;\n padding:14px 16px;\n display:flex;\n flex-direction:column;\n gap:10px;\n ">\n \x3c!-- Header --\x3e\n <div style="display:flex; align-items:center; gap:8px;">\n <div style="font-weight:600; font-family:monospace; font-size:13px; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">\n {{b.id}}\n </div>\n <button @click="openEdit(b)" style="font-size:11px; padding:3px 8px; opacity:.8;">Edit</button>\n <button @click="remove(b)" style="font-size:11px; padding:3px 8px; color:var(--okdb-danger); opacity:.8;">✕</button>\n </div>\n\n \x3c!-- Token bar --\x3e\n <div>\n <div style="display:flex; justify-content:space-between; font-size:12px; margin-bottom:4px;">\n <span style="opacity:.7;">Tokens</span>\n <span :style="\'color:\' + pctColor(tokenPct(b))">\n {{b.tokens}} / {{b.capacity}} ({{tokenPct(b)}}%)\n </span>\n </div>\n <div style="height:6px; background:var(--okdb-app-border); border-radius:999px; overflow:hidden;">\n <div\n :style="\'height:100%; border-radius:999px; width:\' + tokenPct(b) + \'%; background:\' + pctColor(tokenPct(b)) + \'; transition:width 0.3s;\'"\n ></div>\n </div>\n </div>\n\n \x3c!-- Stats row --\x3e\n <div style="display:flex; gap:12px; font-size:12px; opacity:.75;">\n <span>Refill: <b>{{refillLabel(b)}}</b></span>\n </div>\n\n \x3c!-- Actions --\x3e\n <div style="display:flex; gap:6px;">\n <button @click="refill(b)" style="font-size:11px; flex:1;" :disabled="b.tokens >= b.capacity">\n Refill to Full\n </button>\n </div>\n </div>\n </for>\n </div>\n\n</div>\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 rebuildStats(){try{await fetch(i.apiBase+"/queue/stats/rebuild",{method:"POST"}),e.$ok.shared?.toast?.success?.("Stats rebuilt"),await this.loadStats(),e.$dispatch("refresh"),await this.query.load()}catch{e.$ok.shared?.toast?.error?.("Stats rebuild failed")}},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 <div style="width:1px; height:16px; background:var(--okdb-app-border);"></div>\n <button @click="rebuildStats"\n style="font-size:11px; opacity:.4; background:transparent; border-color:transparent; padding:3px 8px;"\n title="Full scan rebuild of stats counters">\n Rebuild Stats\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 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 +1 @@
|
|
|
1
|
-
import e from"@toolkit/elements/ok-icon.ok.js";import t from"@toolkit/core/ok-overlay.ok.js";import r from"../../../components/ok-text-monaco-input.ok.js";import s from"../../../components/field-path-input.ok.js";const i=[{value:"$count",label:"$count",needsField:!1,isRef:!1},{value:"$sum",label:"$sum",needsField:!0,isRef:!1},{value:"$avg",label:"$avg",needsField:!0,isRef:!1},{value:"$min",label:"$min",needsField:!0,isRef:!1},{value:"$max",label:"$max",needsField:!0,isRef:!1},{value:"$countBy",label:"$countBy",needsField:!0,isRef:!1},{value:"$ref",label:"$ref",needsField:!1,isRef:!0}],a=i.filter(e=>!e.isRef);function o(){return{name:"count",op:"$count",field:"",items:!1}}function d(e){const t={};for(const r of e.refReducers||[])r.name.trim()&&("$count"===r.op?(t[r.name]={$count:!0},r.items&&(t[r.name].items=!0)):(t[r.name]={[r.op]:r.field.trim()||"_id"},r.items&&(t[r.name].items=!0)));const r={type:e.refType||"",key:e.refKey||""};Object.keys(t).length&&(r.reduce=t);try{const t=(e.refFilterStr||"").trim();t&&(r.filter=JSON.parse(t))}catch{}try{const t=(e.refMapStr||"").trim();t&&(r.map=JSON.parse(t))}catch{}return r}function c(e,t,r){const s={};for(const t of e)t.name.trim()&&("$ref"===t.op?s[t.name]={$ref:d(t)}:"$count"===t.op?(s[t.name]={$count:!0},t.items&&(s[t.name].items=!0)):(s[t.name]={[t.op]:t.field.trim()||"_id"},t.items&&(s[t.name].items=!0)));const i={reduce:s};try{const e=t.trim();e&&(i.filter=JSON.parse(e))}catch{}try{const e=r.trim();e&&(i.map=JSON.parse(e))}catch{}return i}function l(e){const t=[];for(const[r,s]of Object.entries(e??{})){const e=Object.keys(s).find(e=>"items"!==e)??"$count";t.push({name:r,op:e,field:"string"==typeof s[e]?s[e]:"",items:!!s.items})}return t.length?t:[{name:"count",op:"$count",field:"",items:!1}]}function n(e){const t=e.split(".").pop();return"_id"!==t&&(t.endsWith("Id")||t.endsWith("_id")||t.endsWith("ID"))}function p(e){return e&&Object.keys(e).length?Object.entries(e).map(([e,t])=>({path:e,types:Array.isArray(t)?t:[],hint:n(e)?"fk":Array.isArray(t)&&t.includes("geo")?"geo":null})):[]}export default{tag:"create-view-modal",dependencies:[t,e,r,s],attr:{modal:{},onDone:{}},context:e=>({busy:!1,error:null,name:"",sourceType:"",typesData:[],typesLoading:!1,deepPaths:{},deepPathsLoading:!1,get types(){return this.typesData.map(e=>e.type??e.name??String(e)).filter(e=>"string"==typeof e&&!e.startsWith("~")).sort()},get fieldItems(){return p(this.deepPaths)},get opOptions(){return i},get refOpOptions(){return a},defMode:"visual",definitionStr:'{\n "filter": {},\n "reduce": {\n "count": { "$count": true }\n }\n}',vis_reducers:[Object.assign({name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1},{name:"count",op:"$count"})],vis_filterStr:"",vis_mapStr:"",vis_showFilter:!1,vis_showMap:!1,async loadTypes(){this.typesLoading=!0;try{const t=e.$ok.stores.okdb.apiBase,r=await fetch(`${t}/types`);if(!r.ok)return void(this.typesData=[]);const s=await r.json(),i=s.data??s.result??[];this.typesData=Array.isArray(i)?i:Object.entries(i).map(([e,t])=>({type:e,..."object"==typeof t?t:{}}))}catch{this.typesData=[]}finally{this.typesLoading=!1}},async loadDeepPaths(t){if(t){this.deepPathsLoading=!0;try{const r=e.$ok.stores.okdb.apiBase,s=await fetch(`${r}/type/${encodeURIComponent(t)}/deep-properties`);if(!s.ok)return void(this.deepPaths={});const i=await s.json();this.deepPaths=(i.data??i.result)?.paths??{}}catch{this.deepPaths={}}finally{this.deepPathsLoading=!1}}else this.deepPaths={}},async loadRefDeepPaths(t,r){t.refDeepLoading=!0,this.vis_reducers=[...this.vis_reducers];try{const s=e.$ok.stores.okdb.apiBase,i=await fetch(`${s}/type/${encodeURIComponent(r)}/deep-properties`);if(!i.ok)return void(t.refDeepPaths={});const a=await i.json();t.refDeepPaths=(a.data??a.result)?.paths??{}}catch{t.refDeepPaths={}}finally{t.refDeepLoading=!1,this.vis_reducers=[...this.vis_reducers]}},setSourceType(e){this.sourceType=e,e?this.loadDeepPaths(e):this.deepPaths={}},setDefMode(e){if(e!==this.defMode){if("visual"===this.defMode)this.definitionStr=JSON.stringify(c(this.vis_reducers,this.vis_filterStr,this.vis_mapStr),null,2);else try{const e=function(e){const t=[];for(const[r,s]of Object.entries(e.reduce??{}))if(s.$ref){const e=s.$ref;t.push({name:r,op:"$ref",field:"",items:!1,isRef:!0,refExpanded:!0,refType:e.type||"",refKey:e.key||"",refDeepPaths:{},refDeepLoading:!1,refReducers:l(e.reduce),refFilterStr:e.filter?JSON.stringify(e.filter,null,2):"",refMapStr:e.map?JSON.stringify(e.map,null,2):"",refShowFilter:!!e.filter&&Object.keys(e.filter).length>0,refShowMap:!!e.map&&Object.keys(e.map).length>0})}else{const e=Object.keys(s).find(e=>"items"!==e)??"$count";t.push({name:r,op:e,field:"string"==typeof s[e]?s[e]:"",items:!!s.items,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[o()],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1})}return t.length||t.push({name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1}),{reducers:t,filterStr:e.filter&&Object.keys(e.filter).length?JSON.stringify(e.filter,null,2):"",mapStr:e.map&&Object.keys(e.map).length?JSON.stringify(e.map,null,2):""}}(JSON.parse(this.definitionStr));this.vis_reducers=e.reducers,this.vis_filterStr=e.filterStr,this.vis_mapStr=e.mapStr;for(const e of this.vis_reducers)"$ref"===e.op&&e.refType&&this.loadRefDeepPaths(e,e.refType)}catch{}this.defMode=e}},addReducer(){this.vis_reducers=[...this.vis_reducers,{name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1}]},removeReducer(e){this.vis_reducers=this.vis_reducers.filter(t=>t!==e)},setReducerProp(e,t,r){e[t]=r,"op"===t&&(e.isRef="$ref"===r,"$ref"!==r?e.refExpanded=!1:e.refExpanded||(e.refExpanded=!0)),this.vis_reducers=[...this.vis_reducers]},toggleRefExpand(e){e.refExpanded=!e.refExpanded,this.vis_reducers=[...this.vis_reducers]},setRefProp(e,t,r){e[t]=r,this.vis_reducers=[...this.vis_reducers]},setRefType(e,t){e.refType=t,e.refKey="",e.refDeepPaths={},this.vis_reducers=[...this.vis_reducers],t&&this.loadRefDeepPaths(e,t)},refFieldItems:e=>p(e.refDeepPaths),addRefReducer(e){e.refReducers=[...e.refReducers||[],{name:"count",op:"$count",field:"",items:!1}],this.vis_reducers=[...this.vis_reducers]},removeRefReducer(e,t){e.refReducers=e.refReducers.filter(e=>e!==t),this.vis_reducers=[...this.vis_reducers]},setRefReducerProp(e,t,r,s){t[r]=s,e.refReducers=[...e.refReducers],this.vis_reducers=[...this.vis_reducers]},opNeedsField:e=>i.find(t=>t.value===e)?.needsField??!1,opIsRef:e=>"$ref"===e,async create(){this.error=null;const t=this.name.trim();if(!t)return void(this.error="Name is required");if(/\s/.test(t)||t.startsWith("~"))return void(this.error="Name must not contain spaces or start with ~");const r=this.sourceType.trim();if(!r)return void(this.error="Source type is required");let s;if("visual"===this.defMode)s=c(this.vis_reducers,this.vis_filterStr,this.vis_mapStr);else try{s=JSON.parse(this.definitionStr)}catch(e){return void(this.error="Invalid JSON in definition: "+e.message)}if(s.type=r,s.reduce&&Object.keys(s.reduce).length){this.busy=!0;try{const r=e.$ok.stores.okdb.apiBase,i=await fetch(`${r}/views`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,...s})});if(!i.ok){const e=await i.json().catch(()=>({}));throw new Error(e?.error?.message??"Create failed")}e.$attr.onDone?.(t),e.$attr.modal?.close?.()}catch(t){this.error=t.message,e.$ok.shared?.toast?.error?.(this.error)}finally{this.busy=!1}}else this.error="At least one reducer is required"},cancel(){e.$attr.modal?.close?.()}}),mount(e){this.loadTypes()},style:".cvm-body,create-view-modal{display:flex;flex-direction:column}.cvm-body{gap:14px}.cvm-row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}.cvm-field{display:flex;flex-direction:column;gap:5px}.cvm-label{font-size:11px;color:var(--okdb-app-fg-muted);text-transform:uppercase;letter-spacing:.07em;font-weight:600}.cvm-card{padding:14px;border:1px solid var(--okdb-app-border);border-radius:10px;background:var(--okdb-app-bg-deep);display:flex;flex-direction:column;gap:10px}.cvm-input{padding:7px 9px}.cvm-input,.cvm-select{border:1px solid var(--okdb-app-border);border-radius:6px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;font-family:inherit;box-sizing:border-box;width:100%}.cvm-input:focus,.cvm-select:focus{outline:0;border-color:var(--okdb-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--okdb-accent) 12%,transparent)}.cvm-select{cursor:pointer;appearance:none;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 8px center;padding:7px 28px 7px 9px}.cvm-error,.cvm-hint{font-size:11px;color:var(--okdb-app-fg-subtle);line-height:1.45}.cvm-error{font-size:12px;color:var(--okdb-danger);padding:9px 12px;background:color-mix(in srgb,var(--okdb-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 25%,transparent);border-radius:6px}.cvm-actions{display:flex;gap:8px;justify-content:flex-end}.cvm-btn{padding:7px 16px;border-radius:6px;border:1px solid;background:0 0;font-size:12px;font-weight:500;cursor:pointer;transition:all .15s ease}.cvm-btn--primary{border-color:var(--okdb-accent);color:var(--okdb-accent)}.cvm-btn--primary:hover{background:color-mix(in srgb,var(--okdb-accent) 12%,transparent)}.cvm-btn--secondary{border-color:var(--okdb-app-border);color:var(--okdb-app-fg-muted)}.cvm-btn--secondary:hover{border-color:var(--okdb-accent);color:var(--okdb-app-fg);background:color-mix(in srgb,var(--okdb-accent) 6%,transparent)}.cvm-btn:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.cvm-def-header{display:flex;align-items:center;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid var(--okdb-app-border)}.cvm-mode-toggle{display:flex;border:1px solid var(--okdb-app-border);border-radius:5px;overflow:hidden}.cvm-mode-btn{padding:4px 12px;border:0;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;font-weight:500;cursor:pointer;transition:all .15s ease}.cvm-mode-btn+.cvm-mode-btn{border-left:1px solid var(--okdb-app-border)}.cvm-mode-btn--active{background:color-mix(in srgb,var(--okdb-accent) 14%,transparent);color:var(--okdb-accent)}.cvm-visual{display:flex;flex-direction:column;gap:12px}.cvm-section-hdr{font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:.07em;font-weight:600;margin-bottom:6px}.cvm-col-headers{display:flex;gap:6px;margin-bottom:3px}.cvm-col-h{font-size:10px;color:var(--okdb-app-fg-subtle)}.cvm-reducer-list{display:flex;flex-direction:column;gap:5px}.cvm-reducer-row{display:flex;align-items:center;gap:6px}.cvm-r-key,.cvm-r-op{flex:0 0 136px;min-width:0}.cvm-r-op{flex:0 0 106px}.cvm-r-field{flex:1;min-width:0}.cvm-r-del,.cvm-r-items{display:flex;align-items:center}.cvm-r-items{flex:0 0 58px;gap:4px;font-size:11px;color:var(--okdb-app-fg-muted);user-select:none;cursor:pointer}.cvm-r-del{flex:0 0 22px;justify-content:center}.cvm-r-del-btn{background:0 0;border:0;cursor:pointer;color:var(--okdb-app-fg-subtle);padding:3px 4px;border-radius:3px;font-size:14px;line-height:1;transition:color .12s ease}.cvm-r-del-btn:hover{color:var(--okdb-danger)}.cvm-r-ref-btn{flex:1;padding:7px 10px;border:1px dashed var(--okdb-app-border);border-radius:6px;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;text-align:left;min-width:0;transition:all .12s ease}.cvm-add-reducer-btn:hover,.cvm-r-ref-btn:hover{border-color:var(--okdb-accent);color:var(--okdb-accent)}.cvm-r-spacer{flex:0 0 58px}.cvm-add-reducer-btn{align-self:flex-start;padding:4px 12px;border:1px dashed var(--okdb-app-border);border-radius:5px;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;margin-top:4px;transition:all .12s ease}.cvm-optional-hdr{display:flex;align-items:center;gap:8px}.cvm-opt-toggle{background:0 0;border:0;cursor:pointer;font-size:11px;color:var(--okdb-app-fg-muted);padding:0;text-decoration:underline dotted;transition:color .12s ease}.cvm-opt-toggle:hover{color:var(--okdb-accent)}.cvm-opt-textarea{width:100%;height:64px;resize:vertical;font-size:11px;font-family:monospace;margin-top:6px}.cvm-ref-body,.cvm-ref-section{display:flex;flex-direction:column}.cvm-ref-body{margin-top:5px;padding:12px;background:color-mix(in srgb,var(--okdb-accent) 3%,var(--okdb-app-bg));border:1px solid var(--okdb-app-border);border-left:2px solid color-mix(in srgb,var(--okdb-accent) 45%,var(--okdb-app-border));border-radius:6px;gap:10px}.cvm-ref-section{gap:6px}.cvm-ref-section-hdr{font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-bottom:4px}.cvm-json-wrap{height:260px;border:1px solid var(--okdb-app-border);border-radius:8px;overflow:hidden}",template:'<div style="position:relative; width:min(820px,calc(100vw - 60px)); min-width:600px;"><if :="busy"><ok-overlay><div class="loader-center"><ok-icon name="loader" class="spin" style="width:32px;height:32px;"/></div></ok-overlay></if><div class="cvm-body"><div class="cvm-row2"><div class="cvm-field"><label class="cvm-label">Name</label> <input type="text" class="cvm-input" placeholder="e.g. active_users" :value="name" @input="name = $el.value" :disabled="busy"><div class="cvm-hint">No spaces · no ~ prefix</div></div><div class="cvm-field"><label class="cvm-label">Source Type</label><if :="typesLoading"><select class="cvm-select" disabled="disabled"><option>Loading types…</option></select></if><if :="!typesLoading && types.length > 0"><select class="cvm-select" :value="sourceType" @change="setSourceType($event.target.value)" :disabled="busy"><option value="">— select a type —</option><each :of="types" let:item="t"><option :value="t">{{ t }}</option></each></select></if><if :="!typesLoading && types.length === 0"><input type="text" class="cvm-input" placeholder="e.g. user" :value="sourceType" @input="setSourceType($el.value)" :disabled="busy"></if><div class="cvm-hint">OKDB type this view aggregates</div></div></div><div class="cvm-card"><div class="cvm-def-header"><span class="cvm-label" style="margin:0;">Definition</span><div class="cvm-mode-toggle"><button :class="defMode === \'visual\' ? \'cvm-mode-btn cvm-mode-btn--active\' : \'cvm-mode-btn\'" @click="setDefMode(\'visual\')">Visual</button> <button :class="defMode === \'json\' ? \'cvm-mode-btn cvm-mode-btn--active\' : \'cvm-mode-btn\'" @click="setDefMode(\'json\')">JSON</button></div></div><if :="defMode === \'visual\'"><div class="cvm-visual"><div><div class="cvm-section-hdr">Reducers</div><div class="cvm-col-headers"><span class="cvm-col-h" style="flex:0 0 136px;">Output key</span> <span class="cvm-col-h" style="flex:0 0 106px;">Operation</span> <span class="cvm-col-h" style="flex:1;">Field</span> <span class="cvm-col-h" style="flex:0 0 58px;">Items</span> <span style="flex:0 0 22px;"/></div><div class="cvm-reducer-list"><each :of="vis_reducers" let:item="r"><div><div class="cvm-reducer-row"><input type="text" class="cvm-input cvm-r-key" placeholder="key" :value="r.name" @input="setReducerProp(r, \'name\', $el.value)" :disabled="busy"> <select class="cvm-select cvm-r-op" :value="r.op" @change="setReducerProp(r, \'op\', $event.target.value)" :disabled="busy"><each :of="opOptions" let:item="o"><option :value="o.value">{{ o.label }}</option></each></select><if :="opNeedsField(r.op)"><div class="cvm-r-field"><field-path-input :value="r.field" :items="fieldItems" placeholder="field name" allow-custom="true" @change="setReducerProp(r, \'field\', $event.detail)"></div></if><if :="opIsRef(r.op)"><button class="cvm-r-ref-btn cvm-r-field" @click="toggleRefExpand(r)" :disabled="busy">{{ r.refExpanded ? \'▴ collapse\' : \'▾ configure $ref\' }}</button></if><if :="!opNeedsField(r.op) && !opIsRef(r.op)"><div class="cvm-r-field" style="padding:7px 9px;font-size:11px;color:var(--okdb-app-fg-subtle);opacity:0.35;">—</div></if><if :="!opIsRef(r.op)"><label class="cvm-r-items" title="Include individual matching items in the result"><input type="checkbox" :checked="r.items" @change="setReducerProp(r, \'items\', $event.target.checked)" :disabled="busy"> <span>items</span></label></if><if :="opIsRef(r.op)"><div class="cvm-r-spacer"/></if><div class="cvm-r-del"><button class="cvm-r-del-btn" @click="removeReducer(r)" :disabled="busy" title="Remove">×</button></div></div><if :="opIsRef(r.op) && r.refExpanded"><div class="cvm-ref-body"><div class="cvm-row2"><div class="cvm-field"><label class="cvm-label">Child Type</label><if :="types.length > 0"><select class="cvm-select" :value="r.refType" @change="setRefType(r, $event.target.value)" :disabled="busy"><option value="">— select type —</option><each :of="types" let:item="t"><option :value="t">{{ t }}</option></each></select></if><if :="types.length === 0"><input type="text" class="cvm-input" placeholder="e.g. order" :value="r.refType" @input="setRefType(r, $el.value)" :disabled="busy"></if></div><div class="cvm-field"><label class="cvm-label">Key Field <span style="font-size:10px;font-weight:400;color:var(--okdb-app-fg-subtle);margin-left:4px;">on child · fk fields highlighted</span></label><field-path-input :value="r.refKey" :items="refFieldItems(r)" placeholder="FK field on child type" allow-custom="true" @change="setRefProp(r, \'refKey\', $event.detail)"><div class="cvm-hint">Field on the child collection that references this document\'s _id</div></div></div><div class="cvm-ref-section"><div class="cvm-ref-section-hdr">Reducers</div><div class="cvm-col-headers"><span class="cvm-col-h" style="flex:0 0 136px;">Output key</span> <span class="cvm-col-h" style="flex:0 0 106px;">Operation</span> <span class="cvm-col-h" style="flex:1;">Field</span> <span class="cvm-col-h" style="flex:0 0 58px;">Items</span> <span style="flex:0 0 22px;"/></div><div class="cvm-reducer-list"><each :of="r.refReducers" let:item="cr"><div class="cvm-reducer-row"><input type="text" class="cvm-input cvm-r-key" placeholder="key" :value="cr.name" @input="setRefReducerProp(r, cr, \'name\', $el.value)" :disabled="busy"> <select class="cvm-select cvm-r-op" :value="cr.op" @change="setRefReducerProp(r, cr, \'op\', $event.target.value)" :disabled="busy"><each :of="refOpOptions" let:item="o"><option :value="o.value">{{ o.label }}</option></each></select><if :="opNeedsField(cr.op)"><div class="cvm-r-field"><field-path-input :value="cr.field" :items="refFieldItems(r)" placeholder="field name" allow-custom="true" @change="setRefReducerProp(r, cr, \'field\', $event.detail)"></div></if><if :="!opNeedsField(cr.op)"><div class="cvm-r-field" style="padding:7px 9px;font-size:11px;color:var(--okdb-app-fg-subtle);opacity:0.35;">—</div></if><label class="cvm-r-items" title="Include individual matching items"><input type="checkbox" :checked="cr.items" @change="setRefReducerProp(r, cr, \'items\', $event.target.checked)" :disabled="busy"> <span>items</span></label><div class="cvm-r-del"><button class="cvm-r-del-btn" @click="removeRefReducer(r, cr)" :disabled="busy" title="Remove">×</button></div></div></each></div><button class="cvm-add-reducer-btn" @click="addRefReducer(r)" :disabled="busy">+ Add reducer</button></div><div><div class="cvm-optional-hdr"><span class="cvm-ref-section-hdr" style="margin:0;">Filter</span> <button class="cvm-opt-toggle" @click="setRefProp(r, \'refShowFilter\', !r.refShowFilter)">{{ r.refShowFilter ? \'hide\' : (r.refFilterStr ? \'edit\' : \'add\') }}</button></div><if :="r.refShowFilter"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "status": "active" }\' :value="r.refFilterStr" @input="setRefProp(r, \'refFilterStr\', $el.value)" :disabled="busy"/><div class="cvm-hint" style="margin-top:3px;">Sift query — filters child records before aggregation</div></if></div><div><div class="cvm-optional-hdr"><span class="cvm-ref-section-hdr" style="margin:0;">Map</span> <button class="cvm-opt-toggle" @click="setRefProp(r, \'refShowMap\', !r.refShowMap)">{{ r.refShowMap ? \'hide\' : (r.refMapStr ? \'edit\' : \'add\') }}</button></div><if :="r.refShowMap"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "total": "$count" }\' :value="r.refMapStr" @input="setRefProp(r, \'refMapStr\', $el.value)" :disabled="busy"/><div class="cvm-hint" style="margin-top:3px;">Optional value transformation applied after child reduce</div></if></div></div></if></div></each></div><button class="cvm-add-reducer-btn" @click="addReducer()" :disabled="busy">+ Add reducer</button></div><div><div class="cvm-optional-hdr"><span class="cvm-section-hdr" style="margin:0;">Filter</span> <button class="cvm-opt-toggle" @click="vis_showFilter = !vis_showFilter">{{ vis_showFilter ? \'hide\' : (vis_filterStr ? \'edit\' : \'add\') }}</button></div><if :="vis_showFilter"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "status": "active" }\' :value="vis_filterStr" @input="vis_filterStr = $el.value" :disabled="busy"/><div class="cvm-hint">Sift query — only matching records are included in the aggregation</div></if></div><div><div class="cvm-optional-hdr"><span class="cvm-section-hdr" style="margin:0;">Map</span> <button class="cvm-opt-toggle" @click="vis_showMap = !vis_showMap">{{ vis_showMap ? \'hide\' : (vis_mapStr ? \'edit\' : \'add\') }}</button></div><if :="vis_showMap"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "total": "$count" }\' :value="vis_mapStr" @input="vis_mapStr = $el.value" :disabled="busy"/><div class="cvm-hint">Optional value transformation applied after reduce</div></if></div></div></if><if :="defMode === \'json\'"><div class="cvm-json-wrap"><ok-text-monaco-input style="height:100%;" language="json" :value="definitionStr" @edit="definitionStr = $event.detail"/></div><div class="cvm-hint">filter · reduce · map — <code>type</code> is injected from Source Type above</div></if></div><if :="error"><div class="cvm-error">{{ error }}</div></if><div class="cvm-actions"><button class="cvm-btn cvm-btn--secondary" @click="cancel()" :disabled="busy">Cancel</button> <button class="cvm-btn cvm-btn--primary" @click="create()" :disabled="busy || !name.trim() || !sourceType.trim()">Create View</button></div></div></div>'};
|
|
1
|
+
import e from"@toolkit/elements/ok-icon.ok.js";import t from"@toolkit/core/ok-overlay.ok.js";import r from"../../../components/ok-text-monaco-input.ok.js";import i from"../../../components/field-path-input.ok.js";const s=[{value:"$count",label:"$count",needsField:!1,isRef:!1},{value:"$sum",label:"$sum",needsField:!0,isRef:!1},{value:"$avg",label:"$avg",needsField:!0,isRef:!1},{value:"$min",label:"$min",needsField:!0,isRef:!1},{value:"$max",label:"$max",needsField:!0,isRef:!1},{value:"$countBy",label:"$countBy",needsField:!0,isRef:!1},{value:"$ref",label:"$ref",needsField:!1,isRef:!0}],a=s.filter(e=>!e.isRef);function o(){return{name:"count",op:"$count",field:"",items:!1}}function c(e){const t={};for(const r of e.refReducers||[])r.name.trim()&&("$count"===r.op?(t[r.name]={$count:!0},r.items&&(t[r.name].items=!0)):(t[r.name]={[r.op]:r.field.trim()||"_id"},r.items&&(t[r.name].items=!0)));const r={type:e.refType||"",key:e.refKey||""};Object.keys(t).length&&(r.reduce=t);try{const t=(e.refFilterStr||"").trim();t&&(r.filter=JSON.parse(t))}catch{}try{const t=(e.refMapStr||"").trim();t&&(r.map=JSON.parse(t))}catch{}return r}function d(e,t,r,i){const s={};for(const t of e)t.name.trim()&&("$ref"===t.op?s[t.name]={$ref:c(t)}:"$count"===t.op?(s[t.name]={$count:!0},t.items&&(s[t.name].items=!0)):(s[t.name]={[t.op]:t.field.trim()||"_id"},t.items&&(s[t.name].items=!0)));const a={reduce:s};try{const e=t.trim();e&&(a.filter=JSON.parse(e))}catch{}try{const e=r.trim();e&&(a.map=JSON.parse(e))}catch{}return i&&i.enabled&&(a.bucket={field:i.field.trim(),preset:"time",granularity:i.granularity,timezone:i.timezone.trim()||"UTC"}),a}function l(e){const t=[];for(const[r,i]of Object.entries(e??{})){const e=Object.keys(i).find(e=>"items"!==e)??"$count";t.push({name:r,op:e,field:"string"==typeof i[e]?i[e]:"",items:!!i.items})}return t.length?t:[{name:"count",op:"$count",field:"",items:!1}]}function n(e){const t=e.split(".").pop();return"_id"!==t&&(t.endsWith("Id")||t.endsWith("_id")||t.endsWith("ID"))}function p(e){return e&&Object.keys(e).length?Object.entries(e).map(([e,t])=>({path:e,types:Array.isArray(t)?t:[],hint:n(e)?"fk":Array.isArray(t)&&t.includes("geo")?"geo":null})):[]}export default{tag:"create-view-modal",dependencies:[t,e,r,i],attr:{modal:{},onDone:{}},context:e=>({busy:!1,error:null,name:"",sourceType:"",typesData:[],typesLoading:!1,deepPaths:{},deepPathsLoading:!1,get types(){return this.typesData.map(e=>e.type??e.name??String(e)).filter(e=>"string"==typeof e&&!e.startsWith("~")).sort()},get fieldItems(){return p(this.deepPaths)},get opOptions(){return s},get refOpOptions(){return a},defMode:"visual",definitionStr:'{\n "filter": {},\n "reduce": {\n "count": { "$count": true }\n }\n}',vis_reducers:[Object.assign({name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1},{name:"count",op:"$count"})],vis_filterStr:"",vis_mapStr:"",vis_showFilter:!1,vis_showMap:!1,bucketEnabled:!1,bucketField:"",bucketGranularity:"day",bucketTimezone:"UTC",bucketError:null,async loadTypes(){this.typesLoading=!0;try{const t=e.$ok.stores.okdb.apiBase,r=await fetch(`${t}/types`);if(!r.ok)return void(this.typesData=[]);const i=await r.json(),s=i.data??i.result??[];this.typesData=Array.isArray(s)?s:Object.entries(s).map(([e,t])=>({type:e,..."object"==typeof t?t:{}}))}catch{this.typesData=[]}finally{this.typesLoading=!1}},async loadDeepPaths(t){if(t){this.deepPathsLoading=!0;try{const r=e.$ok.stores.okdb.apiBase,i=await fetch(`${r}/type/${encodeURIComponent(t)}/deep-properties`);if(!i.ok)return void(this.deepPaths={});const s=await i.json();this.deepPaths=(s.data??s.result)?.paths??{}}catch{this.deepPaths={}}finally{this.deepPathsLoading=!1}}else this.deepPaths={}},async loadRefDeepPaths(t,r){t.refDeepLoading=!0,this.vis_reducers=[...this.vis_reducers];try{const i=e.$ok.stores.okdb.apiBase,s=await fetch(`${i}/type/${encodeURIComponent(r)}/deep-properties`);if(!s.ok)return void(t.refDeepPaths={});const a=await s.json();t.refDeepPaths=(a.data??a.result)?.paths??{}}catch{t.refDeepPaths={}}finally{t.refDeepLoading=!1,this.vis_reducers=[...this.vis_reducers]}},setSourceType(e){this.sourceType=e,e?this.loadDeepPaths(e):this.deepPaths={}},setDefMode(e){if(e!==this.defMode){if("visual"===this.defMode)this.definitionStr=JSON.stringify(d(this.vis_reducers,this.vis_filterStr,this.vis_mapStr,{enabled:this.bucketEnabled,field:this.bucketField,granularity:this.bucketGranularity,timezone:this.bucketTimezone}),null,2);else try{const e=JSON.parse(this.definitionStr),t=function(e){const t=[];for(const[r,i]of Object.entries(e.reduce??{}))if(i.$ref){const e=i.$ref;t.push({name:r,op:"$ref",field:"",items:!1,isRef:!0,refExpanded:!0,refType:e.type||"",refKey:e.key||"",refDeepPaths:{},refDeepLoading:!1,refReducers:l(e.reduce),refFilterStr:e.filter?JSON.stringify(e.filter,null,2):"",refMapStr:e.map?JSON.stringify(e.map,null,2):"",refShowFilter:!!e.filter&&Object.keys(e.filter).length>0,refShowMap:!!e.map&&Object.keys(e.map).length>0})}else{const e=Object.keys(i).find(e=>"items"!==e)??"$count";t.push({name:r,op:e,field:"string"==typeof i[e]?i[e]:"",items:!!i.items,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[o()],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1})}return t.length||t.push({name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1}),{reducers:t,filterStr:e.filter&&Object.keys(e.filter).length?JSON.stringify(e.filter,null,2):"",mapStr:e.map&&Object.keys(e.map).length?JSON.stringify(e.map,null,2):""}}(e);this.vis_reducers=t.reducers,this.vis_filterStr=t.filterStr,this.vis_mapStr=t.mapStr,e.bucket?(this.bucketEnabled=!0,this.bucketField=e.bucket.field||"",this.bucketGranularity=e.bucket.granularity||"day",this.bucketTimezone=e.bucket.timezone||"UTC"):(this.bucketEnabled=!1,this.bucketField="",this.bucketGranularity="day",this.bucketTimezone="UTC");for(const e of this.vis_reducers)"$ref"===e.op&&e.refType&&this.loadRefDeepPaths(e,e.refType)}catch{}this.defMode=e}},addReducer(){this.vis_reducers=[...this.vis_reducers,{name:"",op:"$count",field:"",items:!1,isRef:!1,refExpanded:!1,refType:"",refKey:"",refDeepPaths:{},refDeepLoading:!1,refReducers:[{name:"count",op:"$count",field:"",items:!1}],refFilterStr:"",refMapStr:"",refShowFilter:!1,refShowMap:!1}]},removeReducer(e){this.vis_reducers=this.vis_reducers.filter(t=>t!==e)},setReducerProp(e,t,r){e[t]=r,"op"===t&&(e.isRef="$ref"===r,"$ref"!==r?e.refExpanded=!1:e.refExpanded||(e.refExpanded=!0)),this.vis_reducers=[...this.vis_reducers]},toggleRefExpand(e){e.refExpanded=!e.refExpanded,this.vis_reducers=[...this.vis_reducers]},setRefProp(e,t,r){e[t]=r,this.vis_reducers=[...this.vis_reducers]},setRefType(e,t){e.refType=t,e.refKey="",e.refDeepPaths={},this.vis_reducers=[...this.vis_reducers],t&&this.loadRefDeepPaths(e,t)},refFieldItems:e=>p(e.refDeepPaths),addRefReducer(e){e.refReducers=[...e.refReducers||[],{name:"count",op:"$count",field:"",items:!1}],this.vis_reducers=[...this.vis_reducers]},removeRefReducer(e,t){e.refReducers=e.refReducers.filter(e=>e!==t),this.vis_reducers=[...this.vis_reducers]},setRefReducerProp(e,t,r,i){t[r]=i,e.refReducers=[...e.refReducers],this.vis_reducers=[...this.vis_reducers]},opNeedsField:e=>s.find(t=>t.value===e)?.needsField??!1,opIsRef:e=>"$ref"===e,async create(){this.error=null,this.bucketError=null;const t=this.name.trim();if(!t)return void(this.error="Name is required");if(/\s/.test(t)||t.startsWith("~"))return void(this.error="Name must not contain spaces or start with ~");const r=this.sourceType.trim();if(!r)return void(this.error="Source type is required");const i=["minute","hour","day","week","month","quarter","year"];let s;if("visual"===this.defMode){if(this.bucketEnabled){if(!this.bucketField.trim())return void(this.bucketError="Bucket field path is required");if(!i.includes(this.bucketGranularity))return void(this.bucketError="Invalid granularity");if(!this.bucketTimezone.trim())return void(this.bucketError="Timezone is required")}s=d(this.vis_reducers,this.vis_filterStr,this.vis_mapStr,{enabled:this.bucketEnabled,field:this.bucketField,granularity:this.bucketGranularity,timezone:this.bucketTimezone})}else try{s=JSON.parse(this.definitionStr)}catch(e){return void(this.error="Invalid JSON in definition: "+e.message)}if(s.type=r,s.reduce&&Object.keys(s.reduce).length){this.busy=!0;try{const r=e.$ok.stores.okdb.apiBase,i=await fetch(`${r}/views`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,...s})});if(!i.ok){const e=await i.json().catch(()=>({}));throw new Error(e?.error?.message??"Create failed")}e.$attr.onDone?.(t),e.$attr.modal?.close?.()}catch(t){this.error=t.message,e.$ok.shared?.toast?.error?.(this.error)}finally{this.busy=!1}}else this.error="At least one reducer is required"},setBucketEnabled(e){this.bucketEnabled=e,this.bucketError=null},cancel(){e.$attr.modal?.close?.()}}),mount(e){this.loadTypes()},style:".cvm-body,create-view-modal{display:flex;flex-direction:column}.cvm-body{gap:14px}.cvm-row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}.cvm-field{display:flex;flex-direction:column;gap:5px}.cvm-label{font-size:11px;color:var(--okdb-app-fg-muted);text-transform:uppercase;letter-spacing:.07em;font-weight:600}.cvm-card{padding:14px;border:1px solid var(--okdb-app-border);border-radius:10px;background:var(--okdb-app-bg-deep);display:flex;flex-direction:column;gap:10px}.cvm-input{padding:7px 9px}.cvm-input,.cvm-select{border:1px solid var(--okdb-app-border);border-radius:6px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;font-family:inherit;box-sizing:border-box;width:100%}.cvm-input:focus,.cvm-select:focus{outline:0;border-color:var(--okdb-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--okdb-accent) 12%,transparent)}.cvm-select{cursor:pointer;appearance:none;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 8px center;padding:7px 28px 7px 9px}.cvm-error,.cvm-hint{font-size:11px;color:var(--okdb-app-fg-subtle);line-height:1.45}.cvm-error{font-size:12px;color:var(--okdb-danger);padding:9px 12px;background:color-mix(in srgb,var(--okdb-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 25%,transparent);border-radius:6px}.cvm-actions{display:flex;gap:8px;justify-content:flex-end}.cvm-btn{padding:7px 16px;border-radius:6px;border:1px solid;background:0 0;font-size:12px;font-weight:500;cursor:pointer;transition:all .15s ease}.cvm-btn--primary{border-color:var(--okdb-accent);color:var(--okdb-accent)}.cvm-btn--primary:hover{background:color-mix(in srgb,var(--okdb-accent) 12%,transparent)}.cvm-btn--secondary{border-color:var(--okdb-app-border);color:var(--okdb-app-fg-muted)}.cvm-btn--secondary:hover{border-color:var(--okdb-accent);color:var(--okdb-app-fg);background:color-mix(in srgb,var(--okdb-accent) 6%,transparent)}.cvm-btn:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.cvm-def-header{display:flex;align-items:center;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid var(--okdb-app-border)}.cvm-mode-toggle{display:flex;border:1px solid var(--okdb-app-border);border-radius:5px;overflow:hidden}.cvm-mode-btn{padding:4px 12px;border:0;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;font-weight:500;cursor:pointer;transition:all .15s ease}.cvm-mode-btn+.cvm-mode-btn{border-left:1px solid var(--okdb-app-border)}.cvm-mode-btn--active{background:color-mix(in srgb,var(--okdb-accent) 14%,transparent);color:var(--okdb-accent)}.cvm-visual{display:flex;flex-direction:column;gap:12px}.cvm-section-hdr{font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:.07em;font-weight:600;margin-bottom:6px}.cvm-col-headers{display:flex;gap:6px;margin-bottom:3px}.cvm-col-h{font-size:10px;color:var(--okdb-app-fg-subtle)}.cvm-reducer-list{display:flex;flex-direction:column;gap:5px}.cvm-reducer-row{display:flex;align-items:center;gap:6px}.cvm-r-key,.cvm-r-op{flex:0 0 136px;min-width:0}.cvm-r-op{flex:0 0 106px}.cvm-r-field{flex:1;min-width:0}.cvm-r-del,.cvm-r-items{display:flex;align-items:center}.cvm-r-items{flex:0 0 58px;gap:4px;font-size:11px;color:var(--okdb-app-fg-muted);user-select:none;cursor:pointer}.cvm-r-del{flex:0 0 22px;justify-content:center}.cvm-r-del-btn{background:0 0;border:0;cursor:pointer;color:var(--okdb-app-fg-subtle);padding:3px 4px;border-radius:3px;font-size:14px;line-height:1;transition:color .12s ease}.cvm-r-del-btn:hover{color:var(--okdb-danger)}.cvm-r-ref-btn{flex:1;padding:7px 10px;border:1px dashed var(--okdb-app-border);border-radius:6px;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;text-align:left;min-width:0;transition:all .12s ease}.cvm-add-reducer-btn:hover,.cvm-r-ref-btn:hover{border-color:var(--okdb-accent);color:var(--okdb-accent)}.cvm-r-spacer{flex:0 0 58px}.cvm-add-reducer-btn{align-self:flex-start;padding:4px 12px;border:1px dashed var(--okdb-app-border);border-radius:5px;background:0 0;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;margin-top:4px;transition:all .12s ease}.cvm-optional-hdr{display:flex;align-items:center;gap:8px}.cvm-opt-toggle{background:0 0;border:0;cursor:pointer;font-size:11px;color:var(--okdb-app-fg-muted);padding:0;text-decoration:underline dotted;transition:color .12s ease}.cvm-opt-toggle:hover{color:var(--okdb-accent)}.cvm-opt-textarea{width:100%;height:64px;resize:vertical;font-size:11px;font-family:monospace;margin-top:6px}.cvm-ref-body,.cvm-ref-section{display:flex;flex-direction:column}.cvm-ref-body{margin-top:5px;padding:12px;background:color-mix(in srgb,var(--okdb-accent) 3%,var(--okdb-app-bg));border:1px solid var(--okdb-app-border);border-left:2px solid color-mix(in srgb,var(--okdb-accent) 45%,var(--okdb-app-border));border-radius:6px;gap:10px}.cvm-ref-section{gap:6px}.cvm-ref-section-hdr{font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-bottom:4px}.cvm-json-wrap{height:260px;border:1px solid var(--okdb-app-border);border-radius:8px;overflow:hidden}",template:'<div style="position:relative; width:min(820px,calc(100vw - 60px)); min-width:600px;"><if :="busy"><ok-overlay><div class="loader-center"><ok-icon name="loader" class="spin" style="width:32px;height:32px;"/></div></ok-overlay></if><div class="cvm-body"><div class="cvm-row2"><div class="cvm-field"><label class="cvm-label">Name</label> <input type="text" class="cvm-input" placeholder="e.g. active_users" :value="name" @input="name = $el.value" :disabled="busy"><div class="cvm-hint">No spaces · no ~ prefix</div></div><div class="cvm-field"><label class="cvm-label">Source Type</label><if :="typesLoading"><select class="cvm-select" disabled="disabled"><option>Loading types…</option></select></if><if :="!typesLoading && types.length > 0"><select class="cvm-select" :value="sourceType" @change="setSourceType($event.target.value)" :disabled="busy"><option value="">— select a type —</option><each :of="types" let:item="t"><option :value="t">{{ t }}</option></each></select></if><if :="!typesLoading && types.length === 0"><input type="text" class="cvm-input" placeholder="e.g. user" :value="sourceType" @input="setSourceType($el.value)" :disabled="busy"></if><div class="cvm-hint">OKDB type this view aggregates</div></div></div><div class="cvm-card"><div class="cvm-def-header"><span class="cvm-label" style="margin:0;">Definition</span><div class="cvm-mode-toggle"><button :class="defMode === \'visual\' ? \'cvm-mode-btn cvm-mode-btn--active\' : \'cvm-mode-btn\'" @click="setDefMode(\'visual\')">Visual</button> <button :class="defMode === \'json\' ? \'cvm-mode-btn cvm-mode-btn--active\' : \'cvm-mode-btn\'" @click="setDefMode(\'json\')">JSON</button></div></div><if :="defMode === \'visual\'"><div class="cvm-visual"><div><div class="cvm-section-hdr">Reducers</div><div class="cvm-col-headers"><span class="cvm-col-h" style="flex:0 0 136px;">Output key</span> <span class="cvm-col-h" style="flex:0 0 106px;">Operation</span> <span class="cvm-col-h" style="flex:1;">Field</span> <span class="cvm-col-h" style="flex:0 0 58px;">Items</span> <span style="flex:0 0 22px;"/></div><div class="cvm-reducer-list"><each :of="vis_reducers" let:item="r"><div><div class="cvm-reducer-row"><input type="text" class="cvm-input cvm-r-key" placeholder="key" :value="r.name" @input="setReducerProp(r, \'name\', $el.value)" :disabled="busy"> <select class="cvm-select cvm-r-op" :value="r.op" @change="setReducerProp(r, \'op\', $event.target.value)" :disabled="busy"><each :of="opOptions" let:item="o"><option :value="o.value">{{ o.label }}</option></each></select><if :="opNeedsField(r.op)"><div class="cvm-r-field"><field-path-input :value="r.field" :items="fieldItems" placeholder="field name" allow-custom="true" @change="setReducerProp(r, \'field\', $event.detail)"></div></if><if :="opIsRef(r.op)"><button class="cvm-r-ref-btn cvm-r-field" @click="toggleRefExpand(r)" :disabled="busy">{{ r.refExpanded ? \'▴ collapse\' : \'▾ configure $ref\' }}</button></if><if :="!opNeedsField(r.op) && !opIsRef(r.op)"><div class="cvm-r-field" style="padding:7px 9px;font-size:11px;color:var(--okdb-app-fg-subtle);opacity:0.35;">—</div></if><if :="!opIsRef(r.op)"><label class="cvm-r-items" title="Include individual matching items in the result"><input type="checkbox" :checked="r.items" @change="setReducerProp(r, \'items\', $event.target.checked)" :disabled="busy"> <span>items</span></label></if><if :="opIsRef(r.op)"><div class="cvm-r-spacer"/></if><div class="cvm-r-del"><button class="cvm-r-del-btn" @click="removeReducer(r)" :disabled="busy" title="Remove">×</button></div></div><if :="opIsRef(r.op) && r.refExpanded"><div class="cvm-ref-body"><div class="cvm-row2"><div class="cvm-field"><label class="cvm-label">Child Type</label><if :="types.length > 0"><select class="cvm-select" :value="r.refType" @change="setRefType(r, $event.target.value)" :disabled="busy"><option value="">— select type —</option><each :of="types" let:item="t"><option :value="t">{{ t }}</option></each></select></if><if :="types.length === 0"><input type="text" class="cvm-input" placeholder="e.g. order" :value="r.refType" @input="setRefType(r, $el.value)" :disabled="busy"></if></div><div class="cvm-field"><label class="cvm-label">Key Field <span style="font-size:10px;font-weight:400;color:var(--okdb-app-fg-subtle);margin-left:4px;">on child · fk fields highlighted</span></label><field-path-input :value="r.refKey" :items="refFieldItems(r)" placeholder="FK field on child type" allow-custom="true" @change="setRefProp(r, \'refKey\', $event.detail)"><div class="cvm-hint">Field on the child collection that references this document\'s _id</div></div></div><div class="cvm-ref-section"><div class="cvm-ref-section-hdr">Reducers</div><div class="cvm-col-headers"><span class="cvm-col-h" style="flex:0 0 136px;">Output key</span> <span class="cvm-col-h" style="flex:0 0 106px;">Operation</span> <span class="cvm-col-h" style="flex:1;">Field</span> <span class="cvm-col-h" style="flex:0 0 58px;">Items</span> <span style="flex:0 0 22px;"/></div><div class="cvm-reducer-list"><each :of="r.refReducers" let:item="cr"><div class="cvm-reducer-row"><input type="text" class="cvm-input cvm-r-key" placeholder="key" :value="cr.name" @input="setRefReducerProp(r, cr, \'name\', $el.value)" :disabled="busy"> <select class="cvm-select cvm-r-op" :value="cr.op" @change="setRefReducerProp(r, cr, \'op\', $event.target.value)" :disabled="busy"><each :of="refOpOptions" let:item="o"><option :value="o.value">{{ o.label }}</option></each></select><if :="opNeedsField(cr.op)"><div class="cvm-r-field"><field-path-input :value="cr.field" :items="refFieldItems(r)" placeholder="field name" allow-custom="true" @change="setRefReducerProp(r, cr, \'field\', $event.detail)"></div></if><if :="!opNeedsField(cr.op)"><div class="cvm-r-field" style="padding:7px 9px;font-size:11px;color:var(--okdb-app-fg-subtle);opacity:0.35;">—</div></if><label class="cvm-r-items" title="Include individual matching items"><input type="checkbox" :checked="cr.items" @change="setRefReducerProp(r, cr, \'items\', $event.target.checked)" :disabled="busy"> <span>items</span></label><div class="cvm-r-del"><button class="cvm-r-del-btn" @click="removeRefReducer(r, cr)" :disabled="busy" title="Remove">×</button></div></div></each></div><button class="cvm-add-reducer-btn" @click="addRefReducer(r)" :disabled="busy">+ Add reducer</button></div><div><div class="cvm-optional-hdr"><span class="cvm-ref-section-hdr" style="margin:0;">Filter</span> <button class="cvm-opt-toggle" @click="setRefProp(r, \'refShowFilter\', !r.refShowFilter)">{{ r.refShowFilter ? \'hide\' : (r.refFilterStr ? \'edit\' : \'add\') }}</button></div><if :="r.refShowFilter"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "status": "active" }\' :value="r.refFilterStr" @input="setRefProp(r, \'refFilterStr\', $el.value)" :disabled="busy"/><div class="cvm-hint" style="margin-top:3px;">Sift query — filters child records before aggregation</div></if></div><div><div class="cvm-optional-hdr"><span class="cvm-ref-section-hdr" style="margin:0;">Map</span> <button class="cvm-opt-toggle" @click="setRefProp(r, \'refShowMap\', !r.refShowMap)">{{ r.refShowMap ? \'hide\' : (r.refMapStr ? \'edit\' : \'add\') }}</button></div><if :="r.refShowMap"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "total": "$count" }\' :value="r.refMapStr" @input="setRefProp(r, \'refMapStr\', $el.value)" :disabled="busy"/><div class="cvm-hint" style="margin-top:3px;">Optional value transformation applied after child reduce</div></if></div></div></if></div></each></div><button class="cvm-add-reducer-btn" @click="addReducer()" :disabled="busy">+ Add reducer</button></div><div><div class="cvm-optional-hdr"><span class="cvm-section-hdr" style="margin:0;">Filter</span> <button class="cvm-opt-toggle" @click="vis_showFilter = !vis_showFilter">{{ vis_showFilter ? \'hide\' : (vis_filterStr ? \'edit\' : \'add\') }}</button></div><if :="vis_showFilter"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "status": "active" }\' :value="vis_filterStr" @input="vis_filterStr = $el.value" :disabled="busy"/><div class="cvm-hint">Sift query — only matching records are included in the aggregation</div></if></div><div><div class="cvm-optional-hdr"><span class="cvm-section-hdr" style="margin:0;">Map</span> <button class="cvm-opt-toggle" @click="vis_showMap = !vis_showMap">{{ vis_showMap ? \'hide\' : (vis_mapStr ? \'edit\' : \'add\') }}</button></div><if :="vis_showMap"><textarea class="cvm-input cvm-opt-textarea" placeholder=\'{ "total": "$count" }\' :value="vis_mapStr" @input="vis_mapStr = $el.value" :disabled="busy"/><div class="cvm-hint">Optional value transformation applied after reduce</div></if></div><div><div class="cvm-optional-hdr"><span class="cvm-section-hdr" style="margin:0;">Bucket</span> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:11px;color:var(--okdb-app-fg-muted);"><input type="checkbox" :checked="bucketEnabled" @change="setBucketEnabled($event.target.checked)" :disabled="busy"> Bucket this view by a time field</label></div><if :="bucketEnabled"><div style="display:flex;flex-direction:column;gap:8px;margin-top:8px;"><div class="cvm-hint">Bucketing partitions records into time buckets at this granularity. Only the <code>time</code> preset is exposed here; for custom projections use the JSON editor.</div><div class="cvm-row2"><div class="cvm-field"><label class="cvm-label">Field path</label><field-path-input :value="bucketField" :items="fieldItems" placeholder="e.g. created_at" allow-custom="true" @change="bucketField = $event.detail"><div class="cvm-hint">Timestamp field on source docs</div></div><div class="cvm-field"><label class="cvm-label">Granularity</label> <select class="cvm-select" :value="bucketGranularity" @change="bucketGranularity = $event.target.value" :disabled="busy"><option value="minute">minute</option><option value="hour">hour</option><option value="day">day</option><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select></div></div><div class="cvm-field"><label class="cvm-label">Timezone</label> <input type="text" class="cvm-input" placeholder="e.g. UTC, America/New_York" :value="bucketTimezone" @input="bucketTimezone = $el.value" :disabled="busy"><div class="cvm-hint">IANA timezone name (e.g. <code>UTC</code>, <code>America/New_York</code>, <code>Europe/London</code>)</div></div><if :="bucketError"><div class="cvm-error">{{ bucketError }}</div></if></div></if></div></div></if><if :="defMode === \'json\'"><div class="cvm-json-wrap"><ok-text-monaco-input style="height:100%;" language="json" :value="definitionStr" @edit="definitionStr = $event.detail"/></div><div class="cvm-hint">filter · reduce · map — <code>type</code> is injected from Source Type above</div></if></div><if :="error"><div class="cvm-error">{{ error }}</div></if><div class="cvm-actions"><button class="cvm-btn cvm-btn--secondary" @click="cancel()" :disabled="busy">Cancel</button> <button class="cvm-btn cvm-btn--primary" @click="create()" :disabled="busy || !name.trim() || !sourceType.trim()">Create View</button></div></div></div>'};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export default{tag:"view-detail",attr:{name:{}},context:e=>({output:null,meta:null,definition:null,loading:!0,error:null,actionLoading:!1,showDeleteConfirm:!1,showOrphanedIndexConfirm:!1,orphanedIndexes:[],async load(){this.loading=!0,this.error=null,this.output=null,this.meta=null,this.definition=null;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,[i,n,o]=await Promise.all([fetch(`${t}/views/${a}`),fetch(`${t}/views/${a}/meta`),fetch(`${t}/views/${a}/definition`)]),d=i.ok?await i.json():null,r=n.ok?await n.json():null,s=o.ok?await o.json():null;this.output=d?.data??d?.result??null,this.meta=r?.data??r?.result??null,this.definition=s?.data??s?.result??null}catch(e){this.error=e.message}finally{this.loading=!1}},async doAction(t){this.actionLoading=!0;try{const a=e.$ok.stores.okdb.apiBase,i=e.$attr.name,n=await fetch(`${a}/views/${i}/${t}`,{method:"POST"});if(!n.ok){const e=await n.json().catch(()=>({}));throw new Error(e?.error?.message??`${t} failed`)}await this.load(),e.$dispatch("state-changed")}catch(e){this.error=e.message}finally{this.actionLoading=!1}},async deleteView(){this.actionLoading=!0;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,i=await fetch(`${t}/views/${a}`,{method:"DELETE"});if(!i.ok){const e=await i.json().catch(()=>({})),t=e?.error?.code;if("VIEW_HAS_ORPHANED_INDEXES"===t)return this.orphanedIndexes=e?.error?.details?.orphanedIndexes??[],this.showDeleteConfirm=!1,void(this.showOrphanedIndexConfirm=!0);throw new Error(e?.error?.message??"Delete failed")}e.$dispatch("removed")}catch(e){this.error=e.message}finally{this.actionLoading=!1,this.showOrphanedIndexConfirm||(this.showDeleteConfirm=!1)}},async deleteViewWithIndexes(t){this.actionLoading=!0;try{const a=e.$ok.stores.okdb.apiBase,i=e.$attr.name,n=await fetch(`${a}/views/${i}`,{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({managedIndexes:t})});if(!n.ok){const e=await n.json().catch(()=>({}));throw new Error(e?.error?.message??"Delete failed")}e.$dispatch("removed")}catch(e){this.error=e.message}finally{this.actionLoading=!1,this.showOrphanedIndexConfirm=!1,this.orphanedIndexes=[]}},formatJson(e){if(null==e)return"";try{return JSON.stringify(e,null,2)}catch{return String(e)}},stateColor(e){switch(e){case"ready":return"#4caf50";case"creating":case"resetting":return"#4FC1FF";case"halted":return"#f44336";case"stopped":return"#fbbf24";default:return"#666"}},get state(){return this.meta?.state??"unknown"},get isInProgress(){return"creating"===this.state||"resetting"===this.state}}),mount(e){e.$watch(()=>e.$attr.name,()=>{this.loading=!0,this.output=null,this.meta=null,this.definition=null,this.showDeleteConfirm=!1,this.showOrphanedIndexConfirm=!1,this.orphanedIndexes=[],this.load()}),this.load()},style:"view-detail{gap:0;height:100%;overflow-y:auto}.vd,.vd__header,view-detail{display:flex;flex-direction:column}.vd{gap:20px;padding:20px 24px}.vd__header{gap:10px;padding-bottom:16px;border-bottom:1px solid var(--okdb-app-border)}.vd__title-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.vd__name,.vd__state-badge{font-weight:600;white-space:nowrap}.vd__name{font-size:20px;color:var(--okdb-app-fg-strong);margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;flex-shrink:1}.vd__state-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:999px;border:1px solid var(--okdb-app-border);font-size:11px;text-transform:uppercase;letter-spacing:.04em}.vd__state-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.vd__type-label{font-size:11px;color:var(--okdb-app-fg-subtle);font-family:monospace}.vd__actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.vd__btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:600;border:1px solid;background:0 0;cursor:pointer;transition:all .15s ease}.vd__btn--primary{border-color:var(--okdb-accent);color:var(--okdb-accent)}.vd__btn--primary:hover{background:color-mix(in srgb,var(--okdb-accent) 10%,transparent);transform:none;box-shadow:none}.vd__btn--warning{border-color:var(--okdb-warning);color:var(--okdb-warning)}.vd__btn--warning:hover{background:color-mix(in srgb,var(--okdb-warning) 10%,transparent);transform:none;box-shadow:none}.vd__btn--danger{border-color:var(--okdb-danger);color:var(--okdb-danger)}.vd__btn--danger:hover{background:color-mix(in srgb,var(--okdb-danger) 10%,transparent);transform:none;box-shadow:none}.vd__btn:disabled{opacity:.4;cursor:not-allowed}.vd__delete-confirm{display:flex;align-items:center;gap:10px;padding:8px 12px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 25%,transparent);border-radius:5px;font-size:12px;color:var(--okdb-danger);flex-wrap:wrap}.vd__delete-confirm-text{flex:1;min-width:0}.vd__delete-confirm-actions{display:flex;gap:6px}.vd__section{display:flex;flex-direction:column;gap:10px}.vd__section-title{font-size:11px;font-weight:600;color:var(--okdb-app-fg-muted);text-transform:uppercase;letter-spacing:.05em}.vd__pre{font-family:monospace;font-size:12px;background:var(--okdb-app-bg-deep);border:1px solid var(--okdb-app-border);border-radius:5px;padding:12px 14px;overflow-x:auto;color:var(--okdb-app-fg);white-space:pre;margin:0;line-height:1.5}.vd__meta-grid{display:flex;flex-direction:column;gap:6px;font-size:12px}.vd__meta-row{display:flex;gap:8px}.vd__meta-key{color:var(--okdb-app-fg-subtle);width:80px;flex-shrink:0}.vd__meta-val{color:var(--okdb-app-fg);font-family:monospace;word-break:break-all}.vd__meta-val--error{color:var(--okdb-danger)}.vd__empty,.vd__error{font-size:12px;color:var(--okdb-app-fg-subtle)}.vd__error{color:var(--okdb-danger);padding:8px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:4px}.vd__loading{font-size:12px;opacity:.5;padding:40px;text-align:center}.vd__refs{display:flex;flex-direction:column;gap:4px;margin-top:4px}.vd__ref-row{display:flex;gap:8px;font-size:11px;padding:4px 8px;background:var(--okdb-app-bg-deep);border-radius:3px;border:1px solid var(--okdb-app-border)}.vd__ref-name{color:var(--okdb-sky);font-family:monospace;flex:1}.vd__ref-state{color:var(--okdb-app-fg-subtle)}",template:'<div class="vd"><if :="loading"><div class="vd__loading">Loading…</div></if><if :="!loading"><if :="error"><div class="vd__error">{{ error }}</div></if><div class="vd__header"><div class="vd__title-row"><div style="flex:1; min-width:0; display:flex; align-items:baseline; gap:8px; overflow:hidden;"><h2 class="vd__name">{{ $attr.name }}</h2><if :="definition != null && definition.type != null && definition.type !== \'\'"><span class="vd__type-label">{{ definition.type }}</span></if></div><span class="vd__state-badge" :style="\'color:\' + stateColor(state)"><span class="vd__state-dot" :style="\'background:\' + stateColor(state)"/> {{ state }}</span></div><div class="vd__actions"><if :="!isInProgress"><if :="state === \'ready\'"><button class="vd__btn vd__btn--warning" :disabled="actionLoading" @click="doAction(\'stop\')">Stop</button></if><if :="state === \'stopped\' || state === \'halted\'"><button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="doAction(\'start\')">Start</button></if><button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="doAction(\'rebuild\')">Rebuild</button> <button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="showDeleteConfirm = true">Delete</button></if><if :="isInProgress"><button class="vd__btn vd__btn--primary" disabled="disabled">Stop</button> <button class="vd__btn vd__btn--primary" disabled="disabled">Rebuild</button> <button class="vd__btn vd__btn--danger" disabled="disabled">Delete</button></if></div><if :="showDeleteConfirm"><div class="vd__delete-confirm"><span class="vd__delete-confirm-text">Delete view <strong>{{ $attr.name }}</strong>? This cannot be undone.</span><div class="vd__delete-confirm-actions"><button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="deleteView()">Confirm</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="showDeleteConfirm = false">Cancel</button></div></div></if><if :="showOrphanedIndexConfirm"><div class="vd__delete-confirm"><span class="vd__delete-confirm-text">Deleting this view will orphan: <strong>{{ orphanedIndexes.join(\', \') }}</strong>. Drop or keep these indexes?</span><div class="vd__delete-confirm-actions"><button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="deleteViewWithIndexes(\'drop\')">Drop</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="deleteViewWithIndexes(\'keep\')">Keep</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="showOrphanedIndexConfirm = false">Cancel</button></div></div></if></div><div class="vd__section"><div class="vd__section-title">Output</div><if :="output != null"><pre class="vd__pre">{{ formatJson(output) }}</pre></if><if :="output == null"><div class="vd__empty">No output available</div></if></div><div class="vd__section"><div class="vd__section-title">Meta</div><if :="meta"><div class="vd__meta-grid"><div class="vd__meta-row"><span class="vd__meta-key">state</span> <span class="vd__meta-val" :style="\'color:\' + stateColor(meta.state)">{{ meta.state ?? \'—\' }}</span></div><div class="vd__meta-row"><span class="vd__meta-key">clock</span> <span class="vd__meta-val">{{ meta.clock ?? \'—\' }}</span></div><if :="meta.error"><div class="vd__meta-row"><span class="vd__meta-key">error</span> <span class="vd__meta-val vd__meta-val--error">{{ meta.error }}</span></div></if></div><if :="meta.refs && Object.keys(meta.refs).length > 0"><div class="vd__section-title" style="margin-top: 8px;">Refs</div><div class="vd__refs"><each :of="Object.entries(meta.refs)" let:item="entry"><div class="vd__ref-row"><span class="vd__ref-name">{{ entry[0] }}</span> <span class="vd__ref-state">{{ typeof entry[1] === \'object\' ? (entry[1]?.state ?? JSON.stringify(entry[1])) : entry[1] }}</span></div></each></div></if></if><if :="!meta"><div class="vd__empty">No meta available</div></if></div><div class="vd__section"><div class="vd__section-title">Definition</div><if :="definition != null"><pre class="vd__pre">{{ formatJson(definition) }}</pre></if><if :="definition == null"><div class="vd__empty">No definition available</div></if></div></if></div>'};
|
|
1
|
+
import e from"@toolkit/components/ok-chart.ok.js";export default{tag:"view-detail",dependencies:[e],attr:{name:{}},context:e=>({output:null,meta:null,definition:null,loading:!0,error:null,actionLoading:!1,showDeleteConfirm:!1,showOrphanedIndexConfirm:!1,orphanedIndexes:[],rangeData:[],rangeLoading:!1,rangeError:null,rangeFrom:"",rangeTo:"",rangeIncludePartial:!1,rangePreset:"30d",bucketsData:[],bucketsLoading:!1,bucketsError:null,async load(){this.loading=!0,this.error=null,this.output=null,this.meta=null,this.definition=null;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,[r,o,i]=await Promise.all([fetch(`${t}/views/${a}`),fetch(`${t}/views/${a}/meta`),fetch(`${t}/views/${a}/definition`)]),n=r.ok?await r.json():null,d=o.ok?await o.json():null,s=i.ok?await i.json():null;this.output=n?.data??n?.result??null,this.meta=d?.data??d?.result??null,this.definition=s?.data??s?.result??null,this.isBucketed&&(this.rangePreset="30d",this.rangeFrom=new Date(Date.now()-2592e6).toISOString().split("T")[0],this.rangeTo="",this.loadRange(),this.loadBuckets())}catch(e){this.error=e.message}finally{this.loading=!1}},async doAction(t){this.actionLoading=!0;try{const a=e.$ok.stores.okdb.apiBase,r=e.$attr.name,o=await fetch(`${a}/views/${r}/${t}`,{method:"POST"});if(!o.ok){const e=await o.json().catch(()=>({}));throw new Error(e?.error?.message??`${t} failed`)}await this.load(),e.$dispatch("state-changed")}catch(e){this.error=e.message}finally{this.actionLoading=!1}},async deleteView(){this.actionLoading=!0;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,r=await fetch(`${t}/views/${a}`,{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({})),t=e?.error?.code;if("VIEW_HAS_ORPHANED_INDEXES"===t)return this.orphanedIndexes=e?.error?.details?.orphanedIndexes??[],this.showDeleteConfirm=!1,void(this.showOrphanedIndexConfirm=!0);throw new Error(e?.error?.message??"Delete failed")}e.$dispatch("removed")}catch(e){this.error=e.message}finally{this.actionLoading=!1,this.showOrphanedIndexConfirm||(this.showDeleteConfirm=!1)}},async deleteViewWithIndexes(t){this.actionLoading=!0;try{const a=e.$ok.stores.okdb.apiBase,r=e.$attr.name,o=await fetch(`${a}/views/${r}`,{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({managedIndexes:t})});if(!o.ok){const e=await o.json().catch(()=>({}));throw new Error(e?.error?.message??"Delete failed")}e.$dispatch("removed")}catch(e){this.error=e.message}finally{this.actionLoading=!1,this.showOrphanedIndexConfirm=!1,this.orphanedIndexes=[]}},formatJson(e){if(null==e)return"";try{return JSON.stringify(e,null,2)}catch{return String(e)}},stateColor(e){switch(e){case"ready":return"#4caf50";case"creating":case"resetting":return"#4FC1FF";case"halted":return"#f44336";case"stopped":return"#fbbf24";default:return"#666"}},get state(){return this.meta?.state??"unknown"},get isInProgress(){return"creating"===this.state||"resetting"===this.state},get isBucketed(){return this.definition&&this.definition.bucket},get chartType(){return this.isBucketed?"time"===this.definition.bucket.preset?"line":"bar":null},get chartData(){const e=this.rangeData||[];if(e.length<2)return null;if(!this.isBucketed)return null;const t=e.map(e=>e.bucketKey),a=e[0]?.reducers||{};return{labels:t,datasets:Object.entries(a).filter(([,e])=>e&&"number"==typeof e.value).map(([e])=>e).map(t=>({label:t,data:e.map(e=>{const a=e.reducers[t];return a&&"number"==typeof a.value?a.value:null})}))}},setQuickRange(e){const t=new Date,a=e=>new Date(t-864e5*e).toISOString().split("T")[0];switch(this.rangePreset=e,e){case"7d":this.rangeFrom=a(7),this.rangeTo="";break;case"30d":this.rangeFrom=a(30),this.rangeTo="";break;case"90d":this.rangeFrom=a(90),this.rangeTo="";break;case"1y":this.rangeFrom=a(365),this.rangeTo="";break;case"all":this.rangeFrom="",this.rangeTo=""}this.loadRange()},shiftRange(e){const t=e=>e?new Date(e+"T00:00:00Z"):null,a=e=>e.toISOString().split("T")[0],r=new Date;let o=t(this.rangeFrom),i=t(this.rangeTo);o||i?o?i||(i=new Date(r)):o=new Date(i-2592e6):(i=r,o=new Date(r-2592e6));const n=i-o;this.rangeFrom=a(new Date(o.getTime()+e*n)),this.rangeTo=a(new Date(i.getTime()+e*n)),this.rangePreset="",this.loadRange()},async loadRange(){if(this.isBucketed){this.rangeLoading=!0,this.rangeError=null;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,r=new URLSearchParams;this.rangeFrom&&r.set("from",this.rangeFrom),this.rangeTo&&r.set("to",this.rangeTo),this.rangeIncludePartial&&r.set("includePartial","true");const o=this.definition.bucket.granularity;r.set("granularity",o);const i=await fetch(`${t}/views/${a}/range?${r}`);if(!i.ok){const e=await i.json().catch(()=>({}));throw new Error(e?.error?.message??"Range fetch failed")}const n=await i.json();this.rangeData=n.data??n.result??[]}catch(e){this.rangeError=e.message,this.rangeData=[]}finally{this.rangeLoading=!1}}},async loadBuckets(){if(this.isBucketed){this.bucketsLoading=!0,this.bucketsError=null;try{const t=e.$ok.stores.okdb.apiBase,a=e.$attr.name,r=encodeURIComponent(this.definition.bucket.granularity),o=await fetch(`${t}/views/${a}/buckets?granularity=${r}&limit=10000`);if(!o.ok){const e=await o.json().catch(()=>({}));throw new Error(e?.error?.message??"Bucket list failed")}const i=await o.json();this.bucketsData=i.data??i.result??[]}catch(e){this.bucketsError=e.message,this.bucketsData=[]}finally{this.bucketsLoading=!1}}},get rangeColumns(){return this.rangeData.length?Object.keys(this.rangeData[0].reducers||{}):[]},reducerDisplayValue(e,t){const a=e.reducers?.[t];return null==a?"":"object"==typeof a&&"number"==typeof a.value?a.value:"object"==typeof a?JSON.stringify(a):a},get rangeTotals(){if(!this.rangeData.length)return null;const e=this.rangeColumns;if(!e.length)return null;const t=this.definition?.reduce||{},a={};for(const r of e){const e=t[r],o=e?Object.keys(e).find(e=>"items"!==e):null,i=this.rangeData.map(e=>e.reducers?.[r]).filter(e=>null!=e&&"number"==typeof e.value).map(e=>e.value);i.length?a[r]="$min"===o?Math.min(...i):"$max"===o?Math.max(...i):"$avg"===o?{approx:i.reduce((e,t)=>e+t,0)/i.length}:i.reduce((e,t)=>e+t,0):a[r]=null}return a},rangeTotalDisplay(e){const t=this.rangeTotals;if(!t)return"—";const a=t[e];return null==a?"—":"object"==typeof a&&null!=a.approx?"~"+Math.round(1e3*a.approx)/1e3:a}}),mount(e){e.$watch(()=>e.$attr.name,()=>{this.loading=!0,this.output=null,this.meta=null,this.definition=null,this.showDeleteConfirm=!1,this.showOrphanedIndexConfirm=!1,this.orphanedIndexes=[],this.load()}),this.load()},style:"view-detail{gap:0;height:100%;overflow-y:auto}.vd,.vd__header,view-detail{display:flex;flex-direction:column}.vd{gap:20px;padding:20px 24px}.vd__header{gap:10px;padding-bottom:16px;border-bottom:1px solid var(--okdb-app-border)}.vd__title-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.vd__name,.vd__state-badge{font-weight:600;white-space:nowrap}.vd__name{font-size:20px;color:var(--okdb-app-fg-strong);margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;flex-shrink:1}.vd__state-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:999px;border:1px solid var(--okdb-app-border);font-size:11px;text-transform:uppercase;letter-spacing:.04em}.vd__state-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}.vd__type-label{font-size:11px;color:var(--okdb-app-fg-subtle);font-family:monospace}.vd__actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.vd__btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:600;border:1px solid;background:0 0;cursor:pointer;transition:all .15s ease}.vd__btn--primary{border-color:var(--okdb-accent);color:var(--okdb-accent)}.vd__btn--primary:hover{background:color-mix(in srgb,var(--okdb-accent) 10%,transparent);transform:none;box-shadow:none}.vd__btn--warning{border-color:var(--okdb-warning);color:var(--okdb-warning)}.vd__btn--warning:hover{background:color-mix(in srgb,var(--okdb-warning) 10%,transparent);transform:none;box-shadow:none}.vd__btn--danger{border-color:var(--okdb-danger);color:var(--okdb-danger)}.vd__btn--danger:hover{background:color-mix(in srgb,var(--okdb-danger) 10%,transparent);transform:none;box-shadow:none}.vd__btn:disabled{opacity:.4;cursor:not-allowed}.vd__delete-confirm{display:flex;align-items:center;gap:10px;padding:8px 12px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 25%,transparent);border-radius:5px;font-size:12px;color:var(--okdb-danger);flex-wrap:wrap}.vd__delete-confirm-text{flex:1;min-width:0}.vd__delete-confirm-actions{display:flex;gap:6px}.vd__section{display:flex;flex-direction:column;gap:10px}.vd__section-title{font-size:11px;font-weight:600;color:var(--okdb-app-fg-muted);text-transform:uppercase;letter-spacing:.05em}.vd__pre{font-family:monospace;font-size:12px;background:var(--okdb-app-bg-deep);border:1px solid var(--okdb-app-border);border-radius:5px;padding:12px 14px;overflow-x:auto;color:var(--okdb-app-fg);white-space:pre;margin:0;line-height:1.5}.vd__meta-grid{display:flex;flex-direction:column;gap:6px;font-size:12px}.vd__meta-row{display:flex;gap:8px}.vd__meta-key{color:var(--okdb-app-fg-subtle);width:80px;flex-shrink:0}.vd__meta-val{color:var(--okdb-app-fg);font-family:monospace;word-break:break-all}.vd__meta-val--error{color:var(--okdb-danger)}.vd__empty,.vd__error{font-size:12px;color:var(--okdb-app-fg-subtle)}.vd__error{color:var(--okdb-danger);padding:8px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:4px}.vd__loading{font-size:12px;opacity:.5;padding:40px;text-align:center}.vd__refs{display:flex;flex-direction:column;gap:4px;margin-top:4px}.vd__ref-row{display:flex;gap:8px;font-size:11px;padding:4px 8px;background:var(--okdb-app-bg-deep);border-radius:3px;border:1px solid var(--okdb-app-border)}.vd__ref-name{color:var(--okdb-sky);font-family:monospace;flex:1}.vd__ref-state{color:var(--okdb-app-fg-subtle)}",template:'<div class="vd"><if :="loading"><div class="vd__loading">Loading…</div></if><if :="!loading"><if :="error"><div class="vd__error">{{ error }}</div></if><div class="vd__header"><div class="vd__title-row"><div style="flex:1; min-width:0; display:flex; align-items:baseline; gap:8px; overflow:hidden;"><h2 class="vd__name">{{ $attr.name }}</h2><if :="definition != null && definition.type != null && definition.type !== \'\'"><span class="vd__type-label">{{ definition.type }}</span></if></div><span class="vd__state-badge" :style="\'color:\' + stateColor(state)"><span class="vd__state-dot" :style="\'background:\' + stateColor(state)"/> {{ state }}</span></div><div class="vd__actions"><if :="!isInProgress"><if :="state === \'ready\'"><button class="vd__btn vd__btn--warning" :disabled="actionLoading" @click="doAction(\'stop\')">Stop</button></if><if :="state === \'stopped\' || state === \'halted\'"><button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="doAction(\'start\')">Start</button></if><button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="doAction(\'rebuild\')">Rebuild</button> <button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="showDeleteConfirm = true">Delete</button></if><if :="isInProgress"><button class="vd__btn vd__btn--primary" disabled="disabled">Stop</button> <button class="vd__btn vd__btn--primary" disabled="disabled">Rebuild</button> <button class="vd__btn vd__btn--danger" disabled="disabled">Delete</button></if></div><if :="showDeleteConfirm"><div class="vd__delete-confirm"><span class="vd__delete-confirm-text">Delete view <strong>{{ $attr.name }}</strong>? This cannot be undone.</span><div class="vd__delete-confirm-actions"><button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="deleteView()">Confirm</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="showDeleteConfirm = false">Cancel</button></div></div></if><if :="showOrphanedIndexConfirm"><div class="vd__delete-confirm"><span class="vd__delete-confirm-text">Deleting this view will orphan: <strong>{{ orphanedIndexes.join(\', \') }}</strong>. Drop or keep these indexes?</span><div class="vd__delete-confirm-actions"><button class="vd__btn vd__btn--danger" :disabled="actionLoading" @click="deleteViewWithIndexes(\'drop\')">Drop</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="deleteViewWithIndexes(\'keep\')">Keep</button> <button class="vd__btn vd__btn--primary" :disabled="actionLoading" @click="showOrphanedIndexConfirm = false">Cancel</button></div></div></if></div><if :="isBucketed"><div class="vd__section"><div class="vd__section-title">Bucketing</div><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12px;"><span style="font-family:monospace;background:var(--okdb-app-bg-deep);border:1px solid var(--okdb-app-border);border-radius:4px;padding:2px 8px;white-space:nowrap;">{{ definition.bucket.field }} · {{ definition.bucket.granularity }} · {{ definition.bucket.timezone || \'UTC\' }} · {{ definition.bucket.preset || \'custom\' }}</span><if :="meta && meta.bucketing && meta.bucketing.unbucketedCount > 0"><span style="font-size:11px;color:var(--okdb-warning);background:color-mix(in srgb,var(--okdb-warning) 10%,transparent);border:1px solid color-mix(in srgb,var(--okdb-warning) 30%,transparent);border-radius:4px;padding:2px 8px;">{{ meta.bucketing.unbucketedCount }} records have no bucket (missing or null bucket field)</span></if></div><div><if :="bucketsLoading"><span style="font-size:11px;color:var(--okdb-app-fg-subtle);">Loading buckets…</span></if><if :="!bucketsLoading && bucketsError"><span style="font-size:11px;color:var(--okdb-danger);">{{ bucketsError }}</span></if><if :="!bucketsLoading && !bucketsError && bucketsData.length === 0"><span style="font-size:11px;color:var(--okdb-app-fg-subtle);">No buckets yet</span></if><if :="!bucketsLoading && !bucketsError && bucketsData.length > 0"><span style="font-size:11px;color:var(--okdb-app-fg-muted);font-family:monospace;background:var(--okdb-app-bg-deep);border:1px solid var(--okdb-app-border);border-radius:4px;padding:2px 8px;">{{ bucketsData.length }} bucket{{ bucketsData.length === 1 ? \'\' : \'s\' }} · earliest {{ bucketsData[0].bucketKey }} · latest {{ bucketsData[bucketsData.length - 1].bucketKey }}{{ bucketsData.length >= 10000 ? \' (showing first 10000)\' : \'\' }}</span></if></div></div><div class="vd__section"><div class="vd__section-title">Per-bucket aggregates</div><if :="definition.bucket.preset === \'time\'"><div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;"><button style="padding:4px 8px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:12px;cursor:pointer;line-height:1;" @click="shiftRange(-1)" :disabled="rangeLoading" title="Previous period">←</button> <button :style="rangePreset === \'7d\' ? \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-accent);background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);color:var(--okdb-accent);font-size:11px;font-weight:600;cursor:pointer;\' : \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;\'" @click="setQuickRange(\'7d\')" :disabled="rangeLoading">7d</button> <button :style="rangePreset === \'30d\' ? \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-accent);background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);color:var(--okdb-accent);font-size:11px;font-weight:600;cursor:pointer;\' : \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;\'" @click="setQuickRange(\'30d\')" :disabled="rangeLoading">30d</button> <button :style="rangePreset === \'90d\' ? \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-accent);background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);color:var(--okdb-accent);font-size:11px;font-weight:600;cursor:pointer;\' : \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;\'" @click="setQuickRange(\'90d\')" :disabled="rangeLoading">90d</button> <button :style="rangePreset === \'1y\' ? \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-accent);background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);color:var(--okdb-accent);font-size:11px;font-weight:600;cursor:pointer;\' : \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;\'" @click="setQuickRange(\'1y\')" :disabled="rangeLoading">1y</button> <button :style="rangePreset === \'all\' ? \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-accent);background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);color:var(--okdb-accent);font-size:11px;font-weight:600;cursor:pointer;\' : \'padding:4px 9px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:11px;cursor:pointer;\'" @click="setQuickRange(\'all\')" :disabled="rangeLoading">All</button> <button style="padding:4px 8px;border-radius:4px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:12px;cursor:pointer;line-height:1;" @click="shiftRange(1)" :disabled="rangeLoading" title="Next period">→</button></div></if><div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;"><div style="display:flex;flex-direction:column;gap:3px;"><span style="font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:0.05em;">From</span><if :="definition.bucket.preset === \'time\'"><input type="date" style="padding:5px 8px;border:1px solid var(--okdb-app-border);border-radius:5px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;" :value="rangeFrom" @input="rangeFrom = $el.value"></if><if :="definition.bucket.preset !== \'time\'"><input type="text" placeholder="start key" style="padding:5px 8px;border:1px solid var(--okdb-app-border);border-radius:5px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;" :value="rangeFrom" @input="rangeFrom = $el.value"></if></div><div style="display:flex;flex-direction:column;gap:3px;"><span style="font-size:10px;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:0.05em;">To</span><if :="definition.bucket.preset === \'time\'"><input type="date" style="padding:5px 8px;border:1px solid var(--okdb-app-border);border-radius:5px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;" :value="rangeTo" @input="rangeTo = $el.value"></if><if :="definition.bucket.preset !== \'time\'"><input type="text" placeholder="end key" style="padding:5px 8px;border:1px solid var(--okdb-app-border);border-radius:5px;background:var(--okdb-app-bg-deep);color:var(--okdb-app-fg);font-size:12px;" :value="rangeTo" @input="rangeTo = $el.value"></if></div><label style="display:flex;align-items:center;gap:5px;font-size:12px;cursor:pointer;color:var(--okdb-app-fg-muted);padding-bottom:4px;"><input type="checkbox" :checked="rangeIncludePartial" @change="rangeIncludePartial = $event.target.checked"> Include partial</label> <button style="padding:5px 12px;border-radius:5px;border:1px solid var(--okdb-app-border);background:transparent;color:var(--okdb-app-fg-muted);font-size:12px;cursor:pointer;" @click="loadRange()" :disabled="rangeLoading">Reload</button></div><if :="rangeError"><div class="vd__error">{{ rangeError }}</div></if><if :="rangeLoading"><div class="vd__empty">Loading…</div></if><if :="!rangeLoading && !rangeError && rangeData.length === 0"><div class="vd__empty">No data for the selected range</div></if><if :="!rangeLoading && !rangeError && rangeData.length > 0"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 12px;background:color-mix(in srgb,var(--okdb-accent) 6%,var(--okdb-app-bg-deep));border:1px solid color-mix(in srgb,var(--okdb-accent) 20%,var(--okdb-app-border));border-radius:5px;font-size:12px;"><span style="font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--okdb-app-fg-subtle);white-space:nowrap;">Range totals</span><each :of="rangeColumns" let:item="col"><span style="display:flex;align-items:center;gap:5px;"><span style="font-size:10px;color:var(--okdb-app-fg-subtle);font-family:monospace;">{{ col }}</span> <span style="font-family:monospace;font-weight:600;color:var(--okdb-app-fg);">{{ rangeTotalDisplay(col) }}</span></span></each></div><div style="overflow-x:auto;border:1px solid var(--okdb-app-border);border-radius:5px;font-size:12px;"><div style="display:flex;border-bottom:1px solid var(--okdb-app-border);background:var(--okdb-app-bg-deep);"><div style="flex:0 0 160px;padding:6px 10px;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--okdb-app-fg-subtle);white-space:nowrap;">Bucket</div><each :of="rangeColumns" let:item="col"><div style="flex:1;min-width:80px;padding:6px 10px;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--okdb-app-fg-subtle);white-space:nowrap;font-family:monospace;text-align:right;">{{ col }}</div></each></div><each :of="rangeData" let:item="row"><div style="display:flex;border-bottom:1px solid var(--okdb-app-border);"><div style="flex:0 0 160px;padding:5px 10px;font-family:monospace;color:var(--okdb-app-fg-muted);">{{ row.bucketKey }}</div><each :of="rangeColumns" let:item="col"><div style="flex:1;min-width:80px;padding:5px 10px;font-family:monospace;text-align:right;">{{ reducerDisplayValue(row, col) }}</div></each></div></each><div style="display:flex;border-top:2px solid var(--okdb-app-border);background:var(--okdb-app-bg-deep);"><div style="flex:0 0 160px;padding:5px 10px;font-family:monospace;font-size:11px;font-weight:600;color:var(--okdb-app-fg-subtle);text-transform:uppercase;letter-spacing:0.04em;">Total</div><each :of="rangeColumns" let:item="col"><div style="flex:1;min-width:80px;padding:5px 10px;font-family:monospace;font-weight:600;text-align:right;">{{ rangeTotalDisplay(col) }}</div></each></div></div></if></div><if :="isBucketed && chartData != null"><div class="vd__section"><div class="vd__section-title">Trend</div><div style="height:300px;"><ok-chart :type="chartType" :data="chartData"></div></div></if><if :="isBucketed && chartData == null && rangeData != null"><div class="vd__section"><div class="vd__section-title">Trend</div><div class="vd__empty">Not enough data to chart yet (need at least 2 buckets).</div></div></if></if><div class="vd__section"><div class="vd__section-title">Output<if :="isBucketed"><span style="font-size:10px;font-weight:400;color:var(--okdb-app-fg-subtle);text-transform:none;letter-spacing:0;margin-left:6px;">global totals · all time</span></if></div><if :="output != null"><pre class="vd__pre">{{ formatJson(output) }}</pre></if><if :="output == null"><div class="vd__empty">No output available</div></if></div><div class="vd__section"><div class="vd__section-title">Meta</div><if :="meta"><div class="vd__meta-grid"><div class="vd__meta-row"><span class="vd__meta-key">state</span> <span class="vd__meta-val" :style="\'color:\' + stateColor(meta.state)">{{ meta.state ?? \'—\' }}</span></div><div class="vd__meta-row"><span class="vd__meta-key">clock</span> <span class="vd__meta-val">{{ meta.clock ?? \'—\' }}</span></div><if :="meta.error"><div class="vd__meta-row"><span class="vd__meta-key">error</span> <span class="vd__meta-val vd__meta-val--error">{{ meta.error }}</span></div></if></div><if :="meta.refs && Object.keys(meta.refs).length > 0"><div class="vd__section-title" style="margin-top: 8px;">Refs</div><div class="vd__refs"><each :of="Object.entries(meta.refs)" let:item="entry"><div class="vd__ref-row"><span class="vd__ref-name">{{ entry[0] }}</span> <span class="vd__ref-state">{{ typeof entry[1] === \'object\' ? (entry[1]?.state ?? JSON.stringify(entry[1])) : entry[1] }}</span></div></each></div></if></if><if :="!meta"><div class="vd__empty">No meta available</div></if></div><div class="vd__section"><div class="vd__section-title">Definition</div><if :="definition != null"><pre class="vd__pre">{{ formatJson(definition) }}</pre></if><if :="definition == null"><div class="vd__empty">No definition available</div></if></div></if></div>'};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export default{tag:"views-overview",context:e=>({views:[],loading:!1,error:null,filterState:null,async load(){this.loading=!0,this.error=null;try{const t=e.$ok.stores.okdb.apiBase,r=await fetch(`${t}/views`);if(!r.ok)throw new Error("Failed to load views");const a=await r.json(),o=a.data??a.result??[],i=await Promise.all(o.map(async e=>{const[r,a]=await Promise.all([fetch(`${t}/views/${e}/meta`),fetch(`${t}/views/${e}/definition`)]),o=r.ok?await r.json():{},i=a.ok?await a.json():{},s=o.data??o.result??{},l=i.data??i.result??{};return{name:e,state:s?.state??"unknown",type:l?.type??"?",clock:s?.clock??null,error:s?.error??null,refs:s?.refs??null}}));this.views=i}catch(e){this.error=e.message}finally{this.loading=!1}},get totalCount(){return this.views.length},get readyCount(){return this.views.filter(e=>"ready"===e.state).length},get haltedCount(){return this.views.filter(e=>"halted"===e.state).length},get stoppedCount(){return this.views.filter(e=>"stopped"===e.state).length},get inProgressCount(){return this.views.filter(e=>"creating"===e.state||"resetting"===e.state).length},get filteredViews(){return this.filterState?this.views.filter(e=>e.state===this.filterState):this.views},get haltedWithErrors(){return this.views.filter(e=>"halted"===e.state&&e.error)},toggleFilter(e){this.filterState=this.filterState===e?null:e},stateColor(e){switch(e){case"ready":return"var(--okdb-success)";case"creating":case"resetting":return"var(--okdb-sky)";case"halted":return"var(--okdb-danger)";case"stopped":return"var(--okdb-warning)";default:return"var(--okdb-app-fg-muted)"}},fmt:e=>null==e?"—":Number(e).toLocaleString(),selectView(t){e.$dispatch("select",t.name)}}),mount(e){e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>{this.filterState=null,this.load()}),this.load()},style:"views-overview{display:block}views-overview .vo-wrap{display:flex;flex-direction:column;gap:20px;max-width:860px}views-overview .vo-stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}views-overview .vo-stat-card{background:var(--okdb-app-surface);border:1px solid var(--okdb-app-border);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:6px;box-shadow:0 2px 8px #0003;cursor:pointer;transition:border-color .15s,box-shadow .15s}views-overview .vo-stat-card:hover{border-color:var(--okdb-app-border-strong)}views-overview .vo-stat-card--active{border-color:color-mix(in srgb,var(--okdb-accent) 50%,transparent)!important;box-shadow:0 0 0 1px color-mix(in srgb,var(--okdb-accent) 15%,transparent),0 2px 8px #0003}views-overview .vo-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.5}views-overview .vo-stat-value{font-size:22px;font-weight:700;line-height:1}views-overview .vo-card{background:var(--okdb-app-surface);border:1px solid var(--okdb-app-border);border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0003}views-overview .vo-card-head{padding:13px 16px;border-bottom:1px solid var(--okdb-app-border);display:flex;align-items:center;gap:10px}views-overview .vo-table{width:100%;border-collapse:collapse;font-size:13px}views-overview .vo-table th{padding:7px 16px;text-align:left;font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.05em;opacity:.4;border-bottom:1px solid var(--okdb-app-border);background:var(--okdb-app-bg)}views-overview .vo-table td{padding:9px 16px;border-bottom:1px solid var(--okdb-app-surface-2)}views-overview .vo-row{cursor:pointer;transition:background .1s}views-overview .vo-row:hover td{background:#ffffff07}views-overview .vo-table tbody tr:last-child td{border-bottom:none}views-overview .vo-filter-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}views-overview .vo-filter-pill{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:999px;border:1px solid var(--okdb-app-border);font-size:11px;cursor:pointer;background:0 0;color:var(--okdb-app-fg-muted);transition:all .15s}views-overview .vo-filter-pill:hover{border-color:var(--okdb-app-border-strong);color:var(--okdb-app-fg)}views-overview .vo-filter-pill--active{background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);border-color:color-mix(in srgb,var(--okdb-accent) 40%,transparent);color:var(--okdb-accent)}views-overview .vo-state-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block}views-overview .vo-error-card{background:color-mix(in srgb,var(--okdb-danger) 5%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:8px;padding:10px 14px;display:flex;flex-direction:column;gap:4px}views-overview .vo-error-name{font-family:monospace;font-size:12px;font-weight:600;color:var(--okdb-danger)}views-overview .vo-error-msg{font-size:12px;color:var(--okdb-app-fg-muted);line-height:1.5;word-break:break-all}views-overview .vo-empty{border:1px dashed var(--okdb-app-border);border-radius:14px;padding:40px 32px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:12px}views-overview .vo-error-banner{font-size:12px;color:var(--okdb-danger);padding:10px 14px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:8px}",template:'<div class="vo-wrap"><div style="display:flex; align-items:center; gap:10px;"><div style="font-weight:600; font-size:18px;">Views Overview</div><if :="loading"><span style="font-size:12px; opacity:.4;">loading…</span></if><div style="margin-left:auto;"><button @click="load()" :disabled="loading" style="font-size:12px; padding:5px 12px; opacity:.75;">↻ Refresh</button></div></div><if :="error"><div class="vo-error-banner">{{ error }}</div></if><div class="vo-stat-row"><div class="vo-stat-card" :class="filterState === null ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(null)" title="Show all views"><div class="vo-stat-label">Total Views</div><div class="vo-stat-value" style="color:var(--okdb-accent);">{{ totalCount }}</div></div><div class="vo-stat-card" :class="filterState === \'ready\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'ready\')" title="Filter: ready"><div class="vo-stat-label">Ready</div><div class="vo-stat-value" style="color:var(--okdb-success);">{{ readyCount }}</div></div><div class="vo-stat-card" :class="filterState === \'halted\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'halted\')" title="Filter: halted"><div class="vo-stat-label">Halted</div><div class="vo-stat-value" style="color:var(--okdb-danger);">{{ haltedCount }}</div></div><div class="vo-stat-card" :class="filterState === \'stopped\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'stopped\')" title="Filter: stopped"><div class="vo-stat-label">Stopped</div><div class="vo-stat-value" style="color:var(--okdb-warning);">{{ stoppedCount }}</div></div></div><if :="!loading && totalCount === 0 && !error"><div class="vo-empty"><div style="font-size:32px; opacity:.15;">⊞</div><div style="font-weight:600; font-size:15px;">No views yet</div><div style="font-size:13px; opacity:.55; line-height:1.7; max-width:320px;">Views are materialized projections over your data. Use <b>+ Create View</b> in the sidebar to define one.</div></div></if><if :="totalCount > 0"><div class="vo-card"><div class="vo-card-head"><span style="font-weight:600; font-size:14px;">Views</span><if :="inProgressCount > 0"><span style="font-size:11px; padding:2px 8px; border-radius:999px; border:1px solid color-mix(in srgb, var(--okdb-sky) 30%, transparent); color:var(--okdb-sky); background:color-mix(in srgb, var(--okdb-sky) 6%, transparent);">{{ inProgressCount }} in progress</span></if><div style="margin-left:auto;" class="vo-filter-bar"><span style="font-size:11px; opacity:.4;">Filter:</span> <button class="vo-filter-pill" :class="filterState === null ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(null)">All</button> <button class="vo-filter-pill" :class="filterState === \'ready\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'ready\')"><span class="vo-state-dot" style="background:var(--okdb-success);"/>ready</button> <button class="vo-filter-pill" :class="filterState === \'halted\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'halted\')"><span class="vo-state-dot" style="background:var(--okdb-danger);"/>halted</button> <button class="vo-filter-pill" :class="filterState === \'stopped\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'stopped\')"><span class="vo-state-dot" style="background:var(--okdb-warning);"/>stopped</button></div></div><table class="vo-table"><thead><tr><th>Name</th><th>Type</th><th>State</th><th style="text-align:right;">Clock</th><th>Error</th><th/></tr></thead><tbody><tr as="repeat" :of="filteredViews" let:item="v" class="vo-row" @click="selectView(v)" title="Open view"><td><span style="font-family:monospace; font-size:13px; font-weight:500;">{{ v.name }}</span></td><td><span style="font-family:monospace; font-size:12px; opacity:.7;">{{ v.type }}</span></td><td><span style="display:inline-flex; align-items:center; gap:5px;"><span class="vo-state-dot" :style="\'background:\' + stateColor(v.state)"/> <span :style="\'font-size:12px; color:\' + stateColor(v.state)">{{ v.state }}</span></span></td><td style="text-align:right; font-family:monospace; font-size:12px; opacity:.5;">{{ fmt(v.clock) }}</td><td style="max-width:260px;"><if :="v.error"><span style="font-size:11px; color:var(--okdb-danger); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;" :title="v.error">{{ v.error }}</span></if></td><td style="text-align:right; opacity:.3; font-size:11px; padding-right:16px;">→</td></tr><if :="filteredViews.length === 0"><tr><td colspan="6" style="text-align:center; padding:20px; font-size:12px; opacity:.4;">No views match this filter.</td></tr></if></tbody></table></div></if><if :="haltedWithErrors.length > 0"><div class="vo-card"><div class="vo-card-head"><span style="font-weight:600; font-size:14px;">Errors</span> <span style="font-size:11px; padding:2px 10px; border-radius:999px; border:1px solid color-mix(in srgb, var(--okdb-danger) 33%, transparent); color:var(--okdb-danger); background:color-mix(in srgb, var(--okdb-danger) 5%, transparent); font-weight:600;">{{ haltedWithErrors.length }} </span><span style="font-size:12px; opacity:.4; margin-left:auto;">halted views with error details</span></div><div style="padding:14px 16px; display:flex; flex-direction:column; gap:8px;"><each :of="haltedWithErrors" let:item="v"><div class="vo-error-card" style="cursor:pointer;" @click="selectView(v)" title="Open view"><div style="display:flex; align-items:center; gap:8px;"><span class="vo-error-name">{{ v.name }}</span><if :="v.type"><span style="font-size:10px; font-family:monospace; opacity:.5;">{{ v.type }}</span></if><span style="margin-left:auto; font-size:11px; opacity:.5;">clock {{ fmt(v.clock) }}</span></div><div class="vo-error-msg">{{ v.error }}</div></div></each></div></div></if></div>'};
|
|
1
|
+
export default{tag:"views-overview",context:e=>({views:[],loading:!1,error:null,filterState:null,async load(){this.loading=!0,this.error=null;try{const t=e.$ok.stores.okdb.apiBase,r=await fetch(`${t}/views`);if(!r.ok)throw new Error("Failed to load views");const a=await r.json(),o=a.data??a.result??[],i=await Promise.all(o.map(async e=>{const[r,a]=await Promise.all([fetch(`${t}/views/${e}/meta`),fetch(`${t}/views/${e}/definition`)]),o=r.ok?await r.json():{},i=a.ok?await a.json():{},s=o.data??o.result??{},l=i.data??i.result??{};return{name:e,state:s?.state??"unknown",type:l?.type??"?",clock:s?.clock??null,error:s?.error??null,refs:s?.refs??null}}));this.views=i}catch(e){this.error=e.message}finally{this.loading=!1}},get totalCount(){return this.views.length},get readyCount(){return this.views.filter(e=>"ready"===e.state).length},get haltedCount(){return this.views.filter(e=>"halted"===e.state).length},get stoppedCount(){return this.views.filter(e=>"stopped"===e.state).length},get inProgressCount(){return this.views.filter(e=>"creating"===e.state||"resetting"===e.state).length},get filteredViews(){return this.filterState?this.views.filter(e=>e.state===this.filterState):this.views},get haltedWithErrors(){return this.views.filter(e=>"halted"===e.state&&e.error)},toggleFilter(e){this.filterState=this.filterState===e?null:e},stateColor(e){switch(e){case"ready":return"var(--okdb-success)";case"creating":case"resetting":return"var(--okdb-sky)";case"halted":return"var(--okdb-danger)";case"stopped":return"var(--okdb-warning)";default:return"var(--okdb-app-fg-muted)"}},fmt:e=>null==e?"—":Number(e).toLocaleString(),selectView(t){e.$dispatch("select",t.name)}}),mount(e){e.$watch(()=>e.$ok.stores.okdb.selectedEnv,()=>{this.filterState=null,this.load()}),this.load()},style:"views-overview{display:block}views-overview .vo-wrap{display:flex;flex-direction:column;gap:20px;max-width:860px}views-overview .vo-stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}views-overview .vo-stat-card{background:var(--okdb-app-surface);border:1px solid var(--okdb-app-border);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:6px;box-shadow:0 2px 8px #0003;cursor:pointer;transition:border-color .15s,box-shadow .15s}views-overview .vo-stat-card:hover{border-color:var(--okdb-app-border-strong)}views-overview .vo-stat-card--active{border-color:color-mix(in srgb,var(--okdb-accent) 50%,transparent)!important;box-shadow:0 0 0 1px color-mix(in srgb,var(--okdb-accent) 15%,transparent),0 2px 8px #0003}views-overview .vo-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.5}views-overview .vo-stat-value{font-size:22px;font-weight:700;line-height:1}views-overview .vo-card{background:var(--okdb-app-surface);border:1px solid var(--okdb-app-border);border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0003}views-overview .vo-card-head{padding:13px 16px;border-bottom:1px solid var(--okdb-app-border);display:flex;align-items:center;gap:10px}views-overview .vo-table{width:100%;border-collapse:collapse;font-size:13px}views-overview .vo-table th{padding:7px 16px;text-align:left;font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.05em;opacity:.4;border-bottom:1px solid var(--okdb-app-border);background:var(--okdb-app-bg)}views-overview .vo-table td{padding:9px 16px;border-bottom:1px solid var(--okdb-app-surface-2)}views-overview .vo-row{cursor:pointer;transition:background .1s}views-overview .vo-row:hover td{background:#ffffff07}views-overview .vo-table tbody tr:last-child td{border-bottom:none}views-overview .vo-filter-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}views-overview .vo-filter-pill{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:999px;border:1px solid var(--okdb-app-border);font-size:11px;cursor:pointer;background:0 0;color:var(--okdb-app-fg-muted);transition:all .15s}views-overview .vo-filter-pill:hover{border-color:var(--okdb-app-border-strong);color:var(--okdb-app-fg)}views-overview .vo-filter-pill--active{background:color-mix(in srgb,var(--okdb-accent) 12%,transparent);border-color:color-mix(in srgb,var(--okdb-accent) 40%,transparent);color:var(--okdb-accent)}views-overview .vo-state-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block}views-overview .vo-error-card{background:color-mix(in srgb,var(--okdb-danger) 5%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:8px;padding:10px 14px;display:flex;flex-direction:column;gap:4px}views-overview .vo-error-name{font-family:monospace;font-size:12px;font-weight:600;color:var(--okdb-danger)}views-overview .vo-error-msg{font-size:12px;color:var(--okdb-app-fg-muted);line-height:1.5;word-break:break-all}views-overview .vo-empty{border:1px dashed var(--okdb-app-border);border-radius:14px;padding:40px 32px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:12px}views-overview .vo-error-banner{font-size:12px;color:var(--okdb-danger);padding:10px 14px;background:color-mix(in srgb,var(--okdb-danger) 7%,transparent);border:1px solid color-mix(in srgb,var(--okdb-danger) 20%,transparent);border-radius:8px}",template:'<div class="vo-wrap"><div style="display:flex; align-items:center; gap:10px;"><div style="font-weight:600; font-size:18px;">Views Overview</div><if :="loading"><span style="font-size:12px; opacity:.4;">loading…</span></if><div style="margin-left:auto;"><button @click="load()" :disabled="loading" style="font-size:12px; padding:5px 12px; opacity:.75;">↻ Refresh</button></div></div><if :="error"><div class="vo-error-banner">{{ error }}</div></if><div class="vo-stat-row"><div class="vo-stat-card" :class="filterState === null ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(null)" title="Show all views"><div class="vo-stat-label">Total Views</div><div class="vo-stat-value" style="color:var(--okdb-accent);">{{ totalCount }}</div></div><div class="vo-stat-card" :class="filterState === \'ready\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'ready\')" title="Filter: ready"><div class="vo-stat-label">Ready</div><div class="vo-stat-value" style="color:var(--okdb-success);">{{ readyCount }}</div></div><div class="vo-stat-card" :class="filterState === \'halted\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'halted\')" title="Filter: halted"><div class="vo-stat-label">Halted</div><div class="vo-stat-value" style="color:var(--okdb-danger);">{{ haltedCount }}</div></div><div class="vo-stat-card" :class="filterState === \'stopped\' ? \'vo-stat-card--active\' : \'\'" @click="toggleFilter(\'stopped\')" title="Filter: stopped"><div class="vo-stat-label">Stopped</div><div class="vo-stat-value" style="color:var(--okdb-warning);">{{ stoppedCount }}</div></div></div><if :="!loading && totalCount === 0 && !error"><div class="vo-empty"><div style="font-size:32px; opacity:.15;">⊞</div><div style="font-weight:600; font-size:15px;">No views yet</div><div style="font-size:13px; opacity:.55; line-height:1.7; max-width:320px;">Views are materialized projections over your data. Use <b>+ Create View</b> in the sidebar to define one.</div></div></if><if :="totalCount > 0"><div class="vo-card"><div class="vo-card-head"><span style="font-weight:600; font-size:14px;">Views</span><if :="inProgressCount > 0"><span style="font-size:11px; padding:2px 8px; border-radius:999px; border:1px solid color-mix(in srgb, var(--okdb-sky) 30%, transparent); color:var(--okdb-sky); background:color-mix(in srgb, var(--okdb-sky) 6%, transparent);">{{ inProgressCount }} in progress</span></if><div style="margin-left:auto;" class="vo-filter-bar"><span style="font-size:11px; opacity:.4;">Filter:</span> <button class="vo-filter-pill" :class="filterState === null ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(null)">All</button> <button class="vo-filter-pill" :class="filterState === \'ready\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'ready\')"><span class="vo-state-dot" style="background:var(--okdb-success);"/>ready</button> <button class="vo-filter-pill" :class="filterState === \'halted\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'halted\')"><span class="vo-state-dot" style="background:var(--okdb-danger);"/>halted</button> <button class="vo-filter-pill" :class="filterState === \'stopped\' ? \'vo-filter-pill--active\' : \'\'" @click="toggleFilter(\'stopped\')"><span class="vo-state-dot" style="background:var(--okdb-warning);"/>stopped</button></div></div><table class="vo-table"><thead><tr><th>Name</th><th>Type</th><th>State</th><th style="text-align:right;">Clock</th><th>Error</th><th/></tr></thead><tbody><tr as="repeat" :of="filteredViews" let:item="v" class="vo-row" @click="selectView(v)" title="Open view"><td><span style="font-family:monospace; font-size:13px; font-weight:500;">{{ v.name }}</span></td><td><span style="font-family:monospace; font-size:12px; opacity:.7;">{{ v.type }}</span></td><td><span style="display:inline-flex; align-items:center; gap:5px;"><span class="vo-state-dot" :style="\'background:\' + stateColor(v.state)"/> <span :style="\'font-size:12px; color:\' + stateColor(v.state)">{{ v.state }}</span></span></td><td style="text-align:right; font-family:monospace; font-size:12px; opacity:.5;">{{ fmt(v.clock) }}</td><td style="max-width:260px;"><if :="v.error"><span style="font-size:11px; color:var(--okdb-danger); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;" :title="v.error">{{ v.error }}</span></if></td><td style="text-align:right; opacity:.3; font-size:11px; padding-right:16px;">→</td></tr><tr as="if" :="filteredViews.length === 0"><td colspan="6" style="text-align:center; padding:20px; font-size:12px; opacity:.4;">No views match this filter.</td></tr></tbody></table></div></if><if :="haltedWithErrors.length > 0"><div class="vo-card"><div class="vo-card-head"><span style="font-weight:600; font-size:14px;">Errors</span> <span style="font-size:11px; padding:2px 10px; border-radius:999px; border:1px solid color-mix(in srgb, var(--okdb-danger) 33%, transparent); color:var(--okdb-danger); background:color-mix(in srgb, var(--okdb-danger) 5%, transparent); font-weight:600;">{{ haltedWithErrors.length }} </span><span style="font-size:12px; opacity:.4; margin-left:auto;">halted views with error details</span></div><div style="padding:14px 16px; display:flex; flex-direction:column; gap:8px;"><each :of="haltedWithErrors" let:item="v"><div class="vo-error-card" style="cursor:pointer;" @click="selectView(v)" title="Open view"><div style="display:flex; align-items:center; gap:8px;"><span class="vo-error-name">{{ v.name }}</span><if :="v.type"><span style="font-size:10px; font-family:monospace; opacity:.5;">{{ v.type }}</span></if><span style="margin-left:auto; font-size:11px; opacity:.5;">clock {{ fmt(v.clock) }}</span></div><div class="vo-error-msg">{{ v.error }}</div></div></each></div></div></if></div>'};
|