web-mojo 2.2.85 → 2.2.86
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/dist/admin.cjs.js +1 -1
- package/dist/admin.cjs.js.map +1 -1
- package/dist/admin.css +51 -7
- package/dist/admin.es.js +1 -1
- package/dist/admin.es.js.map +1 -1
- package/dist/auth.cjs.js +1 -1
- package/dist/auth.es.js +1 -1
- package/dist/charts.cjs.js +1 -1
- package/dist/charts.es.js +1 -1
- package/dist/chunks/{version-Bps8Ct3l.js → version-DDnmr6FS.js} +2 -2
- package/dist/chunks/{version-Bps8Ct3l.js.map → version-DDnmr6FS.js.map} +1 -1
- package/dist/chunks/{version-iFGy9HZj.js → version-Di5CfBml.js} +2 -2
- package/dist/chunks/{version-iFGy9HZj.js.map → version-Di5CfBml.js.map} +1 -1
- package/dist/css/web-mojo.css +1 -1
- package/dist/docit.cjs.js +1 -1
- package/dist/docit.es.js +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +1 -1
- package/dist/lightbox.cjs.js +1 -1
- package/dist/lightbox.es.js +1 -1
- package/package.json +1 -1
package/dist/admin.cjs.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./chunks/ContextMenu-DBeueYpI.js"),t=require("./chunks/Collection-Bgp386gn.js");require("./chunks/WebSocketClient-C9VS1m8v.js");const s=require("./chunks/Dialog-2gXM2UcO.js"),i=require("./chunks/MetricsMiniChartWidget-y-KklF3c.js"),a=require("./chunks/ChatView-DZBmWPu-.js"),n=require("./chunks/Passkeys-DNpner4L.js"),o=require("./chunks/FormView-CRPeN8tp.js"),l=require("./chunks/Modal-Y1PW_Fmf.js"),r=require("./chunks/DataView-Dpr4AjSq.js"),d=require("./chunks/MetricsCountryMapView-ww-c8cxk.js"),c=require("./chunks/PDFViewer-Cg_tbqb7.js"),m=require("./chunks/WebApp-C98dm94j.js"),u=require("./chunks/version-iFGy9HZj.js");class AdminHeaderView extends t.View{constructor(e={}){super({title:"Dashboard",...e,headerActions:[{label:"Export",icon:"bi-download",action:"export",buttonClass:"btn-primary"}],className:"admin-header-section"}),this.stats={user_activity_day:0,total_users:0,group_activity_day:0,total_groups:0,api_calls:0,apiChange:"",incidents:0,incidentsChange:""},this.prepareStatsForTemplate()}async getTemplate(){return'\n <div class="admin-stats-header mb-4">\n <div class="row">\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="user_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="group_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="api_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="incident_activity_day"></div>\n </div>\n </div>\n </div>\n '}async onInit(){this.userActivity=new i.MetricsMiniChartWidget({icon:"bi bi-people fs-2",title:"User Activity",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total_users}} <span class="subtitle-label">Total</span>',background:"#5388D6",textColor:"#FFFFFF",granularity:"days",trendRange:4,trendOffset:0,slugs:["user_activity_day"],account:"global",chartType:"bar",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,showSettings:!0,showDateRange:!0,containerId:"user_activity_day"}),this.addChild(this.userActivity),this.groupActivity=new i.MetricsMiniChartWidget({icon:"bi bi-collection fs-2",title:"Group Activity",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total_groups}} <span class="subtitle-label">Total</span>',background:"#1f6a7a",textColor:"#FFFFFF",granularity:"days",trendRange:4,trendOffset:0,slugs:["group_activity_day"],account:"global",chartType:"bar",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"group_activity_day"}),this.addChild(this.groupActivity),this.apiActivity=new i.MetricsMiniChartWidget({icon:"bi bi-graph-up fs-2",title:"API Requests",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total}} <span class="subtitle-label">Total</span>',background:"#50A079",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",trendRange:4,trendOffset:0,granularity:"days",slugs:["api_calls"],account:"global",chartType:"line",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"api_activity_day"}),this.addChild(this.apiActivity),this.incidentActivity=new i.MetricsMiniChartWidget({icon:"bi bi-exclamation-triangle fs-2",title:"Incidents",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total}} <span class="subtitle-label">Total</span>',background:"#B14545",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",trendRange:4,trendOffset:0,granularity:"days",slugs:["incidents"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"incident_activity_day"}),this.addChild(this.incidentActivity)}async onBeforeRender(){}prepareStatsForTemplate(){}async loadValues(){try{const e=await this.getApp().rest.GET("/api/metrics/value/get",{slugs:["total_users","total_groups"],account:"global"});e.success&&e.data.status&&Object.assign(this.stats,e.data.data),this.groupActivity&&(this.groupActivity.header.total_groups=this.stats.total_groups||0,this.groupActivity.header.render()),this.userActivity&&(this.userActivity.header.total_users=this.stats.total_users||0,this.userActivity.header.render())}catch(e){console.error("Failed to load admin stats:",e)}}async loadStats(){try{const e=await this.getApp().rest.GET("/api/metrics/series",{slugs:["user_created","user_activity_day","incidents","api_calls","api_errors","group_activity_day"],account:"global",granularity:"days"});e.success&&e.data.status&&(Object.assign(this.stats,e.data.data),this.prepareStatsForTemplate())}catch(e){console.error("Failed to load admin stats:",e)}}}class AdminDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Admin Dashboard",className:"admin-dashboard-page"}),this.pageTitle="Admin Dashboard",this.pageSubtitle="System monitoring and metrics overview"}async getTemplate(){return'\n <div class="admin-dashboard-container container-lg">\n \x3c!-- Page Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-2">\n <div>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n <small class="text-info">\n <i class="bi bi-shield-check me-1"></i>\n Real-time system metrics and performance monitoring\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all" title="Refresh All Charts">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <button type="button" class="btn btn-outline-primary btn-sm"\n data-action="export-metrics" title="Export Metrics Data">\n <i class="bi bi-download"></i> Export\n </button>\n <button type="button" class="btn btn-outline-warning btn-sm"\n data-action="view-alerts" title="View System Alerts">\n <i class="bi bi-bell"></i> Alerts\n </button>\n </div>\n </div>\n\n \x3c!-- Stats Header --\x3e\n <div data-container="admin-header"></div>\n <div data-container="example-chart"></div>\n \x3c!-- Charts Section --\x3e\n <div class="row">\n \x3c!-- Full Width API Metrics Chart --\x3e\n <div class="col-12 mb-4">\n <div data-container="api-metrics-chart"></div>\n </div>\n </div>\n\n \x3c!-- System Status Footer --\x3e\n <div class="row">\n <div class="col-12">\n <div class="alert alert-success border-0" role="alert">\n <div class="d-flex align-items-center">\n <i class="bi bi-check-circle-fill me-2"></i>\n <div>\n <strong>System Status:</strong> All systems operational.\n Last updated: <span class="text-muted">{{lastUpdated}}</span>\n </div>\n <div class="ms-auto">\n <button class="btn btn-sm btn-outline-success" data-action="view-system-status">\n <i class="bi bi-info-circle"></i> Details\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}async onInit(){this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.headerView=new AdminHeaderView({containerId:"admin-header"}),this.addChild(this.headerView),this.apiMetricsChart=new i.MetricsChart({title:'<i class="bi bi-graph-up me-2"></i> API Metrics',endpoint:"/api/metrics/fetch",height:250,granularity:"hours",slugs:["api_calls","api_errors"],account:"global",chartType:"line",showDateRange:!1,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number"},containerId:"api-metrics-chart"}),this.addChild(this.apiMetricsChart)}async onActionRefreshAll(e,t){try{const s=t||e?.currentTarget||null,i=s?.querySelector?.("i");i?.classList.add("bi-spin"),s&&(s.disabled=!0);const a=[this.headerView?.loadValues(),this.apiMetricsChart?.refresh()].filter(Boolean);await Promise.allSettled(a),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString();const n=this.getApp()?.events;n&&n.emit("admin:dashboard-refreshed",{page:this,timestamp:this.lastUpdated})}catch(s){console.error("Failed to refresh dashboard:",s);const e=this.element.querySelector(".alert-success");e&&(e.className="alert alert-danger border-0",e.innerHTML='\n <div class="d-flex align-items-center">\n <i class="bi bi-exclamation-triangle-fill me-2"></i>\n <div>\n <strong>Error:</strong> Failed to refresh dashboard data.\n </div>\n </div>\n ',setTimeout(()=>{e.className="alert alert-success border-0",e.innerHTML=`\n <div class="d-flex align-items-center">\n <i class="bi bi-check-circle-fill me-2"></i>\n <div>\n <strong>System Status:</strong> All systems operational.\n Last updated: <span class="text-muted">${this.lastUpdated}</span>\n </div>\n </div>\n `},5e3))}finally{const e=t.querySelector("i");e?.classList.remove("bi-spin"),button&&(button.disabled=!1)}}async onActionExportMetrics(e,t){try{await(this.apiMetricsChart?.export("png"));const e=this.getApp()?.events;e&&e.emit("admin:metrics-exported",{page:this,charts:["api-metrics"]})}catch(s){console.error("Failed to export metrics:",s)}}async onActionViewAlerts(e,t){const s=this.getApp()?.router;s&&s.navigateTo("/admin/alerts")}async onActionViewSystemStatus(e,t){const s=this.getApp()?.router;s&&s.navigateTo("/admin/system-status")}async refreshDashboard(){return this.onActionRefreshAll(null,null,{disabled:!1,querySelector:()=>null})}getCharts(){return{apiMetrics:this.apiMetricsChart}}getStats(){return this.headerView?.stats||{}}async onAfterRender(){this.headerView?.loadValues()}}class SideNavView extends t.View{constructor(e={}){const{sections:t=[],activeSection:s,navWidth:i,contentPadding:a,enableResponsive:n,minWidth:o,...l}=e;super({tagName:"div",className:"side-nav-view",...l}),this.navWidth=i||200,this.contentPadding=a||"1.5rem 2.5rem",this.enableResponsive=!1!==n,this.minWidth=o||500,this.sectionConfigs=[],this.sectionViews={},this.sectionKeys=[],this.activeSection=null,this.currentMode="sidebar",this.resizeObserver=null,this.lastContainerWidth=0;for(const r of t)this._addSectionConfig(r);this.activeSection=s||this.sectionKeys[0]||null,this.handleResize=this.handleResize.bind(this)}_addSectionConfig(e){"divider"!==e.type?e.permissions&&!this._hasPermission(e.permissions)||(this.sectionConfigs.push(e),this.sectionKeys.push(e.key),e.view&&(this.sectionViews[e.key]=e.view,e.view.parent=this)):this.sectionConfigs.push({type:"divider",label:e.label})}_hasPermission(e){try{return this.getApp().activeUser.hasPerm(e)}catch{return!0}}async renderTemplate(){const e="dropdown"===this.currentMode?this._buildDropdownNav():this._buildSidebarNav();return`\n <style>\n .snv-layout { display: flex; height: 100%; min-height: 0; }\n .snv-nav {\n width: ${this.navWidth}px;\n background: #f8f9fc;\n border-right: 1px solid #e9ecef;\n padding: 0.75rem 0;\n flex-shrink: 0;\n overflow-y: auto;\n }\n .snv-nav a {\n color: #495057;\n padding: 0.45rem 1.25rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n text-decoration: none;\n cursor: pointer;\n }\n .snv-nav a:hover { background: #e9ecef; }\n .snv-nav a.active {\n background: #e7f1ff;\n color: #0d6efd;\n font-weight: 600;\n border-right: 2px solid #0d6efd;\n }\n .snv-nav a i { width: 18px; text-align: center; font-size: 0.9rem; }\n .snv-nav-label {\n font-size: 0.65rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: #adb5bd;\n padding: 0.75rem 1.25rem 0.25rem;\n }\n .snv-content {\n flex: 1;\n overflow-y: auto;\n padding: ${this.contentPadding};\n min-width: 0;\n }\n .snv-content > .snv-section { display: none; }\n .snv-content > .snv-section.snv-active { display: block; }\n .snv-dropdown { margin-bottom: 0.75rem; }\n .snv-select-btn {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n width: 100%;\n padding: 0.5rem 1rem;\n background: #f8f9fc;\n border: 1px solid #dee2e6;\n border-radius: 0.375rem;\n font-size: 0.85rem;\n color: #495057;\n cursor: pointer;\n }\n .snv-select-btn:hover { background: #e9ecef; }\n .snv-select-btn::after {\n content: '';\n display: inline-block;\n margin-left: auto;\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-left: 0.3em solid transparent;\n }\n @media (max-width: 576px) {\n .snv-nav { display: none; }\n .snv-content { padding: 1.25rem; }\n }\n </style>\n ${"dropdown"===this.currentMode?`\n <div class="snv-dropdown">${e}</div>\n <div class="snv-content" data-container="snv-content"></div>\n `:`\n <div class="snv-layout">\n <nav class="snv-nav">${e}</nav>\n <div class="snv-content" data-container="snv-content"></div>\n </div>\n `}\n `}_buildSidebarNav(){return this.sectionConfigs.map(e=>{if("divider"===e.type)return`<div class="snv-nav-label">${this.escapeHtml(e.label)}</div>`;const t=e.key===this.activeSection,s=e.icon?`<i class="bi ${e.icon}"></i>`:"";return`<a role="button" class="${t?"active":""}" data-action="navigate" data-section="${e.key}">${s} ${this.escapeHtml(e.label)}</a>`}).join("")}_buildDropdownNav(){const e=this.sectionConfigs.find(e=>e.key===this.activeSection),t=e?e.label:this.sectionKeys[0],s=this.sectionConfigs.filter(e=>"divider"!==e.type).map(e=>{const t=e.key===this.activeSection;return`\n <li>\n <button class="dropdown-item ${t?"active":""}"\n data-action="navigate"\n data-section="${e.key}"\n type="button">\n ${e.icon?`<i class="bi ${e.icon} me-2"></i>`:""}\n ${this.escapeHtml(e.label)}\n ${t?'<i class="bi bi-check-lg ms-2"></i>':""}\n </button>\n </li>\n `}).join("");return`\n <div class="dropdown">\n <button class="snv-select-btn" type="button"\n data-bs-toggle="dropdown" aria-expanded="false">\n ${e?.icon?`<i class="bi ${e.icon}"></i>`:""}\n <span>${this.escapeHtml(t)}</span>\n </button>\n <ul class="dropdown-menu w-100">${s}</ul>\n </div>\n `}async onAfterRender(){await super.onAfterRender(),this.activeSection&&await this._mountSection(this.activeSection),this.enableResponsive&&this._setupResponsive()}async onBeforeDestroy(){await super.onBeforeDestroy(),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),"undefined"!=typeof window&&window.removeEventListener("resize",this.handleResize);for(const e of Object.values(this.sectionViews))e&&"function"==typeof e.destroy&&await e.destroy()}async showSection(e){if(!this.sectionViews[e])return console.warn(`SideNavView: Section "${e}" does not exist`),!1;if(e===this.activeSection){const t=this.sectionViews[e];if(t&&t.isMounted()&&this.element?.contains(t.element))return!0}const t=this.activeSection;this.activeSection=e,t&&t!==e&&await this._unmountSection(t),await this._mountSection(e);const s=this.sectionViews[e];return s?.onSectionActivated&&await s.onSectionActivated(),this._updateNavState(e),this.emit("section:changed",{activeSection:e,previousSection:t}),!0}async _mountSection(e){const t=this.sectionViews[e];if(!t)return;const s=this.element?.querySelector('[data-container="snv-content"]');if(s&&!t.isMounted()){this._showContentLoading(s);try{await t.render(!0,s)}finally{this._hideContentLoading(s)}}}_showContentLoading(e){if(!e)return;let t=e.querySelector(".snv-loading");t||(t=document.createElement("div"),t.className="snv-loading",t.innerHTML='<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>',t.style.cssText="display:flex;align-items:center;justify-content:center;padding:3rem;",e.prepend(t))}_hideContentLoading(e){if(!e)return;const t=e.querySelector(".snv-loading");t&&t.remove()}async _unmountSection(e){const t=this.sectionViews[e];t&&t.isMounted()&&await t.unmount()}_updateNavState(e){if(!this.element)return;this.element.querySelectorAll(".snv-nav a, .dropdown-item").forEach(t=>{const s=t.dataset.section;s&&t.classList.toggle("active",s===e)});const t=this.element.querySelector(".snv-select-btn span");if(t){const s=this.sectionConfigs.find(t=>t.key===e);s&&(t.textContent=s.label)}}async onActionNavigate(e,t){e.preventDefault();const s=t.dataset.section;return s&&await this.showSection(s),!0}_setupResponsive(){if(this.element&&this.enableResponsive)if(this._updateMode(),"undefined"!=typeof ResizeObserver){this.resizeObserver=new ResizeObserver(()=>{this.handleResize()});const e=this.element.parentElement||this.element;this.resizeObserver.observe(e)}else window.addEventListener("resize",this.handleResize)}async handleResize(){const e=this._getContainerWidth();Math.abs(e-this.lastContainerWidth)>50&&(this.lastContainerWidth=e,await this._updateMode())}_getContainerWidth(){return this.element&&(this.element.parentElement||this.element).offsetWidth||this.minWidth}async _updateMode(){const e=this._getContainerWidth(),t=e<this.minWidth?"dropdown":"sidebar";t!==this.currentMode&&(this.currentMode=t,this.isMounted()&&await this.render(),this.emit("navigation:modeChanged",{mode:this.currentMode,containerWidth:e}))}getActiveSection(){return this.activeSection}getSectionKeys(){return[...this.sectionKeys]}getSection(e){return this.sectionViews[e]||null}async addSection(e,t=!1){return e.key&&this.sectionViews[e.key]?(console.warn(`SideNavView: Section "${e.key}" already exists`),!1):(this._addSectionConfig(e),this.isMounted()&&(await this.render(),t&&e.key&&await this.showSection(e.key)),this.emit("section:added",{config:e}),!0)}async removeSection(e){const t=this.sectionViews[e];return t?("function"==typeof t.destroy&&await t.destroy(),delete this.sectionViews[e],this.sectionKeys=this.sectionKeys.filter(t=>t!==e),this.sectionConfigs=this.sectionConfigs.filter(t=>t.key!==e),this.activeSection===e&&(this.activeSection=this.sectionKeys[0]||null),this.isMounted()&&await this.render(),this.emit("section:removed",{key:e}),!0):(console.warn(`SideNavView: Section "${e}" does not exist`),!1)}_onModelChange(){}static create(e={}){return new SideNavView(e)}}class LoginEvent extends t.Model{constructor(e={}){super(e,{endpoint:"/api/account/logins"})}}class LoginEventList extends t.Collection{constructor(e={}){super({ModelClass:LoginEvent,endpoint:"/api/account/logins",...e})}}class LoginLocationMapView extends t.View{constructor(e={}){super({className:"login-location-map-view",...e}),this.userId=e.userId||null,this.height=e.height||360,this.mapStyle=e.mapStyle||"dark",this.drStart=e.drStart||null,this.drEnd=e.drEnd||null,this._drillCountry=null,this._refreshing=!1,this.mapView=null,this._mapAvailable=!1}async getTemplate(){return`\n <div class="login-location-map">\n <div class="d-none align-items-center gap-2 mb-2" data-region="drill-bar">\n <button class="btn btn-sm btn-outline-secondary" data-action="reset-drill-down">\n <i class="bi bi-arrow-left me-1"></i>All Countries\n </button>\n <span class="text-muted small" data-region="drill-label"></span>\n </div>\n <div data-container="map" style="height:${this.height}px;"></div>\n <div class="text-muted small px-1 pt-2" data-region="status"></div>\n </div>\n `}async onInit(){try{const e=(await Promise.resolve().then(()=>require("./chunks/MetricsCountryMapView-ww-c8cxk.js")).then(e=>e.MapLibreView$1)).default;this.mapView=new e({containerId:"map",height:this.height,style:this.mapStyle,zoom:1.3,center:[10,20],pitch:15,bearing:0,showNavigationControl:!0,autoFitBounds:!1}),this.addChild(this.mapView),this._mapAvailable=!0,await this.refresh()}catch(e){this._mapAvailable=!1,this._setStatus("Map extension not available.")}}async refresh(){if(!this._refreshing&&this._mapAvailable){this._refreshing=!0,this._setStatus("Loading locations…");try{const e=await this._fetchSummary();this._applyMarkers(e),this._setStatus("")}catch(e){console.error("LoginLocationMapView refresh error",e),this._setStatus("Unable to load login locations.")}finally{this._refreshing=!1}}}async _fetchSummary(e=null){const t=this.getApp()?.rest;if(!t)throw new Error("REST client unavailable");const s={};let i;this.drStart&&(s.dr_start=this.drStart),this.drEnd&&(s.dr_end=this.drEnd),this.userId?(i="/api/account/logins/user",s.user_id=this.userId):i="/api/account/logins/summary",e&&(s.country_code=e,s.region=!0);const a=await t.GET(i,s);if(!a.success||!a.data?.status)throw new Error(a.data?.error||"Login summary API error");return a.data.data||[]}_applyMarkers(e){if(!e.length)return this.mapView.updateMarkers([]),void this._setStatus("No login locations found.");const t=Math.max(...e.map(e=>e.count)),s=e.filter(e=>e.latitude&&e.longitude).map(e=>{const s=e.count/(t||1),i=Math.round(18+26*s),a=!!e.region,n=a?e.region:e.country_code,o=a?e.new_region_count||0:e.new_country_count||0,l=a?"new region":"new country",r=`\n <div class="text-center" style="min-width:120px;">\n <div class="fw-semibold">${n}</div>\n <div class="text-muted">${e.count.toLocaleString()} login${1!==e.count?"s":""}</div>\n ${o>0?`<div><span class="badge bg-warning text-dark" style="font-size:0.65rem;">${o} ${l}</span></div>`:""}\n </div>\n `;return{lng:e.longitude,lat:e.latitude,size:i,color:this._getMarkerColor(s),popup:r,_countryCode:e.country_code,_isRegion:a}});this.mapView.updateMarkers(s),this._drillCountry||this._attachMarkerClicks(s)}_attachMarkerClicks(e){this.mapView?.mapMarkers&&this.mapView.mapMarkers.forEach((t,s)=>{const i=e[s];if(!i||i._isRegion)return;const a=t.getElement();a&&a.addEventListener("dblclick",e=>{e.stopPropagation(),this.drillDown(i._countryCode)})})}_getMarkerColor(e){const t=[255,193,7],s=[32,201,151].map((s,i)=>Math.round(s+(t[i]-s)*e));return`rgba(${s[0]}, ${s[1]}, ${s[2]}, 0.9)`}async drillDown(e){if(!this._refreshing){this._drillCountry=e,this._showDrillBar(e),this._refreshing=!0,this._setStatus("Loading regions…");try{const t=await this._fetchSummary(e);this._applyMarkers(t),this._setStatus(""),this.mapView?.markers?.length>1&&this.mapView.fitBounds()}catch(t){console.error("LoginLocationMapView drillDown error",t),this._setStatus("Unable to load region data.")}finally{this._refreshing=!1}}}async onActionResetDrillDown(){this._drillCountry=null,this._hideDrillBar(),await this.refresh()}_showDrillBar(e){const t=this.element?.querySelector('[data-region="drill-bar"]'),s=this.element?.querySelector('[data-region="drill-label"]');t&&t.classList.replace("d-none","d-flex"),s&&(s.textContent=`Regions in ${e}`)}_hideDrillBar(){const e=this.element?.querySelector('[data-region="drill-bar"]');e&&e.classList.replace("d-flex","d-none")}async onTabActivated(){this.mapView?.map&&this.mapView.map.resize(),await this.refresh()}_setStatus(e){const t=this.element?.querySelector('[data-region="status"]');t&&(t.textContent=e||"",t.style.display=e?"block":"none")}}class AdminProfileSection extends t.View{constructor(e={}){super({className:"admin-profile-section",template:'\n <style>\n .ap-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .ap-section-label:first-child { margin-top: 0; }\n .ap-field-row { display: flex; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid #f0f0f0; }\n .ap-field-row:last-child { border-bottom: none; }\n .ap-field-label { width: 140px; font-size: 0.8rem; color: #6c757d; flex-shrink: 0; }\n .ap-field-value { flex: 1; font-size: 0.88rem; color: #212529; display: flex; align-items: center; gap: 0.4rem; }\n .ap-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .ap-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n .ap-badge-ok { font-size: 0.65rem; padding: 0.15em 0.45em; background: #d1e7dd; color: #0f5132; border-radius: 3px; }\n .ap-badge-warn { font-size: 0.65rem; padding: 0.15em 0.45em; background: #fff3cd; color: #856404; border-radius: 3px; }\n .ap-badge-muted { font-size: 0.65rem; padding: 0.15em 0.45em; background: #f0f0f0; color: #6c757d; border-radius: 3px; }\n .ap-not-set { color: #adb5bd; font-style: italic; font-size: 0.85rem; }\n </style>\n\n \x3c!-- Contact & Verification --\x3e\n <div class="ap-section-label">Contact & Verification</div>\n <div class="ap-field-row">\n <div class="ap-field-label">Email</div>\n <div class="ap-field-value">\n {{model.email}}\n {{#model.is_email_verified|bool}}\n <span class="ap-badge-ok">Verified</span>\n {{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}\n <span class="ap-badge-warn">Unverified</span>\n {{/model.is_email_verified|bool}}\n </div>\n {{#model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="unverify-email" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="force-verify-email" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="change-email" title="Change email"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Phone</div>\n <div class="ap-field-value">\n {{#hasPhone|bool}}\n {{model.phone_number}}\n {{#model.is_phone_verified|bool}}\n <span class="ap-badge-ok">Verified</span>\n {{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}\n <span class="ap-badge-warn">Unverified</span>\n {{/model.is_phone_verified|bool}}\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <span class="ap-not-set">Not set</span>\n {{/hasPhone|bool}}\n </div>\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="unverify-phone" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="force-verify-phone" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="change-phone" title="Change phone"><i class="bi bi-pencil"></i></button>\n <button type="button" class="ap-field-action" data-action="remove-phone" title="Remove phone"><i class="bi bi-x-lg"></i></button>\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <button type="button" class="ap-field-action" data-action="set-phone" title="Set phone number"><i class="bi bi-plus"></i></button>\n {{/hasPhone|bool}}\n </div>\n\n \x3c!-- Account --\x3e\n <div class="ap-section-label">Account</div>\n <div class="ap-field-row">\n <div class="ap-field-label">Username</div>\n <div class="ap-field-value">{{model.username}}</div>\n <button type="button" class="ap-field-action" data-action="edit-username" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Status</div>\n <div class="ap-field-value">\n {{#model.is_active|bool}}<span class="ap-badge-ok">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="ap-badge-warn">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Role</div>\n <div class="ap-field-value">\n {{roleLabel}}\n {{#model.is_staff|bool}}<span class="ap-badge-muted">Staff</span>{{/model.is_staff|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">MFA</div>\n <div class="ap-field-value">\n {{#model.requires_mfa|bool}}<span class="ap-badge-ok">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="ap-badge-muted">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Member Since</div>\n <div class="ap-field-value">{{model.date_joined|date}}</div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Last Login</div>\n <div class="ap-field-value">{{model.last_login|relative}}</div>\n </div>\n ',...e})}get hasPhone(){return!(!this.model||!this.model.get("phone_number"))}get roleLabel(){return this.model&&this.model.get("is_superuser")?"Superuser":"User"}async onActionForceVerifyEmail(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("email")}</strong> as verified? This bypasses the normal verification flow.`,"Force Verify Email"))||(await this._saveField({is_email_verified:!0},"Email marked as verified"),!0)}async onActionUnverifyEmail(){return!(await s.Dialog.confirm("Mark email as unverified? The user will need to re-verify their email.","Unverify Email"))||(await this._saveField({is_email_verified:!1},"Email marked as unverified"),!0)}async onActionForceVerifyPhone(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("phone_number")}</strong> as verified? This bypasses the normal verification flow.`,"Force Verify Phone"))||(await this._saveField({is_phone_verified:!0},"Phone marked as verified"),!0)}async onActionUnverifyPhone(){return!(await s.Dialog.confirm("Mark phone as unverified? The user will need to re-verify their phone number.","Unverify Phone"))||(await this._saveField({is_phone_verified:!1},"Phone marked as unverified"),!0)}async onActionChangeEmail(){const e=await s.Dialog.prompt("Enter the new email address for this user:","Change Email",{defaultValue:this.model.get("email")||""});return null===e||!e.trim()||(await this._saveField({email:e.trim()},"Email updated"),!0)}async onActionChangePhone(){const e=await s.Dialog.prompt("Enter the new phone number for this user:","Change Phone",{defaultValue:this.model.get("phone_number")||""});return null===e||!e.trim()||(await this._saveField({phone_number:e.trim()},"Phone number updated"),!0)}async onActionSetPhone(){const e=await s.Dialog.prompt("Enter a phone number for this user:","Set Phone Number",{placeholder:"(415) 555-0123"});return!e||!e.trim()||(await this._saveField({phone_number:e.trim()},"Phone number added"),!0)}async onActionRemovePhone(){if(!(await s.Dialog.confirm("Remove this user's phone number?","Remove Phone")))return!0;const e=await this.model.save({phone_number:null});return 200===e.status?(this.model.set("is_phone_verified",!1),this.getApp()?.toast?.success("Phone number removed"),await this.render()):this.getApp()?.toast?.error(e.message||"Failed to remove phone number"),!0}async onActionEditUsername(){const e=await s.Dialog.prompt("Username:","Edit Username",{defaultValue:this.model.get("username")||""});return null!==e&&e.trim()&&await this._saveField({username:e.trim()},"Username updated"),!0}async _saveField(e,t){const s=await this.model.save(e);200===s.status?(this.getApp()?.toast?.success(t),await this.render()):this.getApp()?.toast?.error(s.message||"Failed to save")}}class AdminPersonalSection extends t.View{constructor(e={}){super({className:"admin-personal-section",template:'\n <style>\n .aps-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .aps-section-label:first-child { margin-top: 0; }\n .aps-field-row { display: flex; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid #f0f0f0; }\n .aps-field-row:last-child { border-bottom: none; }\n .aps-field-label { width: 140px; font-size: 0.8rem; color: #6c757d; flex-shrink: 0; }\n .aps-field-value { flex: 1; font-size: 0.88rem; color: #212529; display: flex; align-items: center; gap: 0.4rem; }\n .aps-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .aps-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n .aps-badge-ok { font-size: 0.65rem; padding: 0.15em 0.45em; background: #d1e7dd; color: #0f5132; border-radius: 3px; }\n .aps-badge-warn { font-size: 0.65rem; padding: 0.15em 0.45em; background: #fff3cd; color: #856404; border-radius: 3px; }\n .aps-not-set { color: #adb5bd; font-style: italic; font-size: 0.85rem; }\n </style>\n\n \x3c!-- Name --\x3e\n <div class="aps-section-label">Name</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Display Name</div>\n <div class="aps-field-value">\n {{#model.display_name}}{{model.display_name}}{{/model.display_name}}\n {{^model.display_name}}<span class="aps-not-set">Not set</span>{{/model.display_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">First Name</div>\n <div class="aps-field-value">\n {{#model.first_name}}{{model.first_name}}{{/model.first_name}}\n {{^model.first_name}}<span class="aps-not-set">Not set</span>{{/model.first_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-first-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">Last Name</div>\n <div class="aps-field-value">\n {{#model.last_name}}{{model.last_name}}{{/model.last_name}}\n {{^model.last_name}}<span class="aps-not-set">Not set</span>{{/model.last_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-last-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="aps-section-label">Details</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Date of Birth</div>\n <div class="aps-field-value">\n {{#hasDob|bool}}\n {{dobFormatted}}\n {{#model.is_dob_verified|bool}}<span class="aps-badge-ok">Verified</span>{{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}<span class="aps-badge-warn">Unverified</span>{{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n {{^hasDob|bool}}<span class="aps-not-set">Not set</span>{{/hasDob|bool}}\n </div>\n {{#hasDob|bool}}\n {{#model.is_dob_verified|bool}}\n <button type="button" class="aps-field-action" data-action="unverify-dob" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}\n <button type="button" class="aps-field-action" data-action="force-verify-dob" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n <button type="button" class="aps-field-action" data-action="edit-dob" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">Timezone</div>\n <div class="aps-field-value">{{timezoneDisplay}}</div>\n <button type="button" class="aps-field-action" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n\n \x3c!-- Address --\x3e\n <div class="aps-section-label">Address</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Address</div>\n <div class="aps-field-value">\n {{#hasAddress|bool}}{{addressSummary}}{{/hasAddress|bool}}\n {{^hasAddress|bool}}<span class="aps-not-set">Not set</span>{{/hasAddress|bool}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-address" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n ',...e})}get hasDob(){return!!this.model?.get("dob")}get dobFormatted(){const e=this.model?.get("dob");if(!e)return"";try{const[t,s,i]=e.split("-");return`${s}/${i}/${t}`}catch{return e}}get timezoneDisplay(){return(this.model?.get("metadata")||{}).timezone||"Not set"}get hasAddress(){const e=this.model?.get("metadata")||{};return!!(e.street||e.city||e.state||e.zip||e.country)}get addressSummary(){const e=this.model?.get("metadata")||{};return[e.street,e.city,e.state,e.zip,e.country].filter(Boolean).join(", ")}async onActionForceVerifyDob(){return!(await s.Dialog.confirm("Mark date of birth as verified?","Force Verify DOB"))||(await this._saveField({is_dob_verified:!0},"DOB marked as verified"),!0)}async onActionUnverifyDob(){return!(await s.Dialog.confirm("Mark date of birth as unverified?","Unverify DOB"))||(await this._saveField({is_dob_verified:!1},"DOB marked as unverified"),!0)}async onActionEditDisplayName(){const e=await s.Dialog.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return null!==e&&e.trim()&&await this._saveField({display_name:e.trim()},"Display name"),!0}async onActionEditFirstName(){const e=await s.Dialog.prompt("First name:","Edit First Name",{defaultValue:this.model.get("first_name")||""});return null!==e&&await this._saveField({first_name:e.trim()},"First name"),!0}async onActionEditLastName(){const e=await s.Dialog.prompt("Last name:","Edit Last Name",{defaultValue:this.model.get("last_name")||""});return null!==e&&await this._saveField({last_name:e.trim()},"Last name"),!0}async onActionEditDob(){const e=await s.Dialog.showForm({title:"Date of Birth",size:"sm",fields:[{name:"dob",type:"date",label:"Date of Birth",cols:12}],data:{dob:this.model.get("dob")||""}});return!e||(await this._saveField({dob:e.dob||null},"Date of birth"),!0)}async onActionEditTimezone(){const e=this.model.get("metadata")||{},t=await s.Dialog.showForm({title:"Change Timezone",size:"sm",fields:[{name:"timezone",type:"select",label:"Timezone",cols:12,options:[{value:"America/New_York",text:"Eastern Time (ET)"},{value:"America/Chicago",text:"Central Time (CT)"},{value:"America/Denver",text:"Mountain Time (MT)"},{value:"America/Los_Angeles",text:"Pacific Time (PT)"},{value:"America/Anchorage",text:"Alaska Time (AKT)"},{value:"Pacific/Honolulu",text:"Hawaii Time (HT)"},{value:"UTC",text:"UTC"},{value:"Europe/London",text:"London (GMT/BST)"},{value:"Europe/Paris",text:"Paris (CET/CEST)"},{value:"Europe/Berlin",text:"Berlin (CET/CEST)"},{value:"Asia/Tokyo",text:"Tokyo (JST)"},{value:"Asia/Shanghai",text:"Shanghai (CST)"},{value:"Australia/Sydney",text:"Sydney (AEST)"}]}],data:{timezone:e.timezone||""}});return!t||(await this._saveField({metadata:{...e,timezone:t.timezone}},"Timezone"),!0)}async onActionEditAddress(){const e=this.model.get("metadata")||{},t=await s.Dialog.showForm({title:"Edit Address",size:"md",fields:[{name:"street",type:"text",label:"Street",placeholder:"123 Main St",cols:12},{name:"city",type:"text",label:"City",cols:6},{name:"state",type:"text",label:"State / Province",cols:6},{name:"zip",type:"text",label:"Zip / Postal Code",cols:6},{name:"country",type:"text",label:"Country",cols:6}],data:{street:e.street||"",city:e.city||"",state:e.state||"",zip:e.zip||"",country:e.country||""}});if(!t)return!0;const i={...e,street:t.street||"",city:t.city||"",state:t.state||"",zip:t.zip||"",country:t.country||""};return await this._saveField({metadata:i},"Address"),!0}async _saveField(e,t){const s=await this.model.save(e);200===s.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(s.message||`Failed to update ${t.toLowerCase()}`)}}class AdminSecuritySection extends t.View{constructor(e={}){super({className:"admin-security-section",template:'\n <style>\n .as-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .as-section-label:first-child { margin-top: 0; }\n .as-item { display: flex; align-items: center; gap: 0.85rem; padding: 0.85rem 1rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.5rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }\n .as-item:hover { border-color: #dee2e6; background: #fafbfd; }\n .as-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }\n .as-info { flex: 1; min-width: 0; }\n .as-title { font-weight: 600; font-size: 0.88rem; }\n .as-desc { font-size: 0.78rem; color: #6c757d; }\n .as-badge { font-size: 0.72rem; padding: 0.15em 0.5em; border-radius: 3px; flex-shrink: 0; }\n .as-chevron { color: #ced4da; font-size: 0.8rem; flex-shrink: 0; }\n </style>\n\n <div class="as-section-label">Authentication</div>\n\n <div class="as-item" data-action="send-password-reset">\n <div class="as-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="as-info">\n <div class="as-title">Send Password Reset</div>\n <div class="as-desc">Send a password reset email to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n\n <div class="as-item" data-action="send-magic-link">\n <div class="as-icon" style="background: rgba(13,110,253,0.1); color: #0d6efd;"><i class="bi bi-link-45deg"></i></div>\n <div class="as-info">\n <div class="as-title">Send Magic Login Link</div>\n <div class="as-desc">Send a one-click login link to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n\n {{^model.is_email_verified|bool}}\n <div class="as-item" data-action="send-email-verification">\n <div class="as-icon" style="background: rgba(25,135,84,0.1); color: #198754;"><i class="bi bi-envelope-check"></i></div>\n <div class="as-info">\n <div class="as-title">Send Email Verification</div>\n <div class="as-desc">Send a verification email to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n {{/model.is_email_verified|bool}}\n\n <div class="as-item" data-action="set-password">\n <div class="as-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="as-info">\n <div class="as-title">Set Password</div>\n <div class="as-desc">Set a new password directly for this user</div>\n </div>\n <span class="as-badge bg-light text-muted border">Set</span>\n </div>\n\n <div class="as-section-label">Multi-Factor Authentication</div>\n\n <div class="as-item" data-action="toggle-mfa">\n <div class="as-icon" style="background: rgba(111,66,193,0.1); color: #6f42c1;"><i class="bi bi-shield-lock"></i></div>\n <div class="as-info">\n <div class="as-title">MFA Requirement</div>\n <div class="as-desc">\n {{#model.requires_mfa|bool}}User is required to use MFA{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}MFA is not required for this user{{/model.requires_mfa|bool}}\n </div>\n </div>\n {{#model.requires_mfa|bool}}\n <span class="as-badge bg-success bg-opacity-10 text-success border">Enabled</span>\n {{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}\n <span class="as-badge bg-light text-muted border">Disabled</span>\n {{/model.requires_mfa|bool}}\n </div>\n\n <div class="as-item" data-action="manage-passkeys">\n <div class="as-icon bg-success bg-opacity-10 text-success"><i class="bi bi-fingerprint"></i></div>\n <div class="as-info">\n <div class="as-title">Passkeys</div>\n <div class="as-desc">View and manage registered passkeys</div>\n </div>\n <i class="bi bi-chevron-right as-chevron"></i>\n </div>\n\n {{#model.requires_mfa|bool}}\n <div class="as-item" data-action="view-recovery-codes">\n <div class="as-icon" style="background: rgba(111,66,193,0.1); color: #6f42c1;"><i class="bi bi-file-earmark-lock"></i></div>\n <div class="as-info">\n <div class="as-title">Recovery Codes</div>\n <div class="as-desc">View remaining recovery codes</div>\n </div>\n <i class="bi bi-chevron-right as-chevron"></i>\n </div>\n\n <div class="as-item" data-action="disable-totp">\n <div class="as-icon" style="background: rgba(220,53,69,0.1); color: #dc3545;"><i class="bi bi-shield-x"></i></div>\n <div class="as-info">\n <div class="as-title">Disable Authenticator</div>\n <div class="as-desc">Remove TOTP requirement for this user</div>\n </div>\n </div>\n {{/model.requires_mfa|bool}}\n\n <div class="as-section-label">Sessions</div>\n\n <div class="as-item" data-action="revoke-all-sessions">\n <div class="as-icon" style="background: rgba(220,53,69,0.1); color: #dc3545;"><i class="bi bi-box-arrow-right"></i></div>\n <div class="as-info">\n <div class="as-title">Revoke All Sessions</div>\n <div class="as-desc">Force sign-out from all devices</div>\n </div>\n </div>\n ',...e})}async onActionSendPasswordReset(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a password reset email to <strong>${i}</strong>?`,"Send Password Reset")))return!0;const a=await t.rest.POST("/api/auth/password/reset",{email:i});return a.success?e?.toast?.success("Password reset email sent"):e?.toast?.error(a.message||"Failed to send password reset"),!0}async onActionSendEmailVerification(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a verification email to <strong>${i}</strong>?`,"Send Email Verification")))return!0;const a=await t.rest.POST("/api/auth/email/verify",{email:i});return a.success?e?.toast?.success("Verification email sent"):e?.toast?.error(a.message||"Failed to send verification email"),!0}async onActionSendMagicLink(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a magic login link to <strong>${i}</strong>? They will be able to sign in with one click.`,"Send Magic Login Link")))return!0;const a=await t.rest.POST("/api/auth/magic-link",{email:i});return a.success?e?.toast?.success("Magic login link sent"):e?.toast?.error(a.message||"Failed to send magic link"),!0}async onActionSetPassword(){const e=this.getApp(),t=await s.Dialog.showForm({title:"Set Password",size:"sm",fields:[{name:"password",type:"password",label:"New Password",required:!0,cols:12,help:"Set a new password for this user."},{name:"confirm",type:"password",label:"Confirm Password",required:!0,cols:12}]});if(!t)return!0;if(t.password!==t.confirm)return e?.toast?.error("Passwords do not match"),!0;const i=await this.model.save({password:t.password});return 200===i.status?e?.toast?.success("Password updated"):e?.toast?.error(i.message||"Failed to set password"),!0}async onActionToggleMfa(){const e=this.getApp(),t=this.model.get("requires_mfa"),i=t?"disable":"enable";if(!(await s.Dialog.confirm((t?"Disable":"Enable")+" MFA requirement for this user?",(t?"Disable":"Enable")+" MFA")))return!0;const a=await this.model.save({requires_mfa:!t});return 200===a.status?(e?.toast?.success(`MFA ${i}d`),await this.render()):e?.toast?.error(a.message||`Failed to ${i} MFA`),!0}async onActionDisableTotp(){const e=this.getApp();if(!(await s.Dialog.confirm("Disable the authenticator app for this user? They will no longer need a TOTP code to sign in.","Disable Authenticator")))return!0;const i=await t.rest.DELETE(`/api/user/${this.model.id}/totp`);return i.success?(this.model.set("requires_mfa",!1),e?.toast?.success("Authenticator disabled"),await this.render()):e?.toast?.error(i.message||"Failed to disable authenticator"),!0}async onActionManagePasskeys(){const e=new n.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch(o){}const i=e.models||[],a=new t.View({template:'\n <style>\n .pk-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.65rem 0.75rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.4rem; }\n .pk-icon { width: 32px; height: 32px; background: #e7f1ff; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #0d6efd; font-size: 0.9rem; flex-shrink: 0; }\n .pk-info { flex: 1; min-width: 0; }\n .pk-name { font-weight: 600; font-size: 0.85rem; }\n .pk-meta { font-size: 0.73rem; color: #6c757d; }\n .pk-actions .btn { padding: 0.2rem 0.4rem; font-size: 0.75rem; }\n .pk-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .pk-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n {{#passkeys}}\n <div class="pk-row">\n <div class="pk-icon"><i class="bi bi-fingerprint"></i></div>\n <div class="pk-info">\n <div class="pk-name">{{.friendly_name|default:\'Unnamed Passkey\'}}</div>\n <div class="pk-meta">Created {{.created|date}} · Last used {{.last_used|relative|default:\'never\'}} · {{.sign_count}} uses</div>\n </div>\n <div class="pk-actions">\n <button type="button" class="btn btn-outline-secondary" data-action="edit-passkey" data-id="{{.id}}" title="Edit"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-outline-danger" data-action="delete-passkey" data-id="{{.id}}" title="Delete"><i class="bi bi-trash"></i></button>\n </div>\n </div>\n {{/passkeys}}\n {{^passkeys|bool}}\n <div class="pk-empty">\n <i class="bi bi-fingerprint"></i>\n No passkeys registered\n </div>\n {{/passkeys|bool}}\n '});return a.passkeys=i.map(e=>e.toJSON?e.toJSON():e),a.onActionEditPasskey=async(e,t)=>{const a=t.dataset.id,o=i.find(e=>String(e.id)===String(a));return o&&await s.Dialog.showModelForm({title:"Edit Passkey",model:o,fields:n.PasskeyForms.edit.fields,size:"sm"}),!0},a.onActionDeletePasskey=async(e,t)=>{const a=t.dataset.id;if(await s.Dialog.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(a));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await s.Dialog.showDialog({title:"Passkeys",body:a,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionViewRecoveryCodes(){const e=this.getApp(),i=await t.rest.GET(`/api/user/${this.model.id}/totp/recovery-codes`,{},{dataOnly:!0});if(!i.success||!i.data)return e?.toast?.error(i.message||"Failed to load recovery codes"),!0;const{remaining:a,codes:n}=i.data,o=new t.View({template:'\n <style>\n .rc-info { font-size: 0.82rem; color: #6c757d; margin-bottom: 1rem; }\n .rc-remaining { font-weight: 600; color: #495057; }\n .rc-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem; }\n .rc-code { font-family: monospace; font-size: 0.85rem; padding: 0.35rem 0.6rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; text-align: center; }\n </style>\n <div class="rc-info"><span class="rc-remaining">{{remaining}}</span> recovery codes remaining</div>\n <div class="rc-list">\n {{#codes}}<div class="rc-code">{{.}}</div>{{/codes}}\n </div>\n '});return o.remaining=a,o.codes=n||[],await s.Dialog.showDialog({title:"Recovery Codes",body:o,size:"sm",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionRevokeAllSessions(){const e=this.getApp();if(!(await s.Dialog.confirm("Revoke all sessions for this user? They will be signed out of all devices immediately.","Revoke All Sessions")))return!0;const i=await t.rest.POST(`/api/user/${this.model.id}/sessions/revoke`);return i.success?e?.toast?.success("All sessions revoked"):e?.toast?.error(i.message||"Failed to revoke sessions"),!0}}const b={google:"bi-google",github:"bi-github",microsoft:"bi-microsoft",apple:"bi-apple",facebook:"bi-facebook",twitter:"bi-twitter-x",linkedin:"bi-linkedin"};class AdminConnectedSection extends t.View{constructor(e={}){super({className:"admin-connected-section",template:'\n <style>\n .ac-row { display: flex; align-items: center; gap: 0.85rem; padding: 0.85rem 1rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.5rem; }\n .ac-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; background: #f0f0f0; color: #495057; }\n .ac-info { flex: 1; min-width: 0; }\n .ac-provider { font-weight: 600; font-size: 0.88rem; text-transform: capitalize; }\n .ac-meta { font-size: 0.78rem; color: #6c757d; }\n .ac-actions .btn { font-size: 0.75rem; padding: 0.25rem 0.5rem; }\n .ac-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .ac-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n\n {{#connections}}\n <div class="ac-row">\n <div class="ac-icon"><i class="bi {{.icon}}"></i></div>\n <div class="ac-info">\n <div class="ac-provider">{{.provider}}</div>\n <div class="ac-meta">{{.email}} · Connected {{.created|relative}}</div>\n </div>\n <div class="ac-actions">\n <button type="button" class="btn btn-outline-danger" data-action="unlink" data-id="{{.id}}" title="Unlink"><i class="bi bi-x-lg me-1"></i>Unlink</button>\n </div>\n </div>\n {{/connections}}\n {{^connections|bool}}\n <div class="ac-empty">\n <i class="bi bi-plug"></i>\n No connected accounts\n </div>\n {{/connections|bool}}\n ',...e}),this.connections=[]}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id}),s=e?.data?.results||e?.data||[];this.connections=s.map(e=>({...e,icon:b[e.provider]||"bi-link-45deg"}))}catch(e){this.connections=[]}}async onActionUnlink(e,i){const a=i.dataset.id,n=this.connections.find(e=>String(e.id)===String(a)),o=n?.provider||"this account";if(!(await s.Dialog.confirm(`Unlink ${o} for this user?`,"Unlink Account")))return!0;const l=await t.rest.DELETE(`/api/account/oauth_connection/${a}`);return l.success?(this.getApp()?.toast?.success(`${o} account unlinked`),await this.render()):this.getApp()?.toast?.error(l.message||"Failed to unlink account"),!0}}const h={in_app:"In-App",email:"Email",push:"Push"},p=["in_app","email","push"];class AdminNotificationsSection extends t.View{constructor(e={}){super({className:"admin-notifications-section",template:'\n <style>\n .an-table { width: 100%; border-collapse: collapse; }\n .an-table th { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; padding: 0.5rem 0.75rem; border-bottom: 2px solid #e9ecef; }\n .an-table th:first-child { text-align: left; }\n .an-table th:not(:first-child) { text-align: center; width: 80px; }\n .an-table td { padding: 0.65rem 0.75rem; border-bottom: 1px solid #f0f0f0; }\n .an-table td:first-child { font-size: 0.88rem; font-weight: 500; text-transform: capitalize; }\n .an-table td:not(:first-child) { text-align: center; }\n .an-table tr:last-child td { border-bottom: none; }\n .an-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .an-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n\n {{#hasPreferences|bool}}\n <table class="an-table">\n <thead>\n <tr>\n <th>Type</th>\n {{#channels}}\n <th>{{.label}}</th>\n {{/channels}}\n </tr>\n </thead>\n <tbody>\n {{#preferenceRows}}\n <tr>\n <td>{{.kindLabel}}</td>\n {{#.toggles}}\n <td>\n <input type="checkbox" class="form-check-input"\n data-action="toggle-pref"\n data-kind="{{.kind}}"\n data-channel="{{.channel}}"\n {{#.checked}}checked{{/.checked}}>\n </td>\n {{/.toggles}}\n </tr>\n {{/preferenceRows}}\n </tbody>\n </table>\n {{/hasPreferences|bool}}\n {{^hasPreferences|bool}}\n <div class="an-empty">\n <i class="bi bi-bell"></i>\n No notification preferences configured\n </div>\n {{/hasPreferences|bool}}\n ',...e}),this.preferences={}}get channels(){return p.map(e=>({key:e,label:h[e]||e}))}get hasPreferences(){return Object.keys(this.preferences).length>0}get preferenceRows(){return Object.keys(this.preferences).sort().map(e=>({kind:e,kindLabel:e.replace(/[_-]/g," ").replace(/\b\w/g,e=>e.toUpperCase()),toggles:p.map(t=>({kind:e,channel:t,checked:!1!==this.preferences[e]?.[t]}))}))}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/notification/preferences",{user:this.model.id},{dataOnly:!0});this.preferences=e?.data?.preferences||e?.data||{}}catch(e){this.preferences={}}}async onActionTogglePref(e,s){const i=s.dataset.kind,a=s.dataset.channel,n=s.checked;this.preferences[i]||(this.preferences[i]={}),this.preferences[i][a]=n;try{const e=await t.rest.POST("/api/account/notification/preferences",{user:this.model.id,preferences:{[i]:{[a]:n}}});e.success||(this.getApp()?.toast?.error(e.message||"Failed to update preference"),s.checked=!n)}catch(o){this.getApp()?.toast?.error("Failed to update preference"),s.checked=!n}return!0}}class AdminApiKeysSection extends t.View{constructor(e={}){super({className:"admin-api-keys-section",template:'\n <style>\n .aak-list { border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden; }\n .aak-item { display: flex; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #f0f0f0; gap: 1rem; }\n .aak-item:last-child { border-bottom: none; }\n .aak-item-icon { color: #6c757d; font-size: 1.1rem; flex-shrink: 0; }\n .aak-item-info { flex: 1; min-width: 0; }\n .aak-item-name { font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; }\n .aak-item-meta { font-size: 0.75rem; color: #6c757d; display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.15rem; }\n .aak-item-meta i { margin-right: 0.2rem; }\n .aak-empty { padding: 2rem; text-align: center; color: #6c757d; font-size: 0.85rem; }\n .aak-empty i { font-size: 1.5rem; display: block; margin-bottom: 0.5rem; }\n .aak-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }\n .aak-header h6 { margin: 0; font-weight: 600; }\n .aak-result { padding: 1rem; background: #d1e7dd; border: 1px solid #badbcc; border-radius: 8px; margin-bottom: 1rem; }\n .aak-result-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #0f5132; margin-bottom: 0.5rem; }\n .aak-token-wrap { display: flex; gap: 0.5rem; align-items: center; }\n .aak-token { flex: 1; font-family: monospace; font-size: 0.78rem; padding: 0.5rem 0.75rem; background: #fff; border: 1px solid #dee2e6; border-radius: 4px; word-break: break-all; max-height: 80px; overflow-y: auto; }\n .aak-token-warning { font-size: 0.75rem; color: #dc3545; margin-top: 0.5rem; font-weight: 600; }\n </style>\n\n <div id="aak-new-token" style="display: none;">\n <div class="aak-result">\n <div class="aak-result-label">Generated API Key</div>\n <div class="aak-token-wrap">\n <div class="aak-token" id="aak-token-display"></div>\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="copy-token" title="Copy">\n <i class="bi bi-clipboard"></i>\n </button>\n </div>\n <div class="aak-token-warning">\n <i class="bi bi-exclamation-circle me-1"></i>This token will not be shown again. Copy it now.\n </div>\n </div>\n </div>\n\n <div class="aak-header">\n <h6>API Keys</h6>\n <button type="button" class="btn btn-primary btn-sm" data-action="generate-key">\n <i class="bi bi-plus-lg me-1"></i>Generate Key\n </button>\n </div>\n\n <div id="aak-keys-list"></div>\n ',...e}),this.apiKeys=[],this.generatedToken=null}async onBeforeRender(){await this._loadKeys()}async _loadKeys(){try{const e=await t.rest.GET("/api/account/api_keys",{user:this.model.id},{},{dataOnly:!0});this.apiKeys=e.success&&Array.isArray(e.data)?e.data:[]}catch(e){this.apiKeys=[]}}onAfterRender(){this._renderKeysList()}_renderKeysList(){const e=this.element?.querySelector("#aak-keys-list");if(!e)return;if(!this.apiKeys.length)return void(e.innerHTML='\n <div class="aak-list">\n <div class="aak-empty">\n <i class="bi bi-key"></i>\n No API keys for this user\n </div>\n </div>');const t=this.apiKeys.map(e=>{const t=e.name||"API Key",s=e.created?new Date(1e3*e.created).toLocaleDateString():"",i=e.expires?new Date(1e3*e.expires).toLocaleDateString():"Never",a=e.last_used?new Date(1e3*e.last_used).toLocaleDateString():"Never",n=e.allowed_ips?.length?e.allowed_ips.join(", "):"Any";return`\n <div class="aak-item">\n <div class="aak-item-icon"><i class="bi bi-key"></i></div>\n <div class="aak-item-info">\n <div class="aak-item-name">${t} ${!1!==e.is_active?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}</div>\n <div class="aak-item-meta">\n <span><i class="bi bi-code-square"></i>${e.token_prefix?`${e.token_prefix}...`:"••••••••"}</span>\n <span><i class="bi bi-calendar"></i>Created ${s}</span>\n <span><i class="bi bi-clock"></i>Expires ${i}</span>\n <span><i class="bi bi-activity"></i>Last used ${a}</span>\n <span><i class="bi bi-globe"></i>IPs: ${n}</span>\n </div>\n </div>\n <div>\n <button type="button" class="btn btn-outline-danger btn-sm" data-action="revoke-key" data-id="${e.id}" title="Revoke">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n </div>`}).join("");e.innerHTML=`<div class="aak-list">${t}</div>`}async onActionGenerateKey(){const e=await s.Dialog.showForm({title:`Generate API Key for ${this.model.get("display_name")||this.model.get("email")}`,icon:"bi-key",fields:[{name:"name",type:"text",label:"Key Name",placeholder:"e.g., CI/CD Pipeline, Mobile App",required:!0,help:"A descriptive name to identify this key."},{name:"allowed_ips",type:"text",label:"Allowed IPs",placeholder:"e.g., 203.0.113.0/24, 10.0.0.1",help:"Optional. Comma-separated IP addresses or CIDR ranges."},{name:"expire_days",type:"select",label:"Expiration",value:"90",options:[{value:"30",label:"30 days"},{value:"60",label:"60 days"},{value:"90",label:"90 days"},{value:"180",label:"180 days"},{value:"360",label:"360 days"}]}]});if(!e)return!0;const i={uid:this.model.id,name:e.name,expire_days:parseInt(e.expire_days||"90",10)},a=(e.allowed_ips||"").trim();a&&(i.allowed_ips=a.split(",").map(e=>e.trim()).filter(Boolean));const n=await t.rest.POST("/api/auth/manage/generate_api_key",i,{},{dataOnly:!0});if(n.success&&n.data?.token){this.generatedToken=n.data.token;const e=this.element.querySelector("#aak-new-token"),t=this.element.querySelector("#aak-token-display");e&&t&&(t.textContent=this.generatedToken,e.style.display="block"),this.getApp()?.toast?.success("API key generated"),await this._loadKeys(),this._renderKeysList()}else this.getApp()?.toast?.error(n.message||"Failed to generate API key");return!0}async onActionRevokeKey(e,i){const a=i.dataset.id;if(!a)return!0;if(!(await s.Dialog.confirm("Revoke this API key? Any applications using it will lose access immediately.","Revoke API Key")))return!0;const n=await t.rest.DELETE(`/api/account/api_keys/${a}`,{},{},{dataOnly:!0});if(n.success){this.getApp()?.toast?.success("API key revoked");const e=this.element?.querySelector("#aak-new-token");e&&(e.style.display="none"),await this._loadKeys(),this._renderKeysList()}else this.getApp()?.toast?.error(n.message||"Failed to revoke API key");return!0}async onActionCopyToken(){if(this.generatedToken)try{await navigator.clipboard.writeText(this.generatedToken),this.getApp()?.toast?.success("Token copied to clipboard")}catch{this.getApp()?.toast?.error("Failed to copy token")}return!0}}class AdminMetadataSection extends t.View{constructor(e={}){super({className:"admin-metadata-section",template:'\n <style>\n .amd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }\n .amd-header h6 { margin: 0; font-weight: 600; }\n .amd-list { border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden; }\n .amd-item { display: flex; align-items: center; padding: 0.6rem 1rem; border-bottom: 1px solid #f0f0f0; gap: 0.75rem; }\n .amd-item:last-child { border-bottom: none; }\n .amd-key { font-family: ui-monospace, monospace; font-size: 0.82rem; font-weight: 600; color: #495057; min-width: 120px; flex-shrink: 0; }\n .amd-value { flex: 1; font-size: 0.85rem; color: #212529; word-break: break-word; min-width: 0; }\n .amd-actions { flex-shrink: 0; display: flex; gap: 0.25rem; }\n .amd-actions .btn { font-size: 0.7rem; padding: 0.15rem 0.35rem; }\n .amd-empty { padding: 2rem; text-align: center; color: #6c757d; font-size: 0.85rem; }\n .amd-empty i { font-size: 1.5rem; display: block; margin-bottom: 0.5rem; }\n </style>\n\n <div class="amd-header">\n <h6>Metadata</h6>\n <button type="button" class="btn btn-primary btn-sm" data-action="add-entry">\n <i class="bi bi-plus-lg me-1"></i>Add\n </button>\n </div>\n\n <div id="amd-entries"></div>\n ',...e})}onAfterRender(){this._renderEntries()}_getMetadata(){return this.model?.get("metadata")||{}}_renderEntries(){const e=this.element?.querySelector("#amd-entries");if(!e)return;const t=this._getMetadata(),s=Object.keys(t).sort();if(!s.length)return void(e.innerHTML='\n <div class="amd-list">\n <div class="amd-empty">\n <i class="bi bi-braces"></i>\n No metadata entries\n </div>\n </div>');const i=s.map(e=>{const s=t[e],i="object"==typeof s?JSON.stringify(s):String(s);return`\n <div class="amd-item">\n <div class="amd-key">${this._escapeHtml(e)}</div>\n <div class="amd-value">${this._escapeHtml(i)}</div>\n <div class="amd-actions">\n <button type="button" class="btn btn-outline-secondary" data-action="edit-entry" data-key="${this._escapeHtml(e)}" title="Edit"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-outline-danger" data-action="remove-entry" data-key="${this._escapeHtml(e)}" title="Remove"><i class="bi bi-trash"></i></button>\n </div>\n </div>`}).join("");e.innerHTML=`<div class="amd-list">${i}</div>`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionAddEntry(){const e=await s.Dialog.showForm({title:"Add Metadata Entry",icon:"bi-braces",size:"sm",fields:[{name:"key",type:"text",label:"Key",required:!0,placeholder:"e.g., timezone"},{name:"value",type:"text",label:"Value",required:!0,placeholder:"e.g., America/New_York"}]});if(!e)return!0;const t={...this._getMetadata()};try{t[e.key]=JSON.parse(e.value)}catch{t[e.key]=e.value}return 200===(await this.model.save({metadata:t})).status?(this.getApp()?.toast?.success("Metadata entry added"),this._renderEntries()):this.getApp()?.toast?.error("Failed to save metadata"),!0}async onActionEditEntry(e,t){const i=t.dataset.key;if(!i)return!0;const a=this._getMetadata(),n=a[i],o="object"==typeof n?JSON.stringify(n):String(n),l=await s.Dialog.showForm({title:`Edit "${i}"`,icon:"bi-braces",size:"sm",fields:[{name:"value",type:"text",label:"Value",required:!0,value:o}]});if(!l)return!0;const r={...a};try{r[i]=JSON.parse(l.value)}catch{r[i]=l.value}return 200===(await this.model.save({metadata:r})).status?(this.getApp()?.toast?.success("Metadata updated"),this._renderEntries()):this.getApp()?.toast?.error("Failed to save metadata"),!0}async onActionRemoveEntry(e,t){const i=t.dataset.key;if(!i)return!0;if(!(await s.Dialog.confirm(`Remove metadata key "<strong>${this._escapeHtml(i)}</strong>"?`,"Remove Entry")))return!0;const a={...this._getMetadata()};return delete a[i],200===(await this.model.save({metadata:a})).status?(this.getApp()?.toast?.success("Metadata entry removed"),this._renderEntries()):this.getApp()?.toast?.error("Failed to remove metadata entry"),!0}}class DeviceRow extends n.TableRow{get deviceIcon(){const e=this.model?.get("device_info")?.device||{},t=this.model?.get("device_info")?.os||{};return["iPhone","Android"].some(s=>(e.family||"").includes(s)||(t.family||"").includes(s))?"bi-phone":"bi-laptop"}get deviceName(){const e=this.model?.get("device_info")?.device||{};return`${e.brand||""} ${e.family||""}`.trim()||"Unknown Device"}get deviceModel(){return this.model?.get("device_info")?.device?.model||""}get browserName(){const e=this.model?.get("device_info")?.user_agent||{};return e.family?`${e.family} ${e.major||""}`.trim():""}get osName(){const e=this.model?.get("device_info")?.os||{};return e.family?`${e.family} ${e.major||""}`.trim():""}get deviceMeta(){return[this.browserName,this.osName].filter(Boolean).join(" · ")||"—"}}class UserView extends t.View{constructor(e={}){super({className:"user-view",...e}),this.model=e.model||new s.User(e.data||{}),this.sideNavView=null,this.template='\n <div class="user-view-container">\n \x3c!-- User Header + Context Menu --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div data-container="user-header" style="flex: 1;"></div>\n <div data-container="user-context-menu" class="ms-3 flex-shrink-0"></div>\n </div>\n \x3c!-- Side Nav Container --\x3e\n <div data-container="user-sidenav" style="min-height: 400px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"user-header",template:'\n <div class="d-flex justify-content-between align-items-start">\n \x3c!-- Left Side: Primary Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{{model.avatar|avatar(\'md\',\'rounded-circle\')}}}\n <div>\n <h3 class="mb-0">{{model.display_name|default(\'Unnamed User\')}}</h3>\n <a href="mailto:{{model.email}}" class="text-decoration-none text-body">{{model.email}}</a>{{{model.email|clipboard(\'icon-only\')}}}\n {{#model.phone_number}}\n <div class="text-muted small mt-1">{{{model.phone_number|phone(false)}}}</div>\n {{/model.phone_number}}\n </div>\n </div>\n\n \x3c!-- Right Side: Status --\x3e\n <div class="text-end">\n <div class="d-flex align-items-center justify-content-end gap-3">\n <span class="d-inline-flex align-items-center gap-1" title="{{model.is_online|boolean(\'Online\',\'Offline\')}}">\n <i class="bi bi-circle-fill {{model.is_online|boolean(\'text-success\',\'text-secondary\')}}" style="font-size: 0.5rem;"></i>\n <span class="small">{{model.is_online|boolean(\'Online\',\'Offline\')}}</span>\n </span>\n <span class="d-inline-flex align-items-center gap-1" style="cursor: pointer;"\n data-action="toggle-active"\n title="{{model.is_active|boolean(\'Click to deactivate\',\'Click to activate\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.last_activity}}\n <div class="text-muted small mt-1">Last active {{model.last_activity|relative}}</div>\n {{/model.last_activity}}\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const i=new AdminProfileSection({model:this.model}),l=new AdminPersonalSection({model:this.model}),r=new AdminSecuritySection({model:this.model}),d=new AdminConnectedSection({model:this.model}),c=new AdminNotificationsSection({model:this.model}),m=new AdminApiKeysSection({model:this.model}),u=new AdminMetadataSection({model:this.model}),b=new o.FormView({fields:s.User.CATEGORY_PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),h=new o.FormView({fields:s.User.GRANULAR_PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),p=new n.MemberList({params:{user:this.model.get("id"),size:5}}),g=new n.TableView({collection:p,hideActivePillNames:["user"],columns:[{key:"created",label:"Date Joined",formatter:"date",sortable:!0},{key:"group.name",label:"Group Name",sortable:!0},{key:"permissions|keys|badge",label:"Permissions"}]}),v=new a.IncidentEventList({params:{size:5,model_name:"account.User",model_id:this.model.get("id")}}),f=new n.TableView({containerId:"events-table",collection:v,hideActivePillNames:["model_name","model_id"],columns:[{key:"id",label:"ID",sortable:!0,width:"40px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Event"}]}),y=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Security & Account Events</h6>\n <p class="text-muted small mb-3">Incidents and account actions associated with this user.</p>\n </div>\n <div data-container="events-table"></div>'});y.addChild(f);const w=new n.TableView({collection:new s.UserDeviceList({params:{size:10,user:this.model.get("id")}}),hideActivePillNames:["user"],clickAction:"view",itemClass:DeviceRow,columns:[{key:"device_info",label:"Device",template:'\n <div style="font-size:0.85rem; font-weight:500;">\n <i class="bi {{deviceIcon}} text-muted me-1" style="font-size:1.1rem; vertical-align:middle;"></i>{{deviceName}}\n {{#deviceModel}} <span class="text-muted fw-normal">({{deviceModel}})</span>{{/deviceModel}}\n </div>\n <div style="font-size:0.73rem; color:#6c757d; margin-top:0.15rem;">\n {{deviceMeta}}\n {{#model.last_ip}} <span class="text-muted mx-1">·</span> {{model.last_ip}}{{/model.last_ip}}\n </div>'},{key:"first_seen",label:"First Seen",formatter:"epoch|relative",width:"120px"},{key:"last_seen",label:"Last Seen",formatter:"epoch|relative",width:"120px"}]}),_=new LoginLocationMapView({userId:this.model.get("id"),height:300,mapStyle:"dark"}),x=new n.TableView({collection:new LoginEventList({params:{user:this.model.get("id"),size:10}}),hideActivePillNames:["user"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"ip_address",label:"IP Address"},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0}]});x.onTabActivated=async()=>{await(x.collection?.fetch())};const k=new a.TabView({tabs:{Map:_,Logins:x},activeTab:"Map"}),A=new a.PushDeviceList({params:{size:5,user:this.model.get("id")}}),S=new n.TableView({collection:A,hideActivePillNames:["user"],columns:[{key:"duid|truncate_middle(16)",label:"Device ID",sortable:!0},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')"},{key:"device_info.os.family",label:"OS",formatter:"default('—')"},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime"},{key:"last_seen",label:"Last Seen",formatter:"epoch|datetime"}],size:5}),C=new n.LogList({params:{size:5,model_name:"account.User",model_id:this.model.get("id")}}),I=new n.TableView({containerId:"logs-table",collection:C,permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Event Type",filter:{type:"text"}},{name:"log",label:"Details"}]}),P=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Object Logs</h6>\n <p class="text-muted small mb-3">System log entries about changes to this user\'s record.</p>\n </div>\n <div data-container="logs-table"></div>'});P.addChild(I);const T=new n.LogList({params:{size:5,uid:this.model.get("id")}}),D=new n.TableView({containerId:"activity-table",collection:T,hideActivePillNames:["uid"],permissions:"view_logs",columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Event Type",filter:{type:"text"}},{name:"path",label:"Request Path"}]}),V=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Activity Log</h6>\n <p class="text-muted small mb-3">API and request activity performed by this user.</p>\n </div>\n <div data-container="activity-table"></div>'});V.addChild(D),this.sideNavView=new SideNavView({containerId:"user-sidenav",activeSection:"profile",navWidth:200,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:[{key:"profile",label:"Profile",icon:"bi-person",view:i},{key:"personal",label:"Personal",icon:"bi-person-vcard",view:l},{key:"security",label:"Security",icon:"bi-shield-lock",view:r},{key:"connected",label:"OAuth Accounts",icon:"bi-plug",view:d},{type:"divider",label:"Access"},{key:"permissions",label:"Permissions",icon:"bi-shield-check",view:b},{key:"adv_permissions",label:"Adv Permissions",icon:"bi-shield-plus",view:h},{key:"groups",label:"Groups",icon:"bi-people",view:g},{key:"api_keys",label:"API Keys",icon:"bi-key",view:m},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:y},{key:"activity",label:"Activity Log",icon:"bi-clock-history",view:V,permissions:"view_logs"},{key:"logs",label:"Object Logs",icon:"bi-journal-text",view:P,permissions:"view_logs"},{type:"divider",label:"Devices"},{key:"devices",label:"Devices",icon:"bi-laptop",view:w},{key:"locations",label:"Locations",icon:"bi-geo-alt",view:k},{key:"push_devices",label:"Push Devices",icon:"bi-phone",view:S},{type:"divider",label:"Settings"},{key:"notifications",label:"Notifications",icon:"bi-bell",view:c},{key:"metadata",label:"Metadata",icon:"bi-braces",view:u}]}),this.addChild(this.sideNavView);const M=new e.ContextMenu({containerId:"user-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit User",action:"edit-user",icon:"bi-pencil"},...this.model.get("avatar")?[{label:"Clear Avatar",action:"clear-avatar",icon:"bi-person-x"}]:[],{type:"divider"},{label:"Send Password Reset",action:"send-password-reset",icon:"bi-envelope"},{label:"Send Magic Login Link",action:"send-magic-link",icon:"bi-link-45deg"},{label:"Revoke All Sessions",action:"revoke-all-sessions",icon:"bi-box-arrow-right"},{type:"divider"},...this.model.get("is_email_verified")?[]:[{label:"Send Email Verification",action:"send-email-verification",icon:"bi-envelope-check"},{label:"Force Verify Email",action:"force-verify-email",icon:"bi-patch-check"}],...this.model.get("phone_number")&&!this.model.get("is_phone_verified")?[{label:"Force Verify Phone",action:"force-verify-phone",icon:"bi-patch-check"}]:[],{type:"divider"},this.model.get("is_active")?{label:"Deactivate User",action:"deactivate-user",icon:"bi-person-dash"}:{label:"Activate User",action:"activate-user",icon:"bi-person-check"}]}});this.addChild(M)}async onActionEditUser(){await s.Dialog.showModelForm({title:`EDIT - #${this.model.id} ${this.options.modelName}`,model:this.model,formConfig:s.UserForms.edit})}async onActionClearAvatar(){return!(await s.Dialog.confirm("Remove this user's avatar? They will see the default placeholder.","Clear Avatar"))||(200===(await this.model.save({avatar:null})).status?this.getApp().toast.success("Avatar cleared"):this.getApp().toast.error("Failed to clear avatar"),!0)}async onActionSendPasswordReset(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a password reset email to <strong>${e}</strong>?`,"Send Password Reset")))return!0;const i=await t.rest.POST("/api/auth/password/reset",{email:e});return i.success?this.getApp().toast.success("Password reset email sent"):this.getApp().toast.error(i.message||"Failed to send password reset"),!0}async onActionSendMagicLink(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a magic login link to <strong>${e}</strong>?`,"Send Magic Login Link")))return!0;const i=await t.rest.POST("/api/auth/magic-link",{email:e});return i.success?this.getApp().toast.success("Magic login link sent"):this.getApp().toast.error(i.message||"Failed to send magic link"),!0}async onActionRevokeAllSessions(){if(!(await s.Dialog.confirm("Revoke all sessions? The user will be signed out of all devices immediately.","Revoke All Sessions")))return!0;const e=await t.rest.POST(`/api/user/${this.model.id}/sessions/revoke`);return e.success?this.getApp().toast.success("All sessions revoked"):this.getApp().toast.error(e.message||"Failed to revoke sessions"),!0}async onActionSendEmailVerification(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a verification email to <strong>${e}</strong>?`,"Send Email Verification")))return!0;const i=await t.rest.POST("/api/auth/email/verify",{email:e});return i.success?this.getApp().toast.success("Verification email sent"):this.getApp().toast.error(i.message||"Failed to send verification email"),!0}async onActionForceVerifyEmail(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("email")}</strong> as verified?`,"Force Verify Email"))||(200===(await this.model.save({is_email_verified:!0})).status?this.getApp().toast.success("Email marked as verified"):this.getApp().toast.error("Failed to verify email"),!0)}async onActionForceVerifyPhone(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("phone_number")}</strong> as verified?`,"Force Verify Phone"))||(200===(await this.model.save({is_phone_verified:!0})).status?this.getApp().toast.success("Phone marked as verified"):this.getApp().toast.error("Failed to verify phone"),!0)}async onActionToggleActive(){return this.model.get("is_active")?this.onActionDeactivateUser():this.onActionActivateUser()}async onActionDeactivateUser(){return!(await s.Dialog.confirm("Are you sure you want to deactivate this user?"))||(200===(await this.model.save({is_active:!1})).status?this.getApp().toast.success("User deactivated"):this.getApp().toast.error("Failed to deactivate user"),!0)}async onActionActivateUser(){return!(await s.Dialog.confirm("Are you sure you want to activate this user?"))||(200===(await this.model.save({is_active:!0})).status?this.getApp().toast.success("User activated"):this.getApp().toast.error("Failed to activate user"),!0)}async showSection(e){this.sideNavView&&await this.sideNavView.showSection(e)}getActiveSection(){return this.sideNavView?this.sideNavView.getActiveSection():null}async showTab(e){return this.showSection(e)}getActiveTab(){return this.getActiveSection()}_onModelChange(){}static create(e={}){return new UserView(e)}}s.User.VIEW_CLASS=UserView;class UserTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_users",pageName:"Manage Users",router:"admin/users",Collection:s.UserList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-last_activity",is_active:!0},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"display_name|tooltip:model.username",label:"Display Name"},{label:"Info",key:"permissions.manage_users",template:"\n {{^model.is_active}}<span class=\"text-danger\">DISABLED</span> {{/model.is_active}}\n {{#model.permissions.manage_users}}{{{model.permissions.manage_users|yesnoicon('bi bi-person-gear text-danger')|tooltip('Manage Users')}}} {{/model.permissions.manage_users}}\n {{#model.permissions.manage_groups}}{{{model.permissions.manage_groups|yesnoicon('bi bi-building-gear text-primary')|tooltip('Manage Groups')}}} {{/model.permissions.manage_groups}}\n {{#model.permissions.view_global}}{{{model.permissions.view_global|yesnoicon('bi bi-globe text-secondary')|tooltip('View Global Menu')}}} {{/model.permissions.view_global}}\n {{#model.permissions.view_admin}}{{{model.permissions.view_admin|yesnoicon('bi bi-wrench text-secondary')|tooltip('View Admin Menu')}}} {{/model.permissions.view_admin}}\n ",sortable:!1},{key:"email",label:"Email",visibility:"xl",className:"text-muted fs-8"},{key:"last_activity",label:"Last Activity",formatter:"relative",className:"text-muted fs-8"}],filters:[{key:"is_active",label:"Active",type:"boolean",defaultValue:!0},{key:"email",label:"Email",type:"text",defaultValue:""},{key:"username",label:"Username",type:"text",defaultValue:""},{key:"locations__ip_address",label:"IP Address",type:"text",defaultValue:""},{key:"last_activity",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No users found. Click "Add" to create a new user.',contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Profile"},{icon:"bi-shield-check",action:"edit-permissions",label:"Edit Permissions"},{icon:"bi-shield",action:"change-password",label:"Change Password"},{separator:!0},{icon:"bi-envelope",action:"send-invite",label:"Send Invite"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditPermissions(e,t){e.preventDefault();const i=this.collection.get(t.dataset.id);await s.Dialog.showModelForm({model:i,size:"lg",title:`Edit Permissions for "${i._.username}"`,fields:s.UserForms.permissions.fields})}async onActionChangePassword(e,i){const a=this.collection.get(i.dataset.id),n=await s.Dialog.showForm({title:`Change Password for "${a._.username}"`,fields:[{type:"text",name:"username",value:a.get("email")||a.get("username"),attributes:{autocomplete:"username",readonly:"readonly",tabindex:"-1",style:"position: absolute; left: -9999px; opacity: 0; height: 0; width: 0;"}},{name:"new_password",label:"New Password",type:"password",passwordUsage:"new",required:!0,showToggle:!0,attributes:{autocomplete:"new-password"}}]});if(n&&n.new_password){if(t.MOJOUtils.checkPasswordStrength(n.new_password).score<5)return this.getApp().toast.error("Password must be at least 6 characters long and contain at least 2 of the following: uppercase letter, lowercase letter, or number"),void(await this.onActionChangePassword(e,i));const s=await a.save({new_password:n.new_password});this.onPasswordChange(s)||await this.onActionChangePassword(e,i)}}onPasswordChange(e){return e.success?(this.getApp().toast.success("Password changed successfully"),!0):(e.data&&e.data.error?this.getApp().toast.error(e.data.error):this.getApp().toast.error("Failed to change password"),!1)}async onActionSendInvite(e,t){const s=this.collection.get(t.dataset.id),i=await s.save({send_invite:!0});return i.success?(this.getApp().toast.success("Invite sent successfully"),!0):(i.data&&i.data.error?this.getApp().toast.error(i.data.error):this.getApp().toast.error("Failed to send invite"),!1)}}class MemberView extends t.View{constructor(e={}){super({className:"member-view",...e}),this.model=e.model||new n.Member(e.data||{}),this.template='\n <div class="member-view-container">\n \x3c!-- Header + Context Menu --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div data-container="member-header" style="flex: 1;"></div>\n <div data-container="member-context-menu" class="ms-3 flex-shrink-0"></div>\n </div>\n \x3c!-- Side Nav --\x3e\n <div data-container="member-sidenav" style="min-height: 300px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"member-header",template:'\n <div class="d-flex justify-content-between align-items-start">\n \x3c!-- Left: Avatar + Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{{model.user.avatar|avatar(\'md\',\'rounded-circle\')}}}\n <div>\n <h4 class="mb-0">\n <a href="#" data-action="view-user" class="text-decoration-none text-body">{{model.user.display_name}}</a>\n </h4>\n <div class="text-muted small mt-1">\n <i class="bi bi-people me-1"></i>\n <a href="#" data-action="view-group" class="text-decoration-none">{{model.group.name}}</a>\n {{#model.group.kind}}\n <span class="badge bg-light text-muted border ms-1" style="font-size: 0.65rem;">{{model.group.kind|capitalize}}</span>\n {{/model.group.kind}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Status --\x3e\n <div class="text-end">\n <div class="d-flex align-items-center gap-2">\n {{#model.metadata.role}}\n <span class="badge bg-primary bg-opacity-10 text-primary" style="font-size: 0.72rem;">{{model.metadata.role}}</span>\n {{/model.metadata.role}}\n <span class="d-inline-flex align-items-center gap-1" style="cursor: pointer;"\n data-action="toggle-active"\n title="{{model.is_active|boolean(\'Click to deactivate\',\'Click to activate\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.created}}\n <div class="text-muted small mt-1">Joined {{model.created|date}}</div>\n {{/model.created}}\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const s=new t.View({model:this.model,template:'\n <style>\n .mv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .mv-section-label:first-child { margin-top: 0; }\n .mv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .mv-field-row:last-child { border-bottom: none; }\n .mv-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .mv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .mv-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .mv-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n </style>\n\n <div class="mv-section-label">User</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Name</div>\n <div class="mv-field-value">\n <a href="#" data-action="view-user" class="text-decoration-none">{{model.user.display_name}}</a>\n </div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Email</div>\n <div class="mv-field-value">{{model.user.email}}</div>\n </div>\n\n <div class="mv-section-label">Group</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Name</div>\n <div class="mv-field-value">\n <a href="#" data-action="view-group" class="text-decoration-none">{{model.group.name}}</a>\n </div>\n </div>\n {{#model.group.kind}}\n <div class="mv-field-row">\n <div class="mv-field-label">Kind</div>\n <div class="mv-field-value"><span class="badge bg-primary bg-opacity-10 text-primary">{{model.group.kind|capitalize}}</span></div>\n </div>\n {{/model.group.kind}}\n\n <div class="mv-section-label">Membership</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Role</div>\n <div class="mv-field-value">{{model.metadata.role|default(\'—\')}}</div>\n <button type="button" class="mv-field-action" data-action="edit-membership" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Status</div>\n <div class="mv-field-value">\n {{#model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#d1e7dd; color:#0f5132; border-radius:3px;">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#fff3cd; color:#856404; border-radius:3px;">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Member ID</div>\n <div class="mv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.id}}</div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Joined</div>\n <div class="mv-field-value">{{model.created|datetime|default(\'—\')}}</div>\n </div>\n '}),i=new o.FormView({fields:n.Member.PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),a=new n.TableView({collection:new n.LogList({params:{size:10,model_name:"account.Member",model_id:this.model.get("id")}}),permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0},{key:"kind",label:"Kind"},{name:"log",label:"Log"}]});this.sideNavView=new SideNavView({containerId:"member-sidenav",activeSection:"details",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:s},{key:"permissions",label:"Permissions",icon:"bi-shield-check",view:i},{type:"divider",label:"Activity"},{key:"logs",label:"Logs",icon:"bi-journal-text",view:a,permissions:"view_logs"}]}),this.addChild(this.sideNavView);const l=new e.ContextMenu({containerId:"member-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Membership",action:"edit-membership",icon:"bi-pencil"},{type:"divider"},{label:"View User",action:"view-user",icon:"bi-person"},{label:"View Group",action:"view-group",icon:"bi-people"},{type:"divider"},this.model.get("is_active")?{label:"Deactivate Member",action:"deactivate-member",icon:"bi-toggle-off"}:{label:"Activate Member",action:"activate-member",icon:"bi-toggle-on"},{label:"Remove From Group",action:"remove-member",icon:"bi-person-dash",danger:!0}]}});this.addChild(l)}async onActionEditMembership(){await l.default.modelForm({title:"Edit Membership",model:this.model,formConfig:n.MemberForms.edit})}async onActionViewUser(){const e=this.model.get("user")?.id;if(!e)return!0;const{User:t}=await Promise.resolve().then(()=>require("./chunks/Dialog-2gXM2UcO.js")).then(e=>e.User$1);return await l.default.showModelById(t,e),!0}async onActionViewGroup(){const e=this.model.get("group")?.id;if(!e)return!0;const{Group:t}=await Promise.resolve().then(()=>require("./chunks/Dialog-2gXM2UcO.js")).then(e=>e.Group$1);return await l.default.showModelById(t,e),!0}async onActionToggleActive(){return this.model.get("is_active")?this.onActionDeactivateMember():this.onActionActivateMember()}async onActionDeactivateMember(){return!(await l.default.confirm(`Deactivate <strong>${this.model.get("user.display_name")}</strong>'s membership in <strong>${this.model.get("group.name")}</strong>?`,"Deactivate Member"))||(200===(await this.model.save({is_active:!1})).status?this.getApp()?.toast?.success("Member deactivated"):this.getApp()?.toast?.error("Failed to deactivate member"),!0)}async onActionActivateMember(){return!(await l.default.confirm(`Activate <strong>${this.model.get("user.display_name")}</strong>'s membership in <strong>${this.model.get("group.name")}</strong>?`,"Activate Member"))||(200===(await this.model.save({is_active:!0})).status?this.getApp()?.toast?.success("Member activated"):this.getApp()?.toast?.error("Failed to activate member"),!0)}async onActionRemoveMember(){return!(await l.default.confirm(`Remove <strong>${this.model.get("user.display_name")}</strong> from <strong>${this.model.get("group.name")}</strong>? This cannot be undone.`,"Remove Member"))||((await this.model.destroy()).success?(this.getApp()?.toast?.success("Member removed"),this.emit("member:removed",{model:this.model})):this.getApp()?.toast?.error("Failed to remove member"),!0)}_onModelChange(){}static create(e={}){return new MemberView(e)}}n.Member.VIEW_CLASS=MemberView;class MemberTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_members",pageName:"Manage Members",router:"admin/members",Collection:n.MemberList,formEdit:n.MemberForms.edit,itemViewClass:MemberView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"user.display_name",label:"User",formatter:"default('Unknown User')"},{key:"user.email",label:"Email",formatter:"default('No Email')"},{key:"group.name",label:"Group",formatter:"default('Unknown Group')"},{key:"role",label:"Role",formatter:"badge"},{key:"status",label:"Status",formatter:"badge"},{key:"created",label:"Added",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No members found. Click "Add Member" to add users to groups.',batchBarLocation:"top",batchActions:[{label:"Remove",icon:"bi bi-person-dash",action:"batch-remove"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Change Role",icon:"bi bi-person-gear",action:"batch-role"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class ApiKey extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/group/apikey",...t})}}class ApiKeyList extends t.Collection{constructor(e={}){super({ModelClass:ApiKey,endpoint:"/api/group/apikey",size:25,...e})}}const g={create:{title:"Create API Key",fields:[{name:"name",type:"text",label:"Name",placeholder:"Mobile App v2",required:!0,columns:12,help:"A descriptive name to identify this key."},{name:"group",type:"number",label:"Group ID",required:!0,columns:12,help:"The group this key is scoped to."},{name:"permissions",type:"textarea",label:"Permissions (JSON)",placeholder:'{"view_orders": true, "create_orders": true}',columns:12,help:"JSON dict of permissions to grant. Leave empty for no permissions."}]},edit:{title:"Edit API Key",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"is_active",type:"switch",label:"Active",columns:12,help:"Deactivate to revoke access without deleting the key."},{name:"permissions",type:"textarea",label:"Permissions (JSON)",columns:12,help:"JSON dict of granted permissions."}]}};class GroupView extends t.View{constructor(e={}){super({className:"group-view",...e}),this.model=e.model||new s.Group(e.data||{}),this.template='\n <div class="group-view-container">\n \x3c!-- Header --\x3e\n <div data-container="group-header"></div>\n \x3c!-- Side Nav --\x3e\n <div data-container="group-sidenav" style="min-height: 400px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"group-header",template:'\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left Side: Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{#model.avatar}}\n {{{model.avatar|avatar(\'md\',\'rounded\')}}}\n {{/model.avatar}}\n {{^model.avatar}}\n <div class="d-flex align-items-center justify-content-center rounded bg-light" style="width: 56px; height: 56px;">\n <i class="bi bi-people text-secondary" style="font-size: 1.5rem;"></i>\n </div>\n {{/model.avatar}}\n <div>\n <h3 class="mb-0">{{model.name|default(\'Unnamed Group\')}}</h3>\n <div class="d-flex align-items-center gap-2 mt-1">\n <span class="badge bg-primary bg-opacity-10 text-primary" style="font-size: 0.72rem;">{{model.kind|capitalize}}</span>\n {{#model.parent}}\n <span class="text-muted small">\n <i class="bi bi-diagram-3 me-1"></i>\n <a href="#" data-action="view-parent" data-id="{{model.parent.id}}" class="text-decoration-none">{{model.parent.name}}</a>\n </span>\n {{/model.parent}}\n </div>\n {{#model.metadata.timezone}}\n <div class="text-muted small mt-1"><i class="bi bi-clock me-1"></i>{{model.metadata.timezone}}</div>\n {{/model.metadata.timezone}}\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center justify-content-end gap-3">\n <span class="d-inline-flex align-items-center gap-1" title="{{model.is_active|boolean(\'Group Active\',\'Group Inactive\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.last_activity}}\n <div class="text-muted small mt-1">Last active {{model.last_activity|relative}}</div>\n {{/model.last_activity}}\n {{#model.created}}\n <div class="text-muted small mt-1">Created {{model.created|date}}</div>\n {{/model.created}}\n </div>\n <div data-container="group-context-menu"></div>\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const i=new t.View({model:this.model,template:'\n <style>\n .gv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .gv-section-label:first-child { margin-top: 0; }\n .gv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .gv-field-row:last-child { border-bottom: none; }\n .gv-field-label { width: 140px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .gv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .gv-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .gv-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n </style>\n\n <div class="gv-section-label">Group</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Name</div>\n <div class="gv-field-value">{{model.name}}</div>\n <button type="button" class="gv-field-action" data-action="edit-group" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Kind</div>\n <div class="gv-field-value"><span class="badge bg-primary bg-opacity-10 text-primary">{{model.kind|capitalize}}</span></div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Status</div>\n <div class="gv-field-value">\n {{#model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#d1e7dd; color:#0f5132; border-radius:3px;">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#fff3cd; color:#856404; border-radius:3px;">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">ID</div>\n <div class="gv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.id}}</div>\n </div>\n\n <div class="gv-section-label">Hierarchy</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Parent</div>\n <div class="gv-field-value">\n {{#model.parent}}\n <a href="#" data-action="view-parent" data-id="{{model.parent.id}}" class="text-decoration-none">{{model.parent.name}}</a>\n <span class="text-muted small ms-1">({{model.parent.kind|capitalize}})</span>\n {{/model.parent}}\n {{^model.parent}}<span style="color:#adb5bd; font-style:italic; font-size:0.85rem;">None — top-level group</span>{{/model.parent}}\n </div>\n </div>\n\n <div class="gv-section-label">Settings</div>\n {{#model.metadata.timezone}}\n <div class="gv-field-row">\n <div class="gv-field-label">Timezone</div>\n <div class="gv-field-value">{{model.metadata.timezone}}</div>\n </div>\n {{/model.metadata.timezone}}\n {{#model.metadata.domain}}\n <div class="gv-field-row">\n <div class="gv-field-label">Domain</div>\n <div class="gv-field-value">{{model.metadata.domain}}</div>\n </div>\n {{/model.metadata.domain}}\n {{#model.metadata.portal}}\n <div class="gv-field-row">\n <div class="gv-field-label">Portal URL</div>\n <div class="gv-field-value"><a href="{{model.metadata.portal}}" target="_blank" class="text-decoration-none">{{model.metadata.portal}}</a></div>\n </div>\n {{/model.metadata.portal}}\n {{#model.metadata.eod_hour}}\n <div class="gv-field-row">\n <div class="gv-field-label">End of Day</div>\n <div class="gv-field-value">{{model.metadata.eod_hour}}:00</div>\n </div>\n {{/model.metadata.eod_hour}}\n\n <div class="gv-section-label">Dates</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Created</div>\n <div class="gv-field-value">{{model.created|datetime|default(\'—\')}}</div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Modified</div>\n <div class="gv-field-value">{{model.modified|datetime|default(\'—\')}}</div>\n </div>\n '}),o=new n.TableView({collection:new n.MemberList({params:{group:this.model.get("id"),size:10}}),hideActivePillNames:["group"],clickAction:"view",showAdd:!0,addButtonLabel:"Invite",onAdd:e=>this.onInviteClick(e),columns:[{key:"user.display_name",label:"User",sortable:!0},{key:"user.email",label:"Email",sortable:!0},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Joined",formatter:"date",sortable:!0}]}),l=new n.TableView({collection:new s.GroupList({params:{parent:this.model.get("id"),size:10}}),hideActivePillNames:["parent"],clickAction:"view",showAdd:!0,addButtonLabel:"Add Group",onAdd:()=>this.onActionAddChildGroup(),columns:[{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",formatter:"badge"},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge bg-success bg-opacity-10 text-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge bg-secondary bg-opacity-10 text-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"created",label:"Created",formatter:"date",sortable:!0}]}),r=new n.TableView({collection:new a.IncidentEventList({params:{size:10,model_name:"account.Group",model_id:this.model.get("id")}}),hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),d=new n.TableView({collection:new ApiKeyList({params:{group:this.model.get("id"),size:10}}),hideActivePillNames:["group"],clickAction:"view",showAdd:!0,addButtonLabel:"Create Key",addFormConfig:{...g.create,defaults:{group:this.model.get("id")}},columns:[{key:"name",label:"Name",sortable:!0},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge bg-success bg-opacity-10 text-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge bg-secondary bg-opacity-10 text-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}]}),c=new AdminMetadataSection({model:this.model}),m=new n.TableView({collection:new n.LogList({params:{size:10,model_name:"account.Group",model_id:this.model.get("id")}}),permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});this.sideNavView=new SideNavView({containerId:"group-sidenav",activeSection:"details",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:i},{key:"members",label:"Members",icon:"bi-people",view:o},{key:"children",label:"Sub-Groups",icon:"bi-diagram-3",view:l},{key:"api_keys",label:"API Keys",icon:"bi-key",view:d},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:r},{key:"logs",label:"Logs",icon:"bi-journal-text",view:m,permissions:"view_logs"},{type:"divider",label:"Settings"},{key:"metadata",label:"Metadata",icon:"bi-braces",view:c}]}),this.addChild(this.sideNavView);const u=new e.ContextMenu({containerId:"group-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Group",action:"edit-group",icon:"bi-pencil"},{type:"divider"},{label:"Invite Member",action:"invite-member",icon:"bi-person-plus"},{label:"Add Sub-Group",action:"add-child-group",icon:"bi-diagram-3"},{type:"divider"},this.model.get("is_active")?{label:"Deactivate Group",action:"deactivate-group",icon:"bi-toggle-off"}:{label:"Activate Group",action:"activate-group",icon:"bi-toggle-on"}]}});this.addChild(u)}async onActionEditGroup(){await s.Dialog.showModelForm({title:`Edit Group — ${this.model.get("name")}`,model:this.model,size:"lg",formConfig:s.GroupForms.detailed})&&await this.render()}async onActionInviteMember(){return this.onInviteClick(new Event("click"))}async onInviteClick(e){e?.preventDefault&&(e.preventDefault(),e.stopPropagation());const t=await s.Dialog.showForm({title:`Invite User to ${this.model.get("name")}`,size:"sm",fields:[{type:"email",name:"email",label:"Email",required:!0,cols:12}]});if(!t?.email)return!0;const i=this.getApp(),a=await i.rest.POST("/api/group/member/invite",{group:this.model.id,email:t.email});return a.success?(i.toast.success("User invited successfully"),"members"===this.sideNavView?.getActiveSection()&&await this.sideNavView.showSection("members")):i.toast.error(a.message||"Failed to invite user"),!0}async onActionAddChildGroup(){const e=await s.Dialog.showForm({title:`Add Sub-Group to ${this.model.get("name")}`,size:"sm",fields:s.GroupForms.create.fields.filter(e=>"parent"!==e.name)});if(!e)return!0;e.parent=this.model.id;const t=new s.Group(e),i=await t.save();return 200===i.status||201===i.status?(this.getApp()?.toast?.success("Sub-group created"),"children"===this.sideNavView?.getActiveSection()&&await this.sideNavView.showSection("children")):this.getApp()?.toast?.error(i.message||"Failed to create sub-group"),!0}async onActionDeactivateGroup(){return!(await s.Dialog.confirm(`Are you sure you want to deactivate <strong>${this.model.get("name")}</strong>?`,"Deactivate Group"))||(200===(await this.model.save({is_active:!1})).status?(this.getApp()?.toast?.success("Group deactivated"),await this.render()):this.getApp()?.toast?.error("Failed to deactivate group"),!0)}async onActionActivateGroup(){return!(await s.Dialog.confirm(`Are you sure you want to activate <strong>${this.model.get("name")}</strong>?`,"Activate Group"))||(200===(await this.model.save({is_active:!0})).status?(this.getApp()?.toast?.success("Group activated"),await this.render()):this.getApp()?.toast?.error("Failed to activate group"),!0)}async onActionViewParent(e,t){const i=t?.dataset?.id;if(!i)return!0;const a=new s.Group({id:i});return await a.fetch(),a.id&&s.Dialog.showDialog({title:!1,size:"lg",body:new GroupView({model:a}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}),!0}async showSection(e){this.sideNavView&&await this.sideNavView.showSection(e)}getActiveSection(){return this.sideNavView?this.sideNavView.getActiveSection():null}_onModelChange(){}static create(e={}){return new GroupView(e)}}s.Group.VIEW_CLASS=GroupView;class GroupTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_groups",pageName:"Manage Groups",router:"admin/groups",Collection:s.GroupList,formCreate:s.GroupForms.create,formEdit:s.GroupForms.edit,itemViewClass:GroupView,viewDialogOptions:{header:!1},defaultQuery:{sort:"-id",is_active:1},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Display Name"},{key:"kind|badge",label:"Kind",filter:{type:"select",options:s.Group.GroupKindOptions}},{key:"is_active|yesnoicon",label:"Enabled",visibility:"lg"},{key:"parent.name",label:"Parent",formatter:"default('-')",visibility:"md",class:"text-muted fs-8"},{key:"created",label:"Created",className:"text-muted fs-8",formatter:"epoch|datetime",visibility:"lg"},{key:"last_activity",label:"Activity",className:"text-muted fs-8",formatter:"relative",visibility:"lg"}],filters:[{key:"is_active",label:"Active",type:"select",options:[{label:"Active",value:!0},{label:"Inactive",value:!1}]}],contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Group"},{icon:"bi-bullseye",action:"make-active",label:"Make Active Group"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No groups found. Click "Add Group" to create your first one.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"},{label:"Move",icon:"bi bi-arrow-right",action:"batch-move"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}onActionMakeActive(e,t){const s=this.collection.get(t.dataset.id);this.getApp().setActiveGroup(s)}}class DeviceLocationRow extends n.TableRow{get locationText(){const e=this.model?.get("geolocation")||{};return[e.city,e.region].filter(Boolean).join(", ")||e.country_name||"—"}get countryName(){return this.model?.get("geolocation")?.country_name||""}get ispName(){const e=this.model?.get("geolocation")||{};return e.isp||e.asn_org||""}get threatFlags(){const e=this.model?.get("geolocation")||{},t=[];return e.is_vpn&&t.push('<span class="badge bg-warning text-dark" style="font-size:0.6rem;">VPN</span>'),e.is_tor&&t.push('<span class="badge bg-danger" style="font-size:0.6rem;">Tor</span>'),e.is_proxy&&t.push('<span class="badge bg-warning text-dark" style="font-size:0.6rem;">Proxy</span>'),t.join(" ")}get hasThreatFlags(){const e=this.model?.get("geolocation")||{};return!!(e.is_vpn||e.is_tor||e.is_proxy)}}class DeviceView extends t.View{constructor(e={}){super({className:"device-view",...e}),this.model=e.model||new s.UserDevice(e.data||{}),this.deviceInfo=this.model.get("device_info")||{},this.deviceIcon=this._getIcon(this.deviceInfo),this.browserFull=this._getBrowser(),this.osFull=this._getOS(),this.deviceFull=this._getDevice(),this.isMobile=this._isMobile(),this.template='\n <style>\n .dv-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }\n .dv-identity { display: flex; align-items: center; gap: 1rem; }\n .dv-icon-wrap { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; flex-shrink: 0; }\n .dv-title { font-size: 1.15rem; font-weight: 600; margin: 0; line-height: 1.3; }\n .dv-subtitle { font-size: 0.8rem; color: #6c757d; margin-top: 0.15rem; }\n .dv-status { text-align: right; display: flex; align-items: flex-start; gap: 0.75rem; }\n .dv-last-seen-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.04em; }\n .dv-last-seen-value { font-size: 0.88rem; font-weight: 500; }\n .dv-last-seen-ip { font-size: 0.75rem; color: #6c757d; margin-top: 0.1rem; }\n </style>\n\n <div class="dv-header">\n <div class="dv-identity">\n <div class="dv-icon-wrap bg-primary bg-opacity-10 text-primary">\n <i class="bi {{deviceIcon}}"></i>\n </div>\n <div>\n <h4 class="dv-title">{{browserFull}} <span class="fw-normal text-muted">on</span> {{osFull}}</h4>\n <div class="dv-subtitle">\n {{deviceFull}}\n {{#model.user.display_name}}\n <span class="text-muted mx-1">·</span>\n <a href="#" data-action="view-user" class="text-decoration-none">{{model.user.display_name}}</a>\n {{/model.user.display_name}}\n </div>\n </div>\n </div>\n <div class="dv-status">\n <div>\n <div class="dv-last-seen-label">Last Seen</div>\n <div class="dv-last-seen-value">{{model.last_seen|relative}}</div>\n {{#model.last_ip}}<div class="dv-last-seen-ip">{{model.last_ip}}</div>{{/model.last_ip}}\n </div>\n <div data-container="device-context-menu"></div>\n </div>\n </div>\n\n <div data-container="device-sidenav" style="min-height: 300px;"></div>\n '}_getBrowser(){const e=this.deviceInfo?.user_agent||{},t=[e.family,e.major].filter(Boolean);return t.length?t.join(" "):"Unknown Browser"}_getOS(){const e=this.deviceInfo?.os||{},t=[e.major,e.minor].filter(Boolean).join(".");return e.family?`${e.family} ${t}`.trim():"Unknown OS"}_getDevice(){const e=this.deviceInfo?.device||{},t=[e.brand,e.family].filter(Boolean),s=t.length?t.join(" "):"Unknown Device";return e.model?`${s} (${e.model})`:s}_isMobile(){const e=this.deviceInfo?.device||{},t=this.deviceInfo?.os||{};return["iPhone","Android","iPad"].some(s=>(e.family||"").includes(s)||(t.family||"").includes(s))}_getIcon(e){const t=e?.os?.family?.toLowerCase()||"",s=e?.user_agent?.family?.toLowerCase()||"",i=e?.device?.family?.toLowerCase()||"";return s.includes("chrome")?"bi-browser-chrome":s.includes("firefox")?"bi-browser-firefox":s.includes("safari")?"bi-browser-safari":s.includes("edge")?"bi-browser-edge":t.includes("mac")||t.includes("ios")?"bi-apple":t.includes("windows")?"bi-windows":t.includes("android")?"bi-android2":t.includes("linux")?"bi-ubuntu":i.includes("iphone")?"bi-phone":i.includes("ipad")?"bi-tablet":"bi-laptop"}async onInit(){const i=new t.View({model:this.model,template:'\n <style>\n .dv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .dv-section-label:first-child { margin-top: 0; }\n .dv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .dv-field-row:last-child { border-bottom: none; }\n .dv-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .dv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .dv-ua-string { font-family: ui-monospace, monospace; font-size: 0.73rem; color: #6c757d; word-break: break-all; line-height: 1.5; padding: 0.5rem 0.75rem; background: #f8f9fa; border-radius: 6px; margin-top: 0.25rem; }\n </style>\n\n <div class="dv-section-label">Browser</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Name</div>\n <div class="dv-field-value">{{model.device_info.user_agent.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Version</div>\n <div class="dv-field-value">{{model.device_info.user_agent.major|default(\'—\')}}{{#model.device_info.user_agent.minor}}.{{model.device_info.user_agent.minor}}{{/model.device_info.user_agent.minor}}{{#model.device_info.user_agent.patch}}.{{model.device_info.user_agent.patch}}{{/model.device_info.user_agent.patch}}</div>\n </div>\n\n <div class="dv-section-label">Operating System</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Name</div>\n <div class="dv-field-value">{{model.device_info.os.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Version</div>\n <div class="dv-field-value">{{model.device_info.os.major|default(\'—\')}}{{#model.device_info.os.minor}}.{{model.device_info.os.minor}}{{/model.device_info.os.minor}}{{#model.device_info.os.patch}}.{{model.device_info.os.patch}}{{/model.device_info.os.patch}}</div>\n </div>\n\n <div class="dv-section-label">Hardware</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Brand</div>\n <div class="dv-field-value">{{model.device_info.device.brand|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Family</div>\n <div class="dv-field-value">{{model.device_info.device.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Model</div>\n <div class="dv-field-value">{{model.device_info.device.model|default(\'—\')}}</div>\n </div>\n\n <div class="dv-section-label">Identification</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Device ID</div>\n <div class="dv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.78rem;">{{model.duid|truncate_middle(32)}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Last IP</div>\n <div class="dv-field-value">{{model.last_ip|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">First Seen</div>\n <div class="dv-field-value">{{model.first_seen|epoch|datetime|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Last Seen</div>\n <div class="dv-field-value">{{model.last_seen|epoch|datetime|default(\'—\')}}</div>\n </div>\n\n {{#model.device_info.string}}\n <div class="dv-section-label">User Agent String</div>\n <div class="dv-ua-string">{{model.device_info.string}}</div>\n {{/model.device_info.string}}\n '}),a=new n.TableView({collection:new s.UserDeviceLocationList({params:{user_device:this.model.get("id"),size:10}}),hideActivePillNames:["user_device"],clickAction:"view",itemClass:DeviceLocationRow,selectable:!1,columns:[{key:"ip_address",label:"Location",template:'\n <div style="font-size:0.85rem; font-weight:500;">\n <i class="bi bi-geo-alt text-muted me-1" style="font-size:0.95rem; vertical-align:middle;"></i>{{locationText}}\n {{#countryName}} <span class="text-muted fw-normal">· {{countryName}}</span>{{/countryName}}\n </div>\n <div style="font-size:0.73rem; color:#6c757d; margin-top:0.15rem;">\n {{model.ip_address}}\n {{#ispName}} <span class="text-muted mx-1">·</span> {{ispName}}{{/ispName}}\n {{#hasThreatFlags|bool}} <span class="ms-1">{{{threatFlags}}}</span>{{/hasThreatFlags|bool}}\n </div>'},{key:"first_seen",label:"First Seen",formatter:"epoch|relative",width:"110px"},{key:"last_seen",label:"Last Seen",formatter:"epoch|relative",width:"110px"}]});this.sideNavView=new SideNavView({containerId:"device-sidenav",activeSection:"details",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:i},{key:"locations",label:"Locations",icon:"bi-geo-alt",view:a}]}),this.addChild(this.sideNavView);const o=new e.ContextMenu({containerId:"device-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View User",action:"view-user",icon:"bi-person"},{label:"Block Device",action:"block-device",icon:"bi-shield-slash",disabled:!0},{type:"divider"},{label:"Delete Record",action:"delete-device",icon:"bi-trash",danger:!0}]}});this.addChild(o)}async onActionViewUser(){this.emit("view-user",{userId:this.model.get("user")?.id})}async onActionDeleteDevice(){return!(await s.Dialog.confirm("Are you sure you want to delete this device record?","Delete Device"))||((await this.model.destroy()).success&&this.emit("device:deleted",{model:this.model}),!0)}static async show(e){const t=await s.UserDevice.getByDuid(e);return t?s.Dialog.showDialog({title:!1,size:"lg",body:new DeviceView({model:t}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(s.Dialog.alert({message:`Could not find device with DUID: ${e}`,type:"warning"}),null)}}s.UserDevice.VIEW_CLASS=DeviceView;class UserDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_user_devices",pageName:"User Devices",router:"admin/user/devices",Collection:s.UserDeviceList,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"duid",label:"Device ID",sortable:!0,formatter:"truncate_middle(16)"},{key:"user.display_name",label:"User",sortable:!0,formatter:"default('—')"},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')"},{key:"device_info.os.family",label:"OS",formatter:"default('—')"},{key:"last_ip",label:"Last IP",sortable:!0},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime"},{key:"last_seen",label:"Last Seen",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No user devices found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class UserDeviceLocationTablePage extends e.Page{constructor(e={}){super({...e,pageName:"Device Locations",className:"device-locations-page"}),this.name=e.name||"admin_user_device_locations",this.route=e.router||"admin/user/device-locations"}async getTemplate(){return'\n <div class="container-fluid">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h4 class="mb-1">Device Locations</h4>\n <p class="text-muted mb-0 small">Login locations across all users</p>\n </div>\n </div>\n <div data-container="tabs"></div>\n </div>\n '}async onInit(){const e=new LoginLocationMapView({height:400,mapStyle:"dark"}),t=new n.TableView({collection:new LoginEventList({params:{size:20}}),searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showExport:!0,columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"user.display_name",label:"User",sortable:!0},{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px"}]});t.onTabActivated=async()=>{await(t.collection?.fetch())},this.tabView=new a.TabView({containerId:"tabs",tabs:{Map:e,Logins:t},activeTab:"Map"}),this.addChild(this.tabView)}}class GeoIPView extends t.View{constructor(e={}){super({className:"geoip-view",...e}),this.model=e.model||new a.GeoLocatedIP(e.data||{}),this.hasCoordinates=this.model.get("latitude")&&this.model.get("longitude"),this.template='\n <div class="geoip-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-globe-americas"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.ip_address}}</h3>\n <div class="text-muted small">\n {{model.city|default(\'Unknown Location\')}}, {{model.country_name|default(\'Unknown Location\')}}\n </div>\n <div class="text-muted small mt-1">\n ISP: {{model.isp|capitalize}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Risk Summary + Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n \x3c!-- Risk summary --\x3e\n <div class="text-end">\n <div class="d-flex align-items-baseline justify-content-end gap-2">\n <span class="text-muted">Risk:</span>\n <span class="fw-bold fs-4\n {{#model.is_threat}} text-danger {{/model.is_threat}}\n {{#model.is_suspicious}} text-warning {{/model.is_suspicious}}\n {{^model.is_threat}}{{^model.is_suspicious}} text-success {{/model.is_suspicious}}{{/model.is_threat}}\n ">{{#model.threat_level}}{{model.threat_level|capitalize}}{{/model.threat_level}}{{^model.threat_level}}Unknown{{/model.threat_level}}</span>\n </div>\n <div class="mt-1 small d-flex align-items-center justify-content-end gap-2">\n <span class="text-muted">Score:</span>\n <span class="fw-semibold">{{model.risk_score|default(\'—\')}}</span>\n </div>\n <div class="mt-1 d-flex align-items-center justify-content-end gap-2">\n <i class="bi bi-shield-lock {{#model.is_tor}}fs-4 text-success{{/model.is_tor}}{{^model.is_tor}}text-muted{{/model.is_tor}}" data-bs-toggle="tooltip" title="TOR exit"></i>\n <i class="bi bi-shield {{#model.is_vpn}}fs-4 text-success{{/model.is_vpn}}{{^model.is_vpn}}text-muted{{/model.is_vpn}}" data-bs-toggle="tooltip" title="VPN detected"></i>\n <i class="bi bi-cloud {{#model.is_cloud}}fs-4 text-success{{/model.is_cloud}}{{^model.is_cloud}}text-muted{{/model.is_cloud}}" data-bs-toggle="tooltip" title="Cloud provider"></i>\n <i class="bi bi-hdd-stack {{#model.is_datacenter}}fs-4 text-success{{/model.is_datacenter}}{{^model.is_datacenter}}text-muted{{/model.is_datacenter}}" data-bs-toggle="tooltip" title="Datacenter"></i>\n <i class="bi bi-phone {{#model.is_mobile}}fs-4 text-success{{/model.is_mobile}}{{^model.is_mobile}}text-muted{{/model.is_mobile}}" data-bs-toggle="tooltip" title="Mobile connection"></i>\n <i class="bi bi-diagram-3 {{#model.is_proxy}}fs-4 text-success{{/model.is_proxy}}{{^model.is_proxy}}text-muted{{/model.is_proxy}}" data-bs-toggle="tooltip" title="Proxy"></i>\n </div>\n </div>\n \x3c!-- Actions: context menu aligned to top (not vertically centered) --\x3e\n <div class="d-flex align-items-start">\n <div data-container="geoip-context-menu"></div>\n </div>\n </div>\n </div>\n\n \x3c!-- Content --\x3e\n <div data-container="geoip-sidenav"></div>\n </div>\n '}async onInit(){this.detailsView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"ip_address",label:"IP Address",cols:4},{name:"subnet",label:"Subnet",cols:4},{name:"country_name",label:"Country",cols:4},{name:"country_code",label:"Country Code",cols:4},{name:"region",label:"Region",cols:4},{name:"city",label:"City",cols:4},{name:"postal_code",label:"Postal Code",cols:4},{name:"timezone",label:"Timezone",cols:4},{name:"latitude",label:"Latitude",cols:4},{name:"longitude",label:"Longitude",cols:4}]}),this.networkView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"is_tor",label:"TOR Exit Node",formatter:"yesnoicon",cols:4},{name:"is_vpn",label:"VPN",formatter:"yesnoicon",cols:4},{name:"is_proxy",label:"Proxy",formatter:"yesnoicon",cols:4},{name:"is_cloud",label:"Cloud Provider",formatter:"yesnoicon",cols:4},{name:"is_datacenter",label:"Datacenter",formatter:"yesnoicon",cols:4},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:4},{name:"mobile_carrier",label:"Mobile Carrier",cols:8},{name:"asn",label:"ASN",cols:4},{name:"asn_org",label:"ASN Organization",cols:8},{name:"isp",label:"ISP",cols:12},{name:"connection_type",label:"Connection Type",cols:6}]}),this.riskView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"threat_level",label:"Threat Level",formatter:"capitalize",cols:6},{name:"risk_score",label:"Risk Score",cols:6},{name:"is_threat",label:"Threat",formatter:"yesnoicon",cols:6},{name:"is_suspicious",label:"Suspicious",formatter:"yesnoicon",cols:6},{name:"is_known_attacker",label:"Known Attacker",formatter:"yesnoicon",cols:6},{name:"is_known_abuser",label:"Known Abuser",formatter:"yesnoicon",cols:6}]}),this.blockView=new r.default({model:this.model,className:"p-3",showEmptyValues:!1,emptyValueText:"—",columns:2,fields:[{name:"is_blocked",label:"Blocked",formatter:"yesnoicon",cols:6},{name:"block_count",label:"Block Count",cols:6},{name:"blocked_reason",label:"Block Reason",cols:12},{name:"blocked_at",label:"Blocked At",formatter:"datetime",cols:6},{name:"blocked_until",label:"Blocked Until",formatter:"datetime",cols:6},{name:"is_whitelisted",label:"Whitelisted",formatter:"yesnoicon",cols:6},{name:"whitelisted_reason",label:"Whitelist Reason",cols:6}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"provider",label:"Data Provider",formatter:"capitalize",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6},{name:"last_seen",label:"Last Seen",formatter:"datetime",cols:6},{name:"expires_at",label:"Expires",formatter:"datetime",cols:6}]});const t=new a.IncidentEventList({params:{size:5,source_ip:this.model.get("ip_address")}});this.eventsView=new n.TableView({collection:t,hideActivePillNames:["source_ip"],columns:[{key:"id",label:"ID",sortable:!0,width:"40px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]});const s=new n.LogList({params:{size:5,ip:this.model.get("ip_address")}});this.trafficView=new n.TableView({collection:s,permissions:"view_logs",hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});const i=new n.LogList({params:{size:5,model_name:"account.GeoLocatedIP",model_id:this.model.get("id")}});this.logsView=new n.TableView({collection:i,permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime"},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});const o=[];if(this.hasCoordinates){const e=this.model.get("latitude"),t=this.model.get("longitude"),s=[this.model.get("city")||"Unknown",this.model.get("region")||"",this.model.get("country_name")||""].filter(Boolean).join(", ");this.mapView=new d.MapView({markers:[{lat:e,lng:t,popup:`<strong>${this.model.get("ip_address")}</strong><br>${s}`}],tileLayer:"light",zoom:4,height:450}),o.push({key:"map",label:"Map",icon:"bi-map",view:this.mapView})}o.push({key:"location",label:"Location",icon:"bi-geo-alt",view:this.detailsView},{key:"network",label:"Network",icon:"bi-diagram-3",view:this.networkView},{key:"risk",label:"Risk & Reputation",icon:"bi-shield-exclamation",view:this.riskView},{key:"block",label:"Block & Whitelist",icon:"bi-slash-circle",view:this.blockView},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:this.eventsView},{key:"traffic",label:"Traffic",icon:"bi-arrow-left-right",view:this.trafficView,permissions:"view_logs"},{key:"logs",label:"Logs",icon:"bi-journal-text",view:this.logsView,permissions:"view_logs"},{type:"divider",label:"Record"},{key:"metadata",label:"Metadata",icon:"bi-braces",view:this.metadataView}),this.sideNavView=new SideNavView({containerId:"geoip-sidenav",activeSection:this.hasCoordinates?"map":"location",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:o}),this.addChild(this.sideNavView);const l=[{label:"Edit Location",action:"edit-location",icon:"bi-geo-alt"},{label:"Edit Security",action:"edit-security",icon:"bi-shield-lock"},{label:"Edit Network",action:"edit-network",icon:"bi-diagram-3"},{type:"divider"},{label:"Refresh Geolocation",action:"refresh-geoip",icon:"bi-arrow-clockwise"}];this.hasCoordinates&&l.push({label:"View on Map",action:"view-on-map",icon:"bi-map"}),l.push({type:"divider"},{label:"Block IP",action:"block-ip",icon:"bi-slash-circle",class:"text-danger"},{label:"Unblock IP",action:"unblock-ip",icon:"bi-unlock",class:"text-success"},{label:"Whitelist IP",action:"whitelist-ip",icon:"bi-check-circle",class:"text-primary"},{label:"Remove Whitelist",action:"unwhitelist-ip",icon:"bi-x-circle"},{label:"Refresh Threat Data",action:"threat-analysis",icon:"bi-shield-exclamation"}),l.push({type:"divider"},{label:"Delete Record",action:"delete-geoip",icon:"bi-trash",danger:!0});const c=new e.ContextMenu({containerId:"geoip-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:l}});this.addChild(c)}async onAfterRender(){await super.onAfterRender(),window.bootstrap&&window.bootstrap.Tooltip&&this.element&&this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(e=>{const t=window.bootstrap.Tooltip.getInstance(e);t&&"function"==typeof t.dispose&&t.dispose(),new window.bootstrap.Tooltip(e)})}async onActionEditLocation(){await s.Dialog.showModelForm({title:`Edit Location - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_LOCATION_FORM})&&(await this.render(),this.getApp()?.toast?.success("Location updated successfully"))}async onActionEditSecurity(){await s.Dialog.showModelForm({title:`Edit Security - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_SECURITY_FORM})&&(await this.render(),this.getApp()?.toast?.success("Security settings updated successfully"))}async onActionEditNetwork(){await s.Dialog.showModelForm({title:`Edit Network - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_NETWORK_FORM})&&(await this.render(),this.getApp()?.toast?.success("Network information updated successfully"))}async onActionRefreshGeoip(){await this.model.save({refresh:!0}),this.getApp()?.toast?.info("Refresh request sent for "+this.model.get("ip_address"))}async onActionBlockIp(){const e=await s.Dialog.showForm({title:"Block IP",icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Suspicious activity"},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!e)return!0;const t=await this.model.save({block:{reason:e.reason,ttl:parseInt(e.ttl)}});return t.success||200===t.status?(this.getApp()?.toast?.success("IP blocked successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await s.Dialog.showForm({title:"Unblock IP",icon:"bi-unlock",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",placeholder:"e.g., False positive"}]});if(!e)return!0;const t=await this.model.save({unblock:e.reason||"Unblocked from admin"});return t.success||200===t.status?(this.getApp()?.toast?.success("IP unblocked successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await s.Dialog.showForm({title:"Whitelist IP",icon:"bi-check-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Known office IP"}]});if(!e)return!0;const t=await this.model.save({whitelist:e.reason});return t.success||200===t.status?(this.getApp()?.toast?.success("IP whitelisted successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}async onActionUnwhitelistIp(){if(!(await s.Dialog.confirm("Remove this IP from the whitelist?","Remove Whitelist")))return!0;const e=await this.model.save({unwhitelist:1});return e.success||200===e.status?(this.getApp()?.toast?.success("IP removed from whitelist"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to remove from whitelist"),!0}async onActionThreatAnalysis(e,t){try{t&&(t.disabled=!0);const e=await this.model.save({threat_analysis:1});e.success||200===e.status?(this.getApp()?.toast?.success("Threat data refreshed"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to refresh threat data")}finally{t&&(t.disabled=!1)}return!0}async onActionViewOnMap(){if(this.hasCoordinates){const e=`https://www.google.com/maps/search/?api=1&query=${this.model.get("latitude")},${this.model.get("longitude")}`;window.open(e,"_blank")}}async onActionDeleteGeoip(){await s.Dialog.confirm(`Are you sure you want to delete the GeoIP record for "${this.model.get("ip_address")}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("geoip:deleted",{model:this.model})}static async show(e){const t=await a.GeoLocatedIP.lookup(e);if(t){const e=new GeoIPView({model:t}),i=new s.Dialog({header:!1,size:"lg",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});return await i.render(!0,document.body),i.show(),i}return s.Dialog.alert({message:`Could not find geolocation data for IP: ${e}`,type:"warning"}),null}}a.GeoLocatedIP.VIEW_CLASS=GeoIPView;class GeoLocatedIPTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_system_geoip",pageName:"GeoIP Cache",router:"admin/system/geoip",Collection:a.GeoLocatedIPList,itemView:GeoIPView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",sortable:!0,formatter:"default('—')"},{key:"region",label:"Region",sortable:!0,formatter:"default('—')"},{key:"country_name",label:"Country",sortable:!0,formatter:"default('—')"},{key:"isp",label:"ISP",sortable:!0,formatter:"default('—')"},{key:"threat_level",label:"Threat",formatter:"default('—')"}],filters:[{key:"country_code",label:"Country",type:"text"},{key:"threat_level",label:"Threat Level",type:"select",options:[{label:"None",value:"none"},{label:"Low",value:"low"},{label:"Medium",value:"medium"},{label:"High",value:"high"},{label:"Critical",value:"critical"}]},{key:"isp__icontains",label:"ISP",type:"text"},{key:"is_blocked",label:"Blocked",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]},{key:"is_vpn",label:"VPN",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]},{key:"is_tor",label:"TOR",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No GeoIP records found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},tableViewOptions:{addButtonLabel:"Lookup IP",onAdd:e=>{e.preventDefault(),this.onLookup()}}})}async onLookup(){const e=await this.getApp().showForm({title:"Lookup IP",fields:[{name:"ip",type:"text",required:!0}]});if(e&&e.ip){const t=await a.GeoLocatedIP.lookup(e.ip);t&&this.tableView._onRowView({model:t})}}}class ApiKeyView extends t.View{constructor(e={}){super({className:"api-key-view",...e}),this.model=e.model||new ApiKey(e.data||{}),this.template='\n <div class="api-key-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left: Icon & Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-key"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.name|default(\'Unnamed Key\')}}</h3>\n <div class="text-muted small">\n ID: {{model.id}}\n <span class="mx-2">|</span>\n Group: {{model.group.name|default(model.group)}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.is_active|boolean(\'bg-success\',\'bg-secondary\')}}">\n {{model.is_active|boolean(\'Active\',\'Inactive\')}}\n </span>\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Meta & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|datetime}}</div>\n </div>\n <div data-container="apikey-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Token Preview</h6>\n <p class="mb-1 font-monospace small text-muted">\n The raw token is only shown once at creation time.\n </p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Permissions</h6>\n {{#model.permissions}}\n <pre class="mb-0 small">{{model.permissions|json}}</pre>\n {{/model.permissions}}\n {{^model.permissions}}\n <span class="text-muted small">No permissions granted</span>\n {{/model.permissions}}\n </div>\n {{#model.limits}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Rate Limit Overrides</h6>\n <pre class="mb-0 small">{{model.limits|json}}</pre>\n </div>\n {{/model.limits}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Usage</h6>\n <p class="mb-0 small text-muted">\n Include in requests as:\n <code>Authorization: apikey <token></code>\n </p>\n </div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("is_active"),s=new e.ContextMenu({containerId:"apikey-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-key",icon:"bi-pencil"},t?{label:"Deactivate",action:"deactivate-key",icon:"bi-x-circle"}:{label:"Activate",action:"activate-key",icon:"bi-check-circle"},{type:"divider"},{label:"Delete Key",action:"delete-key",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionEditKey(){const e=this.getApp();await e.showModelForm({title:`Edit API Key — ${this.model.get("name")}`,model:this.model,formConfig:g.edit})&&this.render()}async onActionDeactivateKey(){const e=this.getApp();if(!(await e.confirm({title:"Deactivate API Key",message:`Deactivate "${this.model.get("name")}"? Requests using this key will be rejected.`,confirmLabel:"Deactivate",confirmClass:"btn-warning"})))return;e.showLoading();const t=await this.model.save({is_active:!1});e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key deactivated"),this.render()):e.toast.error("Failed to deactivate key")}async onActionActivateKey(){const e=this.getApp();e.showLoading();const t=await this.model.save({is_active:!0});e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key activated"),this.render()):e.toast.error("Failed to activate key")}async onActionDeleteKey(){const e=this.getApp();if(!(await e.confirm({title:"Delete API Key",message:`Permanently delete "${this.model.get("name")}"? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete key")}}ApiKey.VIEW_CLASS=ApiKeyView,ApiKey.ADD_FORM=g.create,ApiKey.EDIT_FORM=g.edit;class ApiKeyTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_api_keys",pageName:"API Keys",router:"admin/api-keys",Collection:ApiKeyList,itemViewClass:ApiKeyView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"group.name",label:"Group",sortable:!0,formatter:"default('—')"},{key:"is_active",label:"Status",formatter:"boolean('Active|bg-success','Inactive|bg-secondary')|badge",width:"100px"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New API Key",emptyMessage:"No API keys found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionAdd(){const e=this.getApp(),t=new ApiKey,s=await e.showForm({model:t,...g.create});if(!s)return;const i=await t.save(s);if(!i?.data?.status)return void e.showError(i?.data?.error||"Failed to create API key");const a=i.data?.data?.token;await e.showAlert({title:"API Key Created — Save Your Token",message:a?`Copy this token now. It will not be shown again.\n\n${a}`:"API key created successfully.",type:a?"warning":"success",size:"lg"}),this.collection.add(t),this.tableView?.refresh()}}class CloudWatchChart extends i.MetricsChart{constructor(e={}){super({endpoint:"/api/aws/cloudwatch/fetch",account:e.resourceType||e.account||"ec2",category:e.category||null,slugs:e.slugs||(e.slug?[e.slug]:null),granularity:e.granularity||"hours",title:e.title||"CloudWatch",defaultDateRange:e.defaultDateRange||"24h",showDateRange:!1,...e}),this.stat=e.stat||"avg",this.resourceType=e.resourceType||e.account||"ec2"}buildApiParams(){const e=super.buildApiParams();return e.stat=this.stat,e}setStat(e){return this.stat=e,this.fetchData()}}const v=[{account:"ec2",category:"cpu",title:"EC2 CPU",unit:"%"},{account:"ec2",category:"net_out",title:"EC2 Network Out",unit:"bytes"},{account:"ec2",category:"memory",title:"EC2 Memory",unit:"%"},{account:"ec2",category:"disk",title:"EC2 Disk",unit:"%"},{account:"rds",category:"cpu",title:"RDS CPU",unit:"%"},{account:"rds",category:"conns",title:"RDS Connections",unit:""},{account:"rds",category:"read_latency",title:"RDS Read Latency",unit:"s"},{account:"rds",category:"write_latency",title:"RDS Write Latency",unit:"s"},{account:"redis",category:"cpu",title:"Redis CPU",unit:"%"},{account:"redis",category:"conns",title:"Redis Connections",unit:""},{account:"redis",category:"cache_misses",title:"Redis Cache Misses",unit:""},{account:"redis",category:"cache_hits",title:"Redis Cache Hits",unit:""}];function f(e){return"%"===e?{label:"%",beginAtZero:!0,max:100}:"bytes"===e?{label:"Bytes",beginAtZero:!0}:"s"===e?{label:"Seconds",beginAtZero:!0}:{beginAtZero:!0}}class CloudWatchDashboardPage extends e.Page{constructor(e={}){super({...e,title:"CloudWatch Monitoring",className:"cloudwatch-dashboard-page"})}async getTemplate(){return`\n <style>\n .cw-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n @media (max-width: 992px) { .cw-grid { grid-template-columns: 1fr; } }\n </style>\n <div class="container-fluid">\n <p class="text-muted mb-3">AWS CloudWatch resource monitoring</p>\n <div class="cw-grid" id="cw-grid">\n ${v.map((e,t)=>`<div id="cw-chart-${t}"></div>`).join("")}\n </div>\n </div>\n `}async onInit(){this.getApp()?.showLoading("Loading CloudWatch...");try{for(let e=0;e<v.length;e++){const t=v[e],s=new CloudWatchChart({containerId:`cw-chart-${e}`,account:t.account,category:t.category,title:t.title,height:160,yAxis:f(t.unit),responsive:!0,showGranularity:!0,showDateRange:!0,defaultDateRange:"24h",granularity:"hours"});this.addChild(s)}}finally{this.getApp()?.hideLoading()}}}class SecurityStatsBar extends t.View{constructor(e={}){super({...e,className:"security-stats-bar"}),this.model=new a.IncidentStats,this.counts={ipBlocks:0,blockedDevices:0,blocksToday:0,newCountryLogins:0}}async getTemplate(){return'\n <div class="row g-3 mb-4">\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-exclamation-triangle text-danger fs-4"></i>\n <div>\n <div class="text-muted small">Open Incidents</div>\n <div class="fw-bold fs-5">{{model.incidents.open}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-ticket-perforated text-warning fs-4"></i>\n <div>\n <div class="text-muted small">Open Tickets</div>\n <div class="fw-bold fs-5">{{model.tickets.open}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-shield-x text-danger fs-4"></i>\n <div>\n <div class="text-muted small">IP Blocks</div>\n <div class="fw-bold fs-5">{{counts.ipBlocks}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-phone-fill text-warning fs-4"></i>\n <div>\n <div class="text-muted small">Blocked Devices</div>\n <div class="fw-bold fs-5">{{counts.blockedDevices}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-person-slash text-info fs-4"></i>\n <div>\n <div class="text-muted small">Blocks Today</div>\n <div class="fw-bold fs-5">{{counts.blocksToday}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-geo-alt-fill text-success fs-4"></i>\n <div>\n <div class="text-muted small">New-Country Logins</div>\n <div class="fw-bold fs-5">{{counts.newCountryLogins}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}async onBeforeRender(){await this.fetchAll()}async fetchAll(){const e=this.getApp()?.rest,t=/* @__PURE__ */(new Date).toISOString().slice(0,10),[s,...i]=await Promise.allSettled([this.model.fetch(),e.GET("/api/system/geoip?is_blocked=true&size=0"),e.GET("/api/account/bouncer/device?risk_tier=blocked&size=0"),e.GET(`/api/account/bouncer/signal?decision=block&dr_start=${t}&size=0`),e.GET(`/api/account/logins?is_new_country=true&dr_start=${t}&size=0`)]),a=["ipBlocks","blockedDevices","blocksToday","newCountryLogins"];i.forEach((e,t)=>{"fulfilled"===e.status&&e.value?.data&&(this.counts[a[t]]=e.value.data.count||0)})}async refresh(){await this.fetchAll(),await this.render()}}class OverviewTab extends t.View{constructor(e={}){super({...e,className:"overview-tab"})}async getTemplate(){return'\n <div class="row g-4">\n <div class="col-xl-6 col-12" data-container="events-widget"></div>\n <div class="col-xl-6 col-12" data-container="incidents-widget"></div>\n </div>\n '}async onInit(){this.eventsWidget=new i.MetricsMiniChartWidget({containerId:"events-widget",icon:"bi bi-activity fs-2",title:"System Events",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#154360",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["incident_events"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:140,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-events"}),this.addChild(this.eventsWidget),this.incidentsWidget=new i.MetricsMiniChartWidget({containerId:"incidents-widget",icon:"bi bi-exclamation-triangle fs-2",title:"System Incidents",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#7D6608",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["incidents"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:140,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.25)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-incidents"}),this.addChild(this.incidentsWidget)}async refresh(){await Promise.allSettled([this.eventsWidget?.refresh(),this.incidentsWidget?.refresh()])}}class ThreatsTab extends t.View{constructor(e={}){super({...e,className:"threats-tab"})}async getTemplate(){return'\n <div class="row g-4">\n <div class="col-xl-4 col-lg-6 col-12" data-container="firewall-blocks-widget"></div>\n <div class="col-xl-4 col-lg-6 col-12" data-container="bouncer-blocks-widget"></div>\n <div class="col-xl-4 col-lg-6 col-12" data-container="bouncer-prescreen-widget"></div>\n </div>\n '}async onInit(){this.firewallBlocksWidget=new i.MetricsMiniChartWidget({containerId:"firewall-blocks-widget",icon:"bi bi-shield-x fs-2",title:"Firewall Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#922B21",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["firewall:blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-firewall-blocks"}),this.addChild(this.firewallBlocksWidget),this.bouncerBlocksWidget=new i.MetricsMiniChartWidget({containerId:"bouncer-blocks-widget",icon:"bi bi-person-slash fs-2",title:"Bouncer Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#6C3483",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["bouncer:blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-bouncer-blocks"}),this.addChild(this.bouncerBlocksWidget),this.bouncerPrescreenWidget=new i.MetricsMiniChartWidget({containerId:"bouncer-prescreen-widget",icon:"bi bi-funnel fs-2",title:"Pre-Screen Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#1A5276",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["bouncer:pre_screen_blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-bouncer-prescreen"}),this.addChild(this.bouncerPrescreenWidget)}async refresh(){await Promise.allSettled([this.firewallBlocksWidget?.refresh(),this.bouncerBlocksWidget?.refresh(),this.bouncerPrescreenWidget?.refresh()])}}class GeographyTab extends t.View{constructor(e={}){super({...e,className:"geography-tab"})}async getTemplate(){return'\n <div class="card shadow-sm mb-4">\n <div class="card-header border-0 bg-transparent d-flex align-items-center justify-content-between">\n <div>\n <h6 class="mb-0 text-uppercase small text-muted">Global Event Hotspots</h6>\n <span class="text-muted small">Clusters sized by total events</span>\n </div>\n <span class="badge bg-info-subtle text-info">Interactive</span>\n </div>\n <div class="card-body p-0" data-container="events-country-map"></div>\n </div>\n\n <div class="row g-4">\n <div class="col-lg-6">\n <div class="card shadow-sm h-100">\n <div class="card-header border-0 bg-transparent">\n <h6 class="mb-0 text-uppercase small text-muted">Events by Country</h6>\n <span class="text-muted small">Hotspots from the last 24 hours</span>\n </div>\n <div class="card-body p-3" data-container="events-by-country-chart"></div>\n </div>\n </div>\n <div class="col-lg-6">\n <div class="card shadow-sm h-100">\n <div class="card-header border-0 bg-transparent">\n <h6 class="mb-0 text-uppercase small text-muted">Incidents by Country</h6>\n <span class="text-muted small">Highest volume regions</span>\n </div>\n <div class="card-body p-3" data-container="incidents-by-country-chart"></div>\n </div>\n </div>\n </div>\n '}async onInit(){this.eventsCountryMap=new d.MetricsCountryMapView({containerId:"events-country-map",category:"incident_events_by_country",account:"incident",maxCountries:20,metricLabel:"Events",height:360,mapStyle:"dark"}),this.addChild(this.eventsCountryMap),this.eventsByCountryChart=new i.MetricsChart({title:'<i class="bi bi-globe-central-south-asia me-2"></i> Events by Country',endpoint:"/api/metrics/fetch",account:"incident",category:"incident_events_by_country",granularity:"days",chartType:"line",showDateRange:!1,showMetricsFilter:!1,height:220,maxDatasets:10,colors:["rgba(32, 201, 151, 0.85)"],yAxis:{label:"Events",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"events-by-country-chart"}),this.addChild(this.eventsByCountryChart),this.incidentsByCountryChart=new i.MetricsChart({title:'<i class="bi bi-geo-alt me-2"></i> Incidents by Country',endpoint:"/api/metrics/fetch",account:"incident",category:"incidents_by_country",granularity:"days",chartType:"line",showDateRange:!1,showMetricsFilter:!1,height:220,maxDatasets:10,colors:["rgba(255, 193, 7, 0.85)"],yAxis:{label:"Incidents",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"incidents-by-country-chart"}),this.addChild(this.incidentsByCountryChart)}async refresh(){await Promise.allSettled([this.eventsCountryMap?.refresh(),this.eventsByCountryChart?.refresh(),this.incidentsByCountryChart?.refresh()])}}class LoginMapTab extends t.View{constructor(e={}){super({...e,className:"login-map-tab"})}async getTemplate(){return'<div data-container="login-map"></div>'}async onInit(){this.loginMap=new LoginLocationMapView({containerId:"login-map",height:480,mapStyle:"dark"}),this.addChild(this.loginMap)}onTabActivated(){this.loginMap?.onTabActivated()}async refresh(){await(this.loginMap?.refresh())}}class LoginActivityTab extends t.View{constructor(e={}){super({...e,className:"login-activity-tab"})}async getTemplate(){return'<div data-container="login-table"></div>'}async onInit(){this.loginTable=new n.TableView({containerId:"login-table",collection:new LoginEventList({params:{sort:"-created",size:20}}),searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"user.display_name",label:"User",sortable:!0},{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0,filter:{type:"text",label:"Country Code"}},{key:"source",label:"Source",sortable:!0,filter:{type:"select",options:[{value:"password",label:"Password"},{value:"magic",label:"Magic Link"},{value:"sms",label:"SMS"},{value:"totp",label:"TOTP"},{value:"oauth",label:"OAuth"}]}},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px",filter:{type:"select",options:[{value:"true",label:"Yes"},{value:"false",label:"No"}]}}]}),this.addChild(this.loginTable)}async onTabActivated(){await this.refresh()}async refresh(){await(this.loginTable?.collection?.fetch())}}class IncidentDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Security Dashboard",className:"incident-dashboard-page"})}async getTemplate(){return'\n <div class="container-fluid incident-dashboard">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <p class="text-muted mb-0">Security Dashboard</p>\n <small class="text-info">\n <i class="bi bi-activity me-1"></i>\n Real-time visibility into incidents, threats, and enforcement\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh dashboard">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n </div>\n\n <div data-container="stats-bar"></div>\n <div data-container="tabs"></div>\n </div>\n '}async onInit(){this.getApp()?.showLoading("Loading Security Dashboard...");try{this.statsBar=new SecurityStatsBar({containerId:"stats-bar"}),this.addChild(this.statsBar),this.overviewTab=new OverviewTab,this.threatsTab=new ThreatsTab,this.geographyTab=new GeographyTab,this.loginMapTab=new LoginMapTab,this.loginActivityTab=new LoginActivityTab,this.tabView=new a.TabView({containerId:"tabs",tabs:{Overview:this.overviewTab,Threats:this.threatsTab,Geography:this.geographyTab,"Login Map":this.loginMapTab,"Login Activity":this.loginActivityTab},activeTab:"Overview"}),this.addChild(this.tabView)}finally{this.getApp()?.hideLoading()}}async onActionRefreshAll(e,t){const s=t||e?.currentTarget||null,i=s?.querySelector?.("i");i?.classList.add("bi-spin"),s&&(s.disabled=!0);const a=this.tabView.getActiveTab(),n=this.tabView.getTab(a);await Promise.allSettled([this.statsBar.refresh(),n?.refresh?.()]),i?.classList.remove("bi-spin"),s&&(s.disabled=!1)}}class StackTraceView extends t.View{constructor(e={}){super({className:"stack-trace-view",...e}),this.stackTrace=e.stackTrace||"",this.template="\n <div class=\"stack-trace-container p-3\">\n <style>\n .stack-trace-line {\n font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;\n font-size: 13px;\n line-height: 1.6;\n padding: 4px 8px;\n margin: 0;\n border-left: 3px solid transparent;\n }\n .stack-trace-line:hover {\n background-color: rgba(0, 0, 0, 0.05);\n }\n .stack-trace-error {\n color: #dc3545;\n font-weight: 600;\n border-left-color: #dc3545;\n background-color: rgba(220, 53, 69, 0.05);\n }\n .stack-trace-file {\n color: #0d6efd;\n font-weight: 500;\n border-left-color: #0d6efd;\n }\n .stack-trace-function {\n color: #6610f2;\n font-weight: 500;\n }\n .stack-trace-location {\n color: #6c757d;\n font-size: 12px;\n }\n .stack-trace-line-number {\n color: #fd7e14;\n font-weight: 600;\n }\n .stack-trace-context {\n color: #495057;\n background-color: rgba(0, 0, 0, 0.02);\n }\n .stack-trace-container {\n background-color: #f8f9fa;\n border: 1px solid #dee2e6;\n border-radius: 0.375rem;\n max-height: 600px;\n overflow-y: auto;\n }\n </style>\n <div class=\"stack-trace-content\">\n {{{formattedStackTrace}}}\n </div>\n </div>\n "}async onBeforeRender(){this.formattedStackTrace=this.formatStackTrace(this.stackTrace)}formatStackTrace(e){if(!e)return'<div class="text-muted p-3">No stack trace available</div>';const t=("string"==typeof e?e:JSON.stringify(e,null,2)).split("\n");let s="";return t.forEach((e,t)=>{if(!e.trim())return void(s+='<div class="stack-trace-line"> </div>');if(0===t&&(e.includes("Error:")||e.includes("Exception:")))return void(s+=`<div class="stack-trace-line stack-trace-error">${this.escapeHtml(e)}</div>`);let i=e.match(/(.+?)\s*\(([^:]+):(\d+):(\d+)\)/);if(i){const[,e,t,a,n]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-function">${this.escapeHtml(e.trim())}</span>\n <span class="stack-trace-location"> (${this.escapeHtml(t)}:<span class="stack-trace-line-number">${a}</span>:${n})</span>\n </div>`)}if(i=e.match(/^\s*at\s+([^:]+):(\d+):(\d+)/),i){const[,e,t,a]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-location">at ${this.escapeHtml(e)}:<span class="stack-trace-line-number">${t}</span>:${a}</span>\n </div>`)}if(i=e.match(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)/),i){const[,e,t,a]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-location">File "${this.escapeHtml(e)}", line <span class="stack-trace-line-number">${t}</span>, in </span>\n <span class="stack-trace-function">${this.escapeHtml(a)}</span>\n </div>`)}e.trim().startsWith("at ")?s+=`<div class="stack-trace-line stack-trace-file">${this.escapeHtml(e)}</div>`:s+=`<div class="stack-trace-line stack-trace-context">${this.escapeHtml(e)}</div>`}),s}updateStackTrace(e){this.stackTrace=e,this.render()}}const y=[{value:"block",label:"Block IP",icon:"bi-slash-circle",description:"Block the source IP address for a specified duration",fields:[{name:"ttl",type:"select",label:"Duration",options:[{value:"3600",label:"1 hour"},{value:"21600",label:"6 hours"},{value:"86400",label:"24 hours"},{value:"604800",label:"7 days"},{value:"2592000",label:"30 days"},{value:"0",label:"Permanent"}],default:"86400"}],build:e=>`block://?ttl=${e.ttl||86400}`,preview:e=>`Block source IP for ${{3600:"1 hour",21600:"6 hours",86400:"24 hours",604800:"7 days",2592e3:"30 days",0:"permanently"}[e.ttl]||e.ttl+"s"}`},{value:"email",label:"Email",icon:"bi-envelope",description:"Send an email notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission or key@name)",default:"perm@manage_security"}],build:e=>`email://${e.target||"perm@manage_security"}`,preview:e=>`Email notification to ${e.target||"perm@manage_security"}`},{value:"sms",label:"SMS",icon:"bi-chat-dots",description:"Send an SMS notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission)",default:"perm@manage_security"}],build:e=>`sms://${e.target||"perm@manage_security"}`,preview:e=>`SMS notification to ${e.target||"perm@manage_security"}`},{value:"notify",label:"Push Notification",icon:"bi-bell",description:"Send a push notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission)",default:"perm@manage_security"}],build:e=>`notify://${e.target||"perm@manage_security"}`,preview:e=>`Push notification to ${e.target||"perm@manage_security"}`},{value:"ticket",label:"Create Ticket",icon:"bi-ticket-detailed",description:"Automatically create a support ticket",fields:[{name:"priority",type:"select",label:"Priority",options:[{value:"1",label:"1 - Low"},{value:"3",label:"3 - Normal"},{value:"5",label:"5 - Medium"},{value:"8",label:"8 - High"},{value:"10",label:"10 - Critical"}],default:"5"}],build:e=>`ticket://?priority=${e.priority||5}`,preview:e=>`Create ticket with ${{1:"Low",3:"Normal",5:"Medium",8:"High",10:"Critical"}[e.priority]||"priority "+e.priority} priority`},{value:"job",label:"Run Job",icon:"bi-gear-wide-connected",description:"Run an async job (Python module path)",fields:[{name:"func",type:"text",label:"Module Path",placeholder:"myapp.security.handlers.on_incident"}],build:e=>`job://${e.func||""}`,preview:e=>`Run job: ${e.func||"(no function specified)"}`},{value:"llm",label:"LLM Triage",icon:"bi-stars",description:"Use LLM to analyze and triage the incident",fields:[],build:()=>"llm://",preview:()=>"LLM-powered incident triage and analysis"}];class HandlerBuilderView extends t.View{constructor(e={}){super({className:"handler-builder-view",...e}),this.handlerString=e.value||"",this._parseExisting(),this.template=`\n <style>\n .hb-container { border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; background: #f8f9fa; }\n .hb-type-select { margin-bottom: 0.75rem; }\n .hb-fields { margin-bottom: 0.75rem; }\n .hb-preview { padding: 0.5rem 0.75rem; background: #e9ecef; border-radius: 6px; font-size: 0.85rem; }\n .hb-preview code { font-size: 0.8rem; }\n .hb-preview .hb-desc { color: #495057; margin-bottom: 0.25rem; }\n .hb-preview .hb-raw { color: #6c757d; font-family: ui-monospace, monospace; }\n </style>\n <div class="hb-container">\n <div class="hb-type-select">\n <label class="form-label fw-semibold small">Handler Type</label>\n <select class="form-select form-select-sm" data-action="change-type" id="hb-type-select">\n <option value="">— Select handler type —</option>\n ${y.map(e=>`<option value="${e.value}">${e.label}</option>`).join("")}\n </select>\n </div>\n <div id="hb-fields" class="hb-fields"></div>\n <div id="hb-preview" class="hb-preview" style="display:none;"></div>\n </div>\n `}_parseExisting(){if(!this.handlerString)return this.selectedType=null,void(this.fieldValues={});const e=this.handlerString.match(/^(\w+):\/\/(.*)$/);if(!e)return this.selectedType=null,void(this.fieldValues={});const[,t,s]=e;this.selectedType=t,this.fieldValues={};const i=y.find(e=>e.value===t);if(i)if(s.startsWith("?")){const e=new URLSearchParams(s);for(const t of i.fields){const s=e.get(t.name);null!==s&&(this.fieldValues[t.name]=s)}}else 1===i.fields.length&&(this.fieldValues[i.fields[0].name]=s)}onAfterRender(){const e=this.element?.querySelector("#hb-type-select");e&&this.selectedType&&(e.value=this.selectedType,this._renderFields(),this._updatePreview())}onActionChangeType(e){const t=e.target;this.selectedType=t.value||null,this.fieldValues={};const s=y.find(e=>e.value===this.selectedType);if(s)for(const i of s.fields)void 0!==i.default&&(this.fieldValues[i.name]=i.default);return this._renderFields(),this._updatePreview(),this._emitChange(),!0}_renderFields(){const e=this.element?.querySelector("#hb-fields");if(!e)return;const t=y.find(e=>e.value===this.selectedType);t&&t.fields.length?e.innerHTML=t.fields.map(e=>{const t=this.fieldValues[e.name]||e.default||"";if("select"===e.type){const s=e.options.map(e=>`<option value="${e.value}" ${e.value===String(t)?"selected":""}>${e.label}</option>`).join("");return`\n <div class="mb-2">\n <label class="form-label small">${e.label}</label>\n <select class="form-select form-select-sm" data-field="${e.name}" data-action="field-change">${s}</select>\n </div>`}return`\n <div class="mb-2">\n <label class="form-label small">${e.label}</label>\n <input type="text" class="form-control form-control-sm" data-field="${e.name}" data-action="field-change"\n value="${t}" placeholder="${e.placeholder||""}">\n </div>`}).join(""):e.innerHTML=""}onActionFieldChange(e){const t=e.target,s=t.dataset.field;return s&&(this.fieldValues[s]=t.value,this._updatePreview(),this._emitChange()),!0}_updatePreview(){const e=this.element?.querySelector("#hb-preview");if(!e)return;const t=y.find(e=>e.value===this.selectedType);if(!t)return void(e.style.display="none");const s=t.build(this.fieldValues),i=t.preview(this.fieldValues);e.style.display="block",e.innerHTML=`\n <div class="hb-desc"><i class="bi ${t.icon} me-1"></i>${i}</div>\n <div class="hb-raw"><code>${s}</code></div>\n `}_emitChange(){const e=this.getValue();this.emit("change",e)}getValue(){const e=y.find(e=>e.value===this.selectedType);return e?e.build(this.fieldValues):""}setValue(e){if(this.handlerString=e||"",this._parseExisting(),this.isMounted()){const e=this.element?.querySelector("#hb-type-select");e&&(e.value=this.selectedType||""),this._renderFields(),this._updatePreview()}}}class RuleSetView extends t.View{constructor(e={}){super({className:"ruleset-view",...e}),this.model=e.model||new a.RuleSet(e.data||{}),this.template='\n <div class="ruleset-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary"><i class="bi bi-gear-wide-connected"></i></div>\n <div>\n <h3 class="mb-1">{{model.name}}</h3>\n <div class="text-muted small">Scope: {{model.category}} | Priority: {{model.priority}}</div>\n </div>\n </div>\n <div data-container="ruleset-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="ruleset-tabs"></div>\n </div>\n '}async onInit(){const t=this.model.get("match_by"),s=a.MatchByOptions.find(e=>e.value===t),i=s?s.label:String(t),o=this.model.get("bundle_by"),l=a.BundleByOptions.find(e=>e.value===o),d=l?l.label:String(o),c=this.model.get("trigger_count"),m=this.model.get("trigger_window"),u=this.model.get("retrigger_every");this.configView=new r.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:[{name:"name",label:"Name",cols:6},{name:"id",label:"RuleSet ID",cols:3},{name:"is_active",label:"Active",formatter:"yesnoicon",cols:3},{name:"category",label:"Event Category",formatter:"badge",cols:4},{name:"priority",label:"Evaluation Priority",cols:4},{name:"match_by",label:"Match Logic",template:i,cols:4},{name:"bundle_by",label:"Bundle By",template:d,cols:4},{name:"bundle_minutes",label:"Bundle Window (min)",cols:4},{name:"bundle_by_rule_set",label:"Bundle by RuleSet",formatter:"yesnoicon",cols:4},{name:"trigger_count",label:"Trigger Count",template:null!=c?String(c)+" events":"Immediate (first event)",cols:4},{name:"trigger_window",label:"Trigger Window",template:null!=m?m+" minutes":"All events counted",cols:4},{name:"retrigger_every",label:"Re-trigger Every",template:null!=u?u+" events":"Fire once only",cols:4},{name:"handler",label:"Handler Chain",cols:12}]});const b=new a.RuleList({params:{parent:this.model.get("id")}});this.rulesView=new n.TableView({collection:b,hideActivePillNames:["parent"],columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"field_name",label:"Field"},{key:"comparator",label:"Comparator",width:"120px"},{key:"value",label:"Value"},{key:"value_type",label:"Type",width:"100px"}],showAdd:!0,clickAction:"edit",actions:["edit","delete"],contextMenu:[{label:"Edit Rule",action:"edit",icon:"bi-pencil"},{label:"Duplicate Rule",action:"duplicate",icon:"bi-files"},{divider:!0},{label:"Delete Rule",action:"delete",icon:"bi-trash",danger:!0}],addFormDefaults:{parent:this.model.get("id")}}),this.tabView=new a.TabView({containerId:"ruleset-tabs",tabs:{Configuration:this.configView,Rules:this.rulesView},activeTab:"Configuration"}),this.addChild(this.tabView);const h=new e.ContextMenu({containerId:"ruleset-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit RuleSet",action:"edit-ruleset",icon:"bi-pencil"},{label:"Edit Handler",action:"edit-handler",icon:"bi-tools"},{label:"Disable",action:"disable-ruleset",icon:"bi-toggle-off"},{type:"divider"},{label:"Delete RuleSet",action:"delete-ruleset",icon:"bi-trash",danger:!0}]}});this.addChild(h)}async onActionEditHandler(){const e=new HandlerBuilderView({value:this.model.get("handler")||""});if("save"===await s.Dialog.showDialog({title:"Configure Handler",body:e,size:"md",scrollable:!0,buttons:[{text:"Cancel",class:"btn-secondary",dismiss:!0},{text:"Save",class:"btn-primary",action:"save"}]})){const t=e.getValue();t&&(200===(await this.model.save({handler:t})).status?(this.getApp()?.toast?.success("Handler updated"),await this.render()):this.getApp()?.toast?.error("Failed to update handler"))}}async onActionEditRuleset(){await s.Dialog.showModelForm({title:`Edit RuleSet - ${this.model.get("name")}`,model:this.model,formConfig:a.RuleSet.EDIT_FORM})&&await this.render()}async onActionDisableRuleset(){const e=!this.model.get("is_active");try{this.model.set("is_active",e),await this.model.save(),await this.render(),this.getApp()?.toast?.success(`RuleSet ${e?"enabled":"disabled"} successfully`)}catch(t){this.getApp()?.toast?.error(`Failed to update RuleSet: ${t.message}`)}}async onActionDeleteRuleset(){if(await s.Dialog.confirm({title:"Delete RuleSet",message:`Are you sure you want to delete the ruleset "${this.model.get("name")}"? This action cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("RuleSet deleted successfully");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("ruleset:deleted",{model:this.model})}catch(e){this.getApp()?.toast?.error(`Failed to delete RuleSet: ${e.message}`)}}}RuleSetView.VIEW_CLASS=RuleSetView;class IncidentHistoryAdapter{constructor(e){this.incidentId=e,this.collection=new a.IncidentHistoryList({params:{parent:this.incidentId,sort:"created",size:100}})}async fetch(){await this.collection.fetch();const e=this.collection.models.map(e=>this.transform(e));return await Promise.all(e.map(async e=>{"system_event"===e.type&&e.content&&(e.content=await this._renderMarkdown(e.content))})),e}transform(e){return{id:e.get("id"),type:"comment"===e.get("kind")?"user_comment":"system_event",author:{name:e.get("user.display_name")||"System",avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]}}async addNote(e){const t=new a.IncidentHistory,s=await t.save({parent:this.incidentId,note:e.text,kind:"comment",media:e.files&&e.files.length>0?e.files[0].id:null});return s.success&&await this.collection.fetch(),s}async _renderMarkdown(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}}class AssistantConversation extends t.Model{constructor(e={}){super(e,{endpoint:"/api/assistant/conversation"})}}class AssistantConversationList extends t.Collection{constructor(e={}){super({ModelClass:AssistantConversation,endpoint:"/api/assistant/conversation",size:50,...e})}}class AssistantMessageView extends a.ChatMessageView{constructor(e={}){super(e),this._blockViews=[],this._needsMarkdown="assistant"===this.message.role&&!!this.message.content}async onAfterRender(){if(await super.onAfterRender(),this._needsMarkdown&&(this._needsMarkdown=!1,await this._renderMarkdown()),!this.message.blocks||0===this.message.blocks.length)return;const e=this.element.querySelector(`[data-container="blocks-${this.message.id||this.id}"]`);if(e)for(let s=0;s<this.message.blocks.length;s++){const i=this.message.blocks[s],a=document.createElement("div");a.className="assistant-block mb-3",e.appendChild(a);try{"table"===i.type?await this._renderTableBlock(i,a):"chart"===i.type?await this._renderChartBlock(i,a):"stat"===i.type?this._renderStatBlock(i,a):"action"===i.type?this._renderActionBlock(i,a):"list"===i.type?this._renderListBlock(i,a):"alert"===i.type?this._renderAlertBlock(i,a):"progress"===i.type&&this._renderProgressBlock(i,a)}catch(t){console.error("Failed to render block:",i.type,t);const e=document.createElement("div");e.className="alert alert-warning small",e.textContent=`Failed to render ${i.type} block`,a.appendChild(e)}}}_createCollapsibleCard(e,{icon:t,title:s,subtitle:i}){const a=`block-${this.message.id||this.id}-${++AssistantMessageView._blockCounter}`,n=this._escapeHtml.bind(this),o=document.createElement("div");o.className="assistant-collapsible-block",o.innerHTML=`\n <a class="assistant-block-toggle collapsed" data-bs-toggle="collapse"\n href="#${a}" role="button" aria-expanded="false">\n <span class="assistant-block-toggle-icon">\n <i class="bi ${t}"></i>\n </span>\n <span class="assistant-block-toggle-text">\n <span class="assistant-block-toggle-title">${n(s||"Data")}</span>\n ${i?`<span class="assistant-block-toggle-subtitle">${n(i)}</span>`:""}\n </span>\n <i class="bi bi-chevron-down assistant-block-chevron"></i>\n </a>\n <div class="collapse" id="${a}">\n <div class="assistant-block-body"></div>\n </div>\n `,e.appendChild(o);const l=o.querySelector(".collapse");let r=[];return l.addEventListener("shown.bs.collapse",()=>{r.forEach(e=>e()),r=[]},{once:!0}),{body:o.querySelector(".assistant-block-body"),onShow:e=>r.push(e)}}async _renderTableBlock(e,s){const{default:i}=await Promise.resolve().then(()=>require("./chunks/Passkeys-DNpner4L.js")).then(e=>e.TableView$1),a=(e.rows||[]).length,n=(e.columns||[]).length,{body:o}=this._createCollapsibleCard(s,{icon:"bi-table",title:e.title||"Table",subtitle:`${a} rows · ${n} columns`}),l=(e.columns||[]).map(e=>"string"==typeof e?{key:e,label:e}:e),r=l.map(e=>e.key),d=(e.rows||[]).map((e,s)=>{const i={id:s};return r.forEach((t,s)=>{i[t]=void 0!==e[s]?e[s]:""}),new t.Model(i)}),c=new t.Collection({preloaded:!0});c.add(d);const m=new i({collection:c,columns:l,paginated:!1,sortable:!1,searchable:!1,filterable:!1,showRefresh:!1,showAdd:!1});this._blockViews.push(m),this.addChild(m),o.appendChild(m.element),m.render(!1)}async _renderChartBlock(e,t){const s=e.chart_type||"line",i=(e.series||[]).length,a=e.labels?.length||0,n="pie"===s,{body:o,onShow:l}=this._createCollapsibleCard(t,{icon:{line:"bi-graph-up",bar:"bi-bar-chart-fill",pie:"bi-pie-chart-fill",area:"bi-graph-up"}[s]||"bi-graph-up",title:e.title||{line:"Line Chart",bar:"Bar Chart",pie:"Pie Chart",area:"Area Chart"}[s]||"Chart",subtitle:n?`${a} segments`:`${i} series · ${a} points`}),r=document.createElement("div");r.className="assistant-chart-body",o.appendChild(r);const d={labels:e.labels||[],datasets:(e.series||[]).map(e=>({label:e.name,data:e.values}))};if(n){const{default:e}=await Promise.resolve().then(()=>require("./chunks/MiniPieChart-NgLN6VnD.js")),t=new e({width:180,height:180,legendPosition:"right",data:d});this._blockViews.push(t),this.addChild(t),r.appendChild(t.element),t.render(!1)}else{const{default:e}=await Promise.resolve().then(()=>require("./chunks/MiniSeriesChart-BAf92vx5.js")),t=new e({chartType:"area"===s?"line":s,fill:"area"===s,height:200,legendPosition:"top",data:d});this._blockViews.push(t),this.addChild(t),r.appendChild(t.element),t.render(!1)}}_renderStatBlock(e,t){const s=e.items||[],i=document.createElement("div");i.className="d-flex flex-wrap gap-2",s.forEach(e=>{const t=document.createElement("div");t.className="assistant-stat-card card",t.innerHTML=`\n <div class="card-body text-center py-2 px-3">\n <div class="text-muted small">${this._escapeHtml(e.label)}</div>\n <div class="fw-bold fs-5">${this._escapeHtml(String(e.value))}</div>\n </div>\n `,i.appendChild(t)}),t.appendChild(i)}_renderActionBlock(e,t){const s=this._escapeHtml.bind(this),i=document.createElement("div");i.className="assistant-action-card",i.innerHTML=`\n <div class="assistant-action-header">${s(e.title||"Action Required")}</div>\n ${e.description?`<div class="assistant-action-desc">${s(e.description)}</div>`:""}\n <div class="assistant-action-buttons"></div>\n `;const a=i.querySelector(".assistant-action-buttons");(e.actions||[]).forEach((t,s)=>{const i=document.createElement("button");i.className=0===s?"btn btn-sm btn-primary":"btn btn-sm btn-outline-secondary",i.textContent=t.label,i.addEventListener("click",()=>{a.querySelectorAll("button").forEach(e=>{e.disabled=!0,e.classList.add("assistant-action-dimmed")}),i.classList.remove("assistant-action-dimmed"),i.classList.add("assistant-action-chosen");const s=this.getApp();s?.ws?.isConnected&&s.ws.send({type:"assistant_action",conversation_id:this.message._conversationId,action_id:e.action_id,value:t.value})}),a.appendChild(i)}),t.appendChild(i)}_renderListBlock(e,t){const s=this._escapeHtml.bind(this),i=document.createElement("div");i.className="assistant-list-card";let a="";e.title&&(a+=`<div class="assistant-list-title">${s(e.title)}</div>`),a+='<dl class="assistant-list-items">',(e.items||[]).forEach(e=>{a+=`\n <div class="assistant-list-row">\n <dt>${s(e.label)}</dt>\n <dd>${s(String(e.value??""))}</dd>\n </div>`}),a+="</dl>",i.innerHTML=a,t.appendChild(i)}_renderAlertBlock(e,t){const s=this._escapeHtml.bind(this),i=e.level||"info",a={info:"bi-info-circle-fill",success:"bi-check-circle-fill",warning:"bi-exclamation-triangle-fill",error:"bi-x-circle-fill"},n=document.createElement("div");n.className=`assistant-alert alert ${{info:"alert-info",success:"alert-success",warning:"alert-warning",error:"alert-danger"}[i]||"alert-info"}`,n.innerHTML=`\n <i class="bi ${a[i]||a.info} me-2"></i>\n <div class="assistant-alert-content">\n ${e.title?`<strong>${s(e.title)}</strong>`:""}\n <div>${s(e.message||"")}</div>\n </div>\n `,t.appendChild(n)}_renderProgressBlock(e,t){const s=this._escapeHtml.bind(this),i=e.steps||[],a=i.filter(e=>"done"===e.status).length,n=i.length>0?Math.round(a/i.length*100):0,o=document.createElement("div");o.className="assistant-progress-card",e.plan_id&&(o.dataset.planId=e.plan_id);let l="";const r={pending:"bi-circle",in_progress:"bi-arrow-repeat",done:"bi-check-circle-fill",skipped:"bi-slash-circle"};i.forEach(e=>{l+=`\n <div class="assistant-progress-step step-${e.status}" data-step-id="${e.id}">\n <i class="bi ${r[e.status]||r.pending} step-icon"></i>\n <div class="step-content">\n <span class="step-description">${s(e.description)}</span>\n ${e.summary?`<span class="step-summary">${s(e.summary)}</span>`:""}\n </div>\n </div>`}),o.innerHTML=`\n <div class="assistant-progress-header">\n <span class="assistant-progress-title">${s(e.title||"Plan")}</span>\n <span class="assistant-progress-counter">${a} of ${i.length}</span>\n </div>\n <div class="progress" style="height: 4px; margin-bottom: 10px;">\n <div class="progress-bar" role="progressbar" style="width: ${n}%"></div>\n </div>\n <div class="assistant-progress-steps">${l}</div>\n `,t.appendChild(o)}updateProgressStep(e,t,s,i){const a=this.element?.querySelector(`[data-plan-id="${e}"]`);if(!a)return;const n={pending:"bi-circle",in_progress:"bi-arrow-repeat",done:"bi-check-circle-fill",skipped:"bi-slash-circle"},o=a.querySelector(`[data-step-id="${t}"]`);if(o){o.className=`assistant-progress-step step-${s}`;const e=o.querySelector(".step-icon");e&&(e.className=`bi ${n[s]||n.pending} step-icon`);let t=o.querySelector(".step-summary");i&&(t||(t=document.createElement("span"),t.className="step-summary",o.querySelector(".step-content").appendChild(t)),t.textContent=i)}const l=a.querySelectorAll(".assistant-progress-step"),r=a.querySelectorAll(".step-done").length,d=a.querySelector(".assistant-progress-counter");d&&(d.textContent=`${r} of ${l.length}`);const c=a.querySelector(".progress-bar");c&&(c.style.width=`${l.length>0?Math.round(r/l.length*100):0}%`)}async _renderMarkdown(){const e=this.element?.querySelector(".message-text");if(!e)return;const t=this.message.content;try{const s=this.getApp(),i=await s.rest.post("/api/docit/render",{markdown:t}),a=i?.data?.data?.html||i?.data?.html;if(a)return void(e.innerHTML=a)}catch(s){}e.innerHTML=AssistantMessageView.markdownToHtml(t)}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}static markdownToHtml(e){const t=document.createElement("div");t.textContent=e;let s=t.innerHTML;return s=s.replace(/```(\w*)\n([\s\S]*?)```/g,(e,t,s)=>`<pre class="assistant-code-block"><code>${s.trim()}</code></pre>`),s=s.replace(/`([^`]+)`/g,'<code class="assistant-inline-code">$1</code>'),s=s.replace(/^### (.+)$/gm,'<h6 class="assistant-heading mt-3 mb-1">$1</h6>'),s=s.replace(/^## (.+)$/gm,'<h5 class="assistant-heading mt-3 mb-1">$1</h5>'),s=s.replace(/^# (.+)$/gm,'<h4 class="assistant-heading mt-3 mb-2">$1</h4>'),s=s.replace(/^---+$/gm,'<hr class="my-2 opacity-25">'),s=s.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>"),s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>"),s=s.replace(/\*(.+?)\*/g,"<em>$1</em>"),s=s.replace(/((?:^- .+$\n?)+)/gm,e=>`<ul class="assistant-list mb-2">${e.trim().split("\n").map(e=>`<li>${e.replace(/^- /,"")}</li>`).join("")}</ul>`),s=s.replace(/\n{2,}/g,"</p><p>"),s=s.replace(/\n/g,"<br>"),s=`<p>${s}</p>`,s=s.replace(/<p>\s*<\/p>/g,""),s=s.replace(/<p>\s*(<h[456]|<hr|<ul|<pre|<\/ul>|<\/pre>)/g,"$1"),s=s.replace(/(<\/h[456]>|<hr[^>]*>|<\/ul>|<\/pre>)\s*<\/p>/g,"$1"),s}}AssistantMessageView._blockCounter=0;class AssistantContextAdapter{constructor({app:e,modelName:t,pk:s,conversationId:i}){this.app=e,this.modelName=t,this.pk=s,this.conversationId=i,this._messageIdCounter=0,this._onConversationCreated=null}async fetch(){if(!this.conversationId)try{const e=await this.app.rest.post("/api/assistant/context",{model:this.modelName,pk:this.pk}),t=e?.data?.data||e?.data||e;this.conversationId=t.conversation_id,this._onConversationCreated&&this._onConversationCreated(this.conversationId)}catch(e){return[]}try{const e=new AssistantConversation({id:this.conversationId});return await e.fetch({graph:"detail"}),(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean)}catch(e){return 404===e.status&&this.conversationId&&!this._fetchRetried?(this._fetchRetried=!0,this.conversationId=null,this.fetch()):[]}}async addNote(e){return e.text&&e.text.trim()?{success:!0}:{success:!1}}_transformMessage(e){if("tool_result"===e.role)return null;let t=e.content||e.text||"",s=e.blocks||[],i=e.tool_calls||[];if(i.length>0){const e=i.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),i=i.filter(e=>"tool_use"===e.type)}if(0===s.length&&t.includes("assistant_block")){const e=/```assistant_block\s*\n([\s\S]*?)```/g,i=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress"]);let n;for(;null!==(n=e.exec(t));)try{const e=JSON.parse(n[1].trim());e&&i.has(e.type)&&s.push(e)}catch(a){}t=t.replace(e,"").replace(/\n{3,}/g,"\n\n").trim()}return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Assistant"}:e.author||{name:e.user?.display_name||this.app?.activeUser?.get("display_name")||"You",id:e.user?.id||this.app?.activeUser?.id},content:t,timestamp:e.created||e.timestamp,blocks:s,tool_calls:i,_conversationId:this.conversationId}}}class AssistantContextChat extends t.View{constructor(e={}){super({className:"assistant-context-chat",...e}),this.app=e.app,this.ws=this.app?.ws,this.adapter=e.adapter,this._wsHandlers={},this._messageIdCounter=0,this._activePlans={},this.template='\n <div class="d-flex flex-column h-100">\n <div class="flex-grow-1" style="min-height: 0;" data-container="chat-area"></div>\n <div class="assistant-input-wrapper border-top p-2">\n <div class="d-flex gap-2 align-items-end">\n <textarea class="form-control" placeholder="Ask the AI assistant..." rows="1" data-ref="input" style="resize: none; max-height: 150px;"></textarea>\n <button class="btn btn-primary" data-action="send" type="button" data-ref="send-btn">\n <i class="bi bi-arrow-up"></i>\n </button>\n <button class="btn btn-outline-danger d-none" data-action="stop" type="button" data-ref="stop-btn">\n <i class="bi bi-stop-fill"></i>\n </button>\n </div>\n <div class="d-flex align-items-center gap-2 mt-1">\n <span class="assistant-connection-indicator" data-ref="status">\n <span class="status-dot connected" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #198754;"></span>\n </span>\n <span class="text-muted small">Enter to send, Shift+Enter for new line</span>\n </div>\n </div>\n </div>\n '}async onInit(){this.chatView=new a.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this.adapter}),this.addChild(this.chatView),this._subscribeWS()}async onAfterRender(){await super.onAfterRender();const e=this.element.querySelector('[data-ref="input"]');e&&(e.addEventListener("input",()=>this._autoResize(e)),e.addEventListener("keydown",e=>this._handleKeydown(e)),setTimeout(()=>e.focus(),100)),this._updateConnectionStatus()}_autoResize(e){e.style.height="auto",e.style.height=Math.min(e.scrollHeight,150)+"px"}_handleKeydown(e){"Enter"!==e.key||e.shiftKey||(e.preventDefault(),this._sendMessage())}onActionSend(){this._sendMessage()}async _sendMessage(){const e=this.element.querySelector('[data-ref="input"]');if(!e)return;const t=e.value.trim();if(!t)return;if(e.value="",e.style.height="auto",!this.adapter.conversationId&&(await this.adapter.fetch(),!this.adapter.conversationId))return void this.app?.toast?.error("Failed to create conversation");const s={id:"local-"+ ++this._messageIdCounter,role:"user",author:{id:this.app?.activeUser?.id,name:this.app?.activeUser?.get("display_name")||"You"},content:t,timestamp:/* @__PURE__ */(new Date).toISOString()};if(this.chatView.addMessage(s),this._setInputEnabled(!1),this.ws&&this.ws.isConnected)this.ws.send({type:"assistant_message",message:t,conversation_id:this.adapter.conversationId});else try{const e=await this.app.rest.post("/api/assistant",{message:t,conversation_id:this.adapter.conversationId}),s=e?.data?.data||e?.data||e;s.response&&this.chatView.addMessage(this.adapter._transformMessage({id:s.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:s.response,blocks:s.blocks||[],created:/* @__PURE__ */(new Date).toISOString()})),this._setInputEnabled(!0)}catch(i){this.app?.toast?.error("Failed to send message"),this._setInputEnabled(!0)}}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_setInputEnabled(e){const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),i=this.element?.querySelector('[data-ref="stop-btn"]');t&&(t.disabled=!e),s&&s.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),this._responseTimeout&&clearTimeout(this._responseTimeout),e||(this._responseTimeout=setTimeout(()=>{this.chatView.hideThinking(),this._setInputEnabled(!0),this.app?.toast?.warning("Request timed out")},6e4))}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),tool_call:e=>this._onToolCall(e),response:e=>this._onResponse(e),error:e=>this._onError(e),plan:e=>this._onPlan(e),plan_update:e=>this._onPlanUpdate(e),message:e=>this._dispatchWSMessage(e)},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.on("message:assistant_response",this._wsHandlers.response),this.ws.on("message:assistant_error",this._wsHandlers.error),this.ws.on("message:assistant_plan",this._wsHandlers.plan),this.ws.on("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.on("message:message",this._wsHandlers.message))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.off("message:assistant_response",this._wsHandlers.response),this.ws.off("message:assistant_error",this._wsHandlers.error),this.ws.off("message:assistant_plan",this._wsHandlers.plan),this.ws.off("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.off("message:message",this._wsHandlers.message),this._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_tool_call":this._onToolCall(t);break;case"assistant_response":this._onResponse(t);break;case"assistant_error":this._onError(t);break;case"assistant_plan":this._onPlan(t);break;case"assistant_plan_update":this._onPlanUpdate(t)}}_isMyConversation(e){return!e.conversation_id||!this.adapter.conversationId||String(e.conversation_id)===String(this.adapter.conversationId)}_onThinking(e){this._isMyConversation(e)&&(this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1))}_onToolCall(e){this._isMyConversation(e)&&this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`)}_onResponse(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0);const t=this.element?.querySelector('[data-ref="input"]');t&&t.focus();const s=this.adapter._transformMessage({id:e.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:e.response||e.content||e.message||"",blocks:e.blocks||[],tool_calls:e.tool_calls_made||e.tool_calls||[],created:e.timestamp||/* @__PURE__ */(new Date).toISOString()});this.chatView.addMessage(s)}_onError(e){this._isMyConversation(e)&&(this.chatView.hideThinking(),this._setInputEnabled(!0),console.error("[AssistantContextChat] WS error:",e.error||e.message),this.app?.toast?.error("The assistant encountered an error. Please try again."))}_onPlan(e){if(!this._isMyConversation(e))return;const t=e.plan;t&&(this._activePlans[t.plan_id]=t,this.chatView.addMessage({id:`plan-${t.plan_id}`,role:"assistant",author:{name:"Assistant"},content:"",timestamp:/* @__PURE__ */(new Date).toISOString(),blocks:[{type:"progress",...t}],tool_calls:[],_conversationId:this.adapter.conversationId}))}_onPlanUpdate(e){if(!this._isMyConversation(e))return;const t=this._activePlans[e.plan_id];if(t){const s=t.steps.find(t=>t.id===e.step_id);s&&(s.status=e.status,s.summary=e.summary)}const s=this.chatView.messageViews.get(`plan-${e.plan_id}`);s?.updateProgressStep&&s.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary)}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");e&&(this.ws?.isConnected?(e.style.background="#198754",e.title="Connected"):(e.style.background="#dc3545",e.title="Disconnected"))}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}async function w(e,t){const i=e.getApp();if(!i)return;const a=e.model,n=a.get("id"),o=(a.get("metadata")||{}).assistant_conversation_id||null,l=new AssistantContextAdapter({app:i,modelName:t,pk:n,conversationId:o});l._onConversationCreated=async e=>{try{await a.save({metadata:{assistant_conversation_id:e}})}catch(t){}};const r=new AssistantContextChat({app:i,adapter:l}),d=new s.Dialog({header:!0,title:"AI Assistant",size:"xl",body:r,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await d.render(!0,document.body),d.show()}function _(e){if("boolean"==typeof e)return"bool";if("number"==typeof e)return Number.isInteger(e)?"int":"float";const t=Number(e);return""===e||isNaN(t)?"str":Number.isInteger(t)?"int":"float"}const x={new:{badge:"bg-info",icon:"bi-bell-fill",label:"New",help:"Unhandled — needs triage by human or LLM agent"},open:{badge:"bg-primary",icon:"bi-folder2-open",label:"Open",help:"Claimed by an operator for investigation"},investigating:{badge:"bg-warning text-dark",icon:"bi-search",label:"Investigating",help:"Actively being investigated (human or LLM)"},paused:{badge:"bg-secondary",icon:"bi-pause-circle-fill",label:"Paused",help:"On hold — waiting for external input"},resolved:{badge:"bg-success",icon:"bi-check-circle-fill",label:"Resolved",help:"Root cause addressed, no further action needed"},closed:{badge:"bg-dark",icon:"bi-x-circle-fill",label:"Closed",help:"Closed — archived for record keeping"},ignored:{badge:"bg-secondary",icon:"bi-eye-slash-fill",label:"Ignored",help:"Noise — review periodically to tune rules"},pending:{badge:"bg-light text-dark border",icon:"bi-hourglass-split",label:"Pending",help:"Below trigger threshold — accumulating events"}};function k(e){return x[(e||"").toLowerCase()]||x.new}function A(e){const t=parseInt(e)||5;return t>=9?{color:"text-white",bg:"bg-danger",label:"Critical"}:t>=7?{color:"text-white",bg:"bg-danger",label:"High"}:t>=5?{color:"text-dark",bg:"bg-warning",label:"Medium"}:t>=3?{color:"text-white",bg:"bg-info",label:"Low"}:{color:"text-white",bg:"bg-secondary",label:"Info"}}function S(e){const t=(e||"").toLowerCase();return"resolved"===t||"closed"===t?{icon:"bi-check-circle-fill",color:"text-success"}:"open"===t||"investigating"===t?{icon:"bi-exclamation-triangle-fill",color:"text-danger"}:"new"===t?{icon:"bi-bell-fill",color:"text-info"}:"paused"===t||"ignored"===t?{icon:"bi-pause-circle-fill",color:"text-warning"}:{icon:"bi-shield-exclamation",color:"text-secondary"}}class GeoIPSummaryCard extends t.View{constructor(e={}){super({className:"geoip-summary-card",...e}),this.sourceIP=e.sourceIP,this.ipInfo=e.ipInfo||null,this.geoData=null,this.threatBadgeClass="bg-secondary",this.threatLevel="Unknown",this.isBlocked=!1,this.isWhitelisted=!1,this.blockedReason="",this.geoModel=null,this.template='\n {{#geoData}}\n <div class="card shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div class="d-flex align-items-center gap-3">\n <div class="text-primary">\n <i class="bi bi-globe-americas fs-3"></i>\n </div>\n <div>\n <div class="mb-1">\n <a role="button" class="fw-semibold font-monospace text-decoration-none" data-action="view-geoip">{{sourceIP}}</a>\n {{#isBlocked|bool}}\n <span class="badge bg-danger ms-2" data-bs-toggle="tooltip" title="{{blockedReason}}"><i class="bi bi-slash-circle me-1"></i>Blocked</span>\n {{/isBlocked|bool}}\n {{#isWhitelisted|bool}}\n <span class="badge bg-success ms-2"><i class="bi bi-check-circle me-1"></i>Whitelisted</span>\n {{/isWhitelisted|bool}}\n </div>\n <div class="text-muted small">\n {{geoData.city|default(\'Unknown\')}}, {{geoData.country_name|default(\'Unknown\')}}\n {{#geoData.country_code}}\n <span class="text-muted">({{geoData.country_code}})</span>\n {{/geoData.country_code}}\n </div>\n <div class="text-muted small">\n {{geoData.isp|default(\'Unknown ISP\')}}\n {{#geoData.asn}} · {{geoData.asn}}{{/geoData.asn}}\n {{#geoData.connection_type}} · {{geoData.connection_type}}{{/geoData.connection_type}}\n </div>\n </div>\n </div>\n <div class="text-end">\n <span class="badge {{threatBadgeClass}}">{{threatLevel}}</span>\n {{#geoData.risk_score}}\n <div class="text-muted small mt-1">Risk Score: {{geoData.risk_score}}</div>\n {{/geoData.risk_score}}\n <div class="d-flex gap-1 mt-1 justify-content-end">\n {{#geoData.is_tor|bool}}<span class="badge bg-danger-subtle text-danger" title="TOR Exit Node">TOR</span>{{/geoData.is_tor|bool}}\n {{#geoData.is_vpn|bool}}<span class="badge bg-warning-subtle text-warning" title="VPN Detected">VPN</span>{{/geoData.is_vpn|bool}}\n {{#geoData.is_proxy|bool}}<span class="badge bg-info-subtle text-info" title="Proxy">Proxy</span>{{/geoData.is_proxy|bool}}\n {{#geoData.is_datacenter|bool}}<span class="badge bg-secondary-subtle text-secondary" title="Datacenter IP">DC</span>{{/geoData.is_datacenter|bool}}\n {{#geoData.is_known_attacker|bool}}<span class="badge bg-danger" title="Known Attacker">Attacker</span>{{/geoData.is_known_attacker|bool}}\n {{#geoData.is_known_abuser|bool}}<span class="badge bg-danger-subtle text-danger" title="Known Abuser">Abuser</span>{{/geoData.is_known_abuser|bool}}\n </div>\n </div>\n </div>\n \x3c!-- Inline actions --\x3e\n <div class="mt-3 pt-2 border-top d-flex gap-2">\n {{^isBlocked|bool}}\n <button class="btn btn-outline-danger btn-sm" data-action="block-ip">\n <i class="bi bi-slash-circle me-1"></i>Block IP\n </button>\n {{/isBlocked|bool}}\n {{#isBlocked|bool}}\n <button class="btn btn-outline-success btn-sm" data-action="unblock-ip">\n <i class="bi bi-unlock me-1"></i>Unblock IP\n </button>\n {{/isBlocked|bool}}\n {{^isWhitelisted|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="whitelist-ip">\n <i class="bi bi-check-circle me-1"></i>Whitelist\n </button>\n {{/isWhitelisted|bool}}\n <button class="btn btn-outline-secondary btn-sm" data-action="view-geoip">\n <i class="bi bi-box-arrow-up-right me-1"></i>Full GeoIP Record\n </button>\n </div>\n </div>\n </div>\n {{/geoData}}\n {{^geoData}}\n <div class="card shadow-sm">\n <div class="card-body text-muted text-center py-3">\n <i class="bi bi-globe me-2"></i>No GeoIP data available for {{sourceIP}}\n </div>\n </div>\n {{/geoData}}\n '}async onInit(){if(this.sourceIP){if(this.ipInfo)this.geoData=this.ipInfo,this.geoModel=new a.GeoLocatedIP(this.ipInfo);else try{this.geoModel=await a.GeoLocatedIP.lookup(this.sourceIP),this.geoModel&&(this.geoData=this.geoModel.attributes)}catch(e){return}this.geoData&&(this.threatLevel=(this.geoData.threat_level||"unknown").toUpperCase(),this.threatBadgeClass=this._getThreatBadgeClass(this.geoData.threat_level),this.isBlocked=!!this.geoData.is_blocked,this.isWhitelisted=!!this.geoData.is_whitelisted,this.blockedReason=this.geoData.blocked_reason||"Blocked")}}_getThreatBadgeClass(e){const t=(e||"").toLowerCase();return"high"===t||"critical"===t?"bg-danger":"medium"===t?"bg-warning text-dark":"low"===t?"bg-success":"bg-secondary"}async onActionViewGeoip(){await GeoIPView.show(this.sourceIP)}async onActionBlockIp(){const e=await s.Dialog.showForm({title:`Block IP — ${this.sourceIP}`,icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Suspicious activity from incident"},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({block:{reason:e.reason,ttl:parseInt(e.ttl)}});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} blocked`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isBlocked=!0,this.blockedReason=e.reason,await this.render()):this.getApp().toast.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await s.Dialog.showForm({title:`Unblock IP — ${this.sourceIP}`,icon:"bi-unlock",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",placeholder:"e.g., False positive"}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({unblock:e.reason||"Unblocked from incident view"});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} unblocked`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isBlocked=!1,await this.render()):this.getApp().toast.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await s.Dialog.showForm({title:`Whitelist IP — ${this.sourceIP}`,icon:"bi-check-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Known office IP"}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({whitelist:e.reason});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} whitelisted`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isWhitelisted=!0,this.isBlocked=!1,await this.render()):this.getApp().toast.error("Failed to whitelist IP"),!0}}class QuickActionsBar extends t.View{constructor(e={}){super({className:"quick-actions-bar mb-3",...e}),this.incident=e.incident;const t=(this.incident.get("status")||"").toLowerCase();this.isActive=["new","open","investigating"].includes(t),this.isResolved=["resolved","closed"].includes(t),this.isProtected=!!this.incident.get("metadata")?.do_not_delete,this.template='\n <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">\n <div class="d-flex align-items-center gap-2">\n {{#isActive|bool}}\n <button class="btn btn-success btn-sm" data-action="quick-resolve" data-bs-toggle="tooltip" title="Mark this incident as resolved">\n <i class="bi bi-check-circle me-1"></i>Resolve\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-pause" data-bs-toggle="tooltip" title="Put on hold">\n <i class="bi bi-pause-circle"></i>\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-ignore" data-bs-toggle="tooltip" title="Mark as noise">\n <i class="bi bi-eye-slash"></i>\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-escalate" data-bs-toggle="tooltip" title="Escalate priority">\n <i class="bi bi-arrow-up-circle"></i>\n </button>\n {{/isActive|bool}}\n {{#isResolved|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="quick-reopen" data-bs-toggle="tooltip" title="Re-open for further investigation">\n <i class="bi bi-arrow-counterclockwise me-1"></i>Re-open\n </button>\n {{/isResolved|bool}}\n </div>\n <div class="d-flex align-items-center gap-2">\n {{#isProtected|bool}}\n <button class="btn btn-warning btn-sm" data-action="quick-unprotect" data-bs-toggle="tooltip" title="Remove deletion protection">\n <i class="bi bi-shield-fill-check me-1"></i>Protected\n </button>\n {{/isProtected|bool}}\n {{^isProtected|bool}}\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-protect" data-bs-toggle="tooltip" title="Protect from auto-deletion">\n <i class="bi bi-shield me-1"></i>Protect\n </button>\n {{/isProtected|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="quick-create-ticket" data-bs-toggle="tooltip" title="Create a review ticket">\n <i class="bi bi-ticket-perforated me-1"></i>Ticket\n </button>\n <button class="btn btn-outline-dark btn-sm" data-action="quick-analyze-llm" data-bs-toggle="tooltip" title="LLM agent reviews events, merges related incidents, and proposes rules">\n <i class="bi bi-robot me-1"></i>LLM Analyze\n </button>\n <button class="btn btn-outline-primary btn-sm" data-action="quick-ask-ai" data-bs-toggle="tooltip" title="Chat with AI about this incident">\n <i class="bi bi-chat-dots me-1"></i>Ask AI\n </button>\n </div>\n </div>\n '}async onActionQuickResolve(){await this.incident.save({status:"resolved"}),this.getApp()?.toast?.success("Incident resolved"),this.emit("incident:updated")}async onActionQuickPause(){await this.incident.save({status:"paused"}),this.getApp()?.toast?.success("Incident paused"),this.emit("incident:updated")}async onActionQuickIgnore(){await this.incident.save({status:"ignored"}),this.getApp()?.toast?.success("Incident ignored"),this.emit("incident:updated")}async onActionQuickReopen(){await this.incident.save({status:"open"}),this.getApp()?.toast?.success("Incident re-opened"),this.emit("incident:updated")}async onActionQuickCreateTicket(){this.emit("create-ticket")}async onActionQuickEscalate(){const e=parseInt(this.incident.get("priority"))||5,t=Math.min(e+2,10);await this.incident.save({priority:t}),this.getApp()?.toast?.success(`Priority escalated to ${t}`),this.emit("incident:updated")}async onActionQuickAnalyzeLlm(){this.emit("analyze-llm")}async onActionQuickAskAi(){this.emit("ask-ai")}async onActionQuickProtect(){await this.incident.save({metadata:{do_not_delete:!0}}),this.getApp()?.toast?.success("Incident protected from deletion"),this.emit("incident:updated")}async onActionQuickUnprotect(){await this.incident.save({metadata:{do_not_delete:!1}}),this.getApp()?.toast?.success("Deletion protection removed"),this.emit("incident:updated")}}class LLMAnalysisResultsView extends t.View{constructor(e={}){super({className:"llm-analysis-results mb-3",...e}),this.analysis=e.analysis||{},this.incident=e.incident,this.summary=this.analysis.summary||"",this.summaryHtml="",this.hasProposedRule=!!this.analysis.proposed_ruleset_id,this.proposedRulesetId=this.analysis.proposed_ruleset_id,this.mergedCount=(this.analysis.merged_incidents||[]).length,this.mergedIds=(this.analysis.merged_incidents||[]).join(", "),this.template='\n <div class="card border-info shadow-sm">\n <div class="card-header bg-info bg-opacity-10 d-flex align-items-center justify-content-between">\n <div>\n <i class="bi bi-robot me-2 text-info"></i>\n <strong>LLM Analysis</strong>\n <span class="text-muted small ms-2">AI-generated triage</span>\n </div>\n <button class="btn btn-outline-info btn-sm" data-action="re-analyze" data-bs-toggle="tooltip" title="Run a fresh LLM analysis">\n <i class="bi bi-arrow-clockwise me-1"></i>Re-analyze\n </button>\n </div>\n <div class="card-body">\n {{#summary}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-chat-left-text me-1"></i>Summary</h6>\n <div class="bg-light rounded p-3 small llm-summary-content">{{{summaryHtml}}}</div>\n </div>\n {{/summary}}\n {{#mergedCount}}\n <div class="mb-3">\n <span class="badge bg-primary"><i class="bi bi-union me-1"></i>{{mergedCount}} incident(s) merged</span>\n <span class="text-muted small ms-2">IDs: {{mergedIds}}</span>\n </div>\n {{/mergedCount}}\n {{#hasProposedRule|bool}}\n <div class="d-flex align-items-center gap-2">\n <span class="badge bg-success"><i class="bi bi-gear me-1"></i>Proposed Rule</span>\n <button class="btn btn-outline-primary btn-sm" data-action="view-proposed-rule">\n <i class="bi bi-box-arrow-up-right me-1"></i>View Proposed RuleSet #{{proposedRulesetId}}\n </button>\n <span class="text-muted small">(created disabled — review before enabling)</span>\n </div>\n {{/hasProposedRule|bool}}\n </div>\n </div>\n '}async onBeforeRender(){this.summary&&!this.summaryHtml&&(this.summaryHtml=await async function(e,t){if(!t)return"";try{const s=e.getApp(),i=await s.rest.post("/api/docit/render",{markdown:t}),a=i?.data?.data?.html||i?.data?.html;if(a)return a}catch(i){}return`<pre style="white-space: pre-wrap;">${s=t,s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}</pre>`;var s}(this,this.summary))}async onActionReAnalyze(){this.emit("analyze-llm")}async onActionViewProposedRule(){if(this.proposedRulesetId)try{const e=new a.RuleSet({id:this.proposedRulesetId});await e.fetch();const t=new RuleSetView({model:e}),i=new s.Dialog({header:!1,size:"xl",body:t,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await i.render(!0,document.body),i.show()}catch(e){this.getApp()?.toast?.error("Could not load proposed RuleSet")}}}class IncidentOverviewSection extends t.View{constructor(e={}){super({className:"incident-overview-section p-3",template:'\n <div data-container="quick-actions" class="mb-3"></div>\n <div data-container="llm-analysis-results"></div>\n <div data-container="overview-data" class="mb-3"></div>\n <div data-container="geoip-summary"></div>\n {{#serverInfo}}\n <div class="text-muted small mt-2"><i class="bi bi-hdd-rack me-1"></i>{{serverInfo}}</div>\n {{/serverInfo}}\n ',...e})}async onInit(){this.quickActions=new QuickActionsBar({containerId:"quick-actions",incident:this.model}),this.quickActions.on("incident:updated",()=>this.emit("incident:updated")),this.quickActions.on("create-ticket",()=>this.emit("create-ticket")),this.quickActions.on("analyze-llm",()=>this.emit("analyze-llm")),this.quickActions.on("ask-ai",()=>this.emit("ask-ai")),this.addChild(this.quickActions),this._showLlmAnalysisIfAvailable();const e=k(this.model.get("status")),t=A(this.model.get("priority"));this.statusHelp=e.help,this.priorityHelp=t.label;const s=this.model.get("title")||"",i=this.model.get("details")||"",a=[{name:"status",label:"Status",formatter:"badge",cols:3},{name:"priority",label:"Priority",cols:3},{name:"category",label:"Category",formatter:"badge",cols:3},{name:"event_count",label:"Events",cols:3},{name:"scope",label:"Scope",cols:3},{name:"hostname",label:"Hostname",cols:3},{name:"created",label:"Created",formatter:"epoch|datetime",cols:3},{name:"modified",label:"Last Updated",formatter:"epoch|datetime",cols:3},{name:"title",label:"Title",cols:12}];i&&i!==s&&a.push({name:"details",label:"Details",cols:12}),this.model.get("model_name")&&(a.push({name:"model_name",label:"Related Model",cols:6}),a.push({name:"model_id",label:"Model ID",cols:6})),this.dataView=new r.default({containerId:"overview-data",model:this.model,columns:2,showEmptyValues:!1,fields:a}),this.addChild(this.dataView);const n=this.model.get("metadata")||{},o=[];n.server&&o.push(n.server),n.timezone&&o.push(n.timezone),this.serverInfo=o.length?o.join(" · "):null;const l=await this._resolveSourceIP();l&&(this.geoipCard=new GeoIPSummaryCard({containerId:"geoip-summary",sourceIP:l,ipInfo:this.model.get("ip_info")}),this.addChild(this.geoipCard))}_showLlmAnalysisIfAvailable(){const e=(this.model.get("metadata")||{}).llm_analysis;e&&!this.llmResultsView&&(this.llmResultsView=new LLMAnalysisResultsView({containerId:"llm-analysis-results",analysis:e,incident:this.model}),this.llmResultsView.on("analyze-llm",()=>this.emit("analyze-llm")),this.addChild(this.llmResultsView))}refreshAnalysis(){this.llmResultsView&&(this.removeChild(this.llmResultsView),this.llmResultsView=null),this._showLlmAnalysisIfAvailable(),this.render()}async _resolveSourceIP(){const e=this.model.get("metadata")||{};if(e.source_ip)return e.source_ip;if(e.ip)return e.ip;if(e.ip_address)return e.ip_address;try{const e=new a.IncidentEventList({params:{incident:this.model.get("id"),size:1,sort:"-created"}});await e.fetch();const t=e.first();if(t)return t.get("source_ip")||t.get("ip_address")||null}catch(t){}return null}}class HttpRequestSection extends t.View{constructor(e={}){super({className:"http-request-section p-3",template:'\n <h6 class="mb-3"><i class="bi bi-globe2 me-2"></i>HTTP Request Details</h6>\n <div data-container="http-data"></div>\n ',...e}),this.metadata=e.metadata||{}}async onInit(){const e=this.metadata,t={get:t=>e[t],attributes:e};this.dataView=new r.default({containerId:"http-data",model:t,columns:2,showEmptyValues:!1,fields:[{name:"http_method",label:"Method",formatter:"badge",cols:3},{name:"http_status",label:"Status Code",cols:3},{name:"http_host",label:"Host",cols:6},{name:"http_path",label:"Path",cols:12},{name:"http_url",label:"URL",cols:12},{name:"http_protocol",label:"Protocol",cols:6},{name:"http_query_string",label:"Query String",cols:6},{name:"http_user_agent",label:"User Agent",cols:12}]}),this.addChild(this.dataView)}}class IPIntelligenceSection extends t.View{constructor(e={}){super({className:"ip-intelligence-section p-3",template:'\n <h6 class="mb-3"><i class="bi bi-shield-lock me-2"></i>Network</h6>\n <div data-container="ip-network" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-exclamation-triangle me-2"></i>Threat Assessment</h6>\n <div data-container="ip-threat" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-flag me-2"></i>Threat Flags</h6>\n <div data-container="ip-flags" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-slash-circle me-2"></i>Block Status</h6>\n <div data-container="ip-block"></div>\n ',...e}),this.ipInfo=e.ipInfo||{}}async onInit(){const e=this.ipInfo,t={get:t=>e[t],attributes:e};this.networkView=new r.default({containerId:"ip-network",model:t,columns:2,showEmptyValues:!1,fields:[{name:"ip_address",label:"IP Address",cols:6},{name:"subnet",label:"Subnet",cols:6},{name:"isp",label:"ISP",cols:6},{name:"asn",label:"ASN",cols:3},{name:"asn_org",label:"ASN Org",cols:3},{name:"mobile_carrier",label:"Mobile Carrier",cols:6},{name:"connection_type",label:"Connection Type",cols:6}]}),this.addChild(this.networkView),this.threatView=new r.default({containerId:"ip-threat",model:t,columns:2,showEmptyValues:!1,fields:[{name:"threat_level",label:"Threat Level",formatter:"badge",cols:4},{name:"risk_score",label:"Risk Score",cols:4},{name:"is_threat",label:"Threat",formatter:"yesnoicon",cols:2},{name:"is_suspicious",label:"Suspicious",formatter:"yesnoicon",cols:2}]}),this.addChild(this.threatView),this.flagsView=new r.default({containerId:"ip-flags",model:t,columns:2,showEmptyValues:!1,fields:[{name:"is_tor",label:"TOR",formatter:"yesnoicon",cols:3},{name:"is_vpn",label:"VPN",formatter:"yesnoicon",cols:3},{name:"is_proxy",label:"Proxy",formatter:"yesnoicon",cols:3},{name:"is_datacenter",label:"Datacenter",formatter:"yesnoicon",cols:3},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:3},{name:"is_cloud",label:"Cloud",formatter:"yesnoicon",cols:3},{name:"is_known_attacker",label:"Known Attacker",formatter:"yesnoicon",cols:3},{name:"is_known_abuser",label:"Known Abuser",formatter:"yesnoicon",cols:3}]}),this.addChild(this.flagsView),this.blockView=new r.default({containerId:"ip-block",model:t,columns:2,showEmptyValues:!1,fields:[{name:"is_blocked",label:"Blocked",formatter:"yesnoicon",cols:3},{name:"block_count",label:"Block Count",cols:3},{name:"is_whitelisted",label:"Whitelisted",formatter:"yesnoicon",cols:3},{name:"blocked_reason",label:"Block Reason",cols:3},{name:"blocked_at",label:"Blocked At",formatter:"epoch|datetime",cols:6},{name:"blocked_until",label:"Blocked Until",formatter:"epoch|datetime",cols:6},{name:"whitelisted_reason",label:"Whitelist Reason",cols:12}]}),this.addChild(this.blockView)}}class RuleEngineSection extends t.View{constructor(e={}){super({className:"rule-engine-section p-3",...e}),this.incident=e.incident;const t=this.incident.get("rule_set");this.rulesetId=t&&"object"==typeof t?t.id:t,this.rulesetModel=null,this.hasRuleset=!1,this.autoDeleteEnabled=!1,this.incidentProtected=!!this.incident.get("metadata")?.do_not_delete,this.template='\n {{#hasRuleset|bool}}\n {{#autoDeleteEnabled|bool}}\n {{^incidentProtected|bool}}\n <div class="alert alert-warning d-flex align-items-center mb-3" role="alert">\n <i class="bi bi-exclamation-triangle-fill me-2"></i>\n <div>This incident will be <strong>permanently deleted</strong> when resolved or closed. Events and history will also be removed.</div>\n </div>\n {{/incidentProtected|bool}}\n {{#incidentProtected|bool}}\n <div class="alert alert-info d-flex align-items-center mb-3" role="alert">\n <i class="bi bi-shield-fill-check me-2"></i>\n <div>Auto-delete is enabled on this rule, but this incident is <strong>protected</strong> from deletion.</div>\n </div>\n {{/incidentProtected|bool}}\n {{/autoDeleteEnabled|bool}}\n <div class="mb-3">\n <div class="d-flex align-items-center justify-content-between mb-2">\n <h6 class="mb-0"><i class="bi bi-gear-wide-connected me-2"></i>Linked RuleSet</h6>\n <div class="d-flex gap-2">\n <button class="btn btn-outline-primary btn-sm" data-action="edit-linked-ruleset">\n <i class="bi bi-pencil me-1"></i>Edit RuleSet\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="view-linked-ruleset">\n <i class="bi bi-box-arrow-up-right me-1"></i>View Full Details\n </button>\n <button class="btn btn-outline-success btn-sm" data-action="create-rule-from-incident">\n <i class="bi bi-plus-circle me-1"></i>Create New Rule\n </button>\n </div>\n </div>\n <div data-container="ruleset-data"></div>\n <div class="mt-3">\n <h6 class="mb-2"><i class="bi bi-funnel me-2"></i>Rule Conditions</h6>\n <div data-container="ruleset-rules"></div>\n </div>\n </div>\n {{/hasRuleset|bool}}\n {{^hasRuleset|bool}}\n <div class="text-center py-5">\n <div class="text-muted mb-3">\n <i class="bi bi-gear fs-1"></i>\n </div>\n <h6 class="text-muted">No RuleSet Linked</h6>\n <p class="text-muted small mb-3">\n This incident was not created by a rule engine match.<br>\n You can create a new rule based on this incident\'s event pattern to catch similar events automatically.\n </p>\n <button class="btn btn-primary" data-action="create-rule-from-incident">\n <i class="bi bi-plus-circle me-1"></i>Create Rule from Incident\n </button>\n </div>\n {{/hasRuleset|bool}}\n '}async onInit(){if(!this.rulesetId)return;try{this.rulesetModel=new a.RuleSet({id:this.rulesetId}),await this.rulesetModel.fetch(),this.hasRuleset=!0,this.autoDeleteEnabled=!!this.rulesetModel.get("metadata")?.delete_on_resolution}catch(l){return}const e=this.rulesetModel.get("match_by"),t=a.MatchByOptions.find(t=>t.value===e),s=this.rulesetModel.get("bundle_by"),i=a.BundleByOptions.find(e=>e.value===s);this.rulesetDataView=new r.default({containerId:"ruleset-data",model:this.rulesetModel,className:"border rounded p-3 bg-light",columns:2,fields:[{name:"name",label:"Name",cols:6},{name:"category",label:"Scope",formatter:"badge",cols:3},{name:"is_active",label:"Active",formatter:"yesnoicon",cols:3},{name:"priority",label:"Priority",cols:3},{name:"match_by",label:"Match Logic",template:t?t.label:String(e),cols:3},{name:"bundle_by",label:"Bundle By",template:i?i.label:String(s),cols:3},{name:"bundle_minutes",label:"Bundle Window",cols:3},{name:"trigger_count",label:"Trigger Count",cols:3},{name:"trigger_window",label:"Trigger Window (min)",cols:3},{name:"retrigger_every",label:"Re-trigger Every",cols:3},{name:"handler",label:"Handler",cols:12}]}),this.addChild(this.rulesetDataView);const o=new a.RuleList({params:{parent:this.rulesetId}});this.rulesTable=new n.TableView({containerId:"ruleset-rules",collection:o,hideActivePillNames:["parent"],columns:[{key:"name",label:"Name"},{key:"field_name",label:"Field"},{key:"comparator",label:"Comparator",width:"110px"},{key:"value",label:"Value"},{key:"value_type",label:"Type",width:"80px"}],showAdd:!1,size:10,paginated:!1}),this.addChild(this.rulesTable)}async onActionEditLinkedRuleset(){this.rulesetModel&&await s.Dialog.showModelForm({title:`Edit RuleSet — ${this.rulesetModel.get("name")}`,model:this.rulesetModel,formConfig:a.RuleSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("RuleSet updated"))}async onActionViewLinkedRuleset(){if(!this.rulesetModel)return;const e=new RuleSetView({model:this.rulesetModel}),t=new s.Dialog({header:!1,size:"xl",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await t.render(!0,document.body),t.show()}async onActionCreateRuleFromIncident(){const e=this.incident,t=e.get("category")||"",i=e.get("scope")||"",n=e.get("metadata")||{},o=await s.Dialog.showForm({title:"Create RuleSet from Incident",icon:"bi-gear-wide-connected",formConfig:a.RuleSetForms.create,size:"lg",data:{name:`Rule: ${t||"custom"} (from incident #${e.get("id")})`,category:i||t,priority:10,is_active:!1,bundle_by:n.source_ip?4:0,bundle_minutes:30,match_by:0}});if(!o)return;const l=new a.RuleSet,r=await l.save({...o,bundle_by:parseInt(o.bundle_by),bundle_minutes:parseInt(o.bundle_minutes)||30,match_by:parseInt(o.match_by)||0});if(!r.success&&200!==r.status)return void this.getApp()?.toast?.error("Failed to create RuleSet");await this.incident.save({rule_set:l.id}),this.rulesetId=l.id,this.rulesetModel=l,this.hasRuleset=!0;const d=await this._showMetadataRulePicker(l,n);this.getApp()?.toast?.success(d?`RuleSet created with ${d} rule condition(s)`:"RuleSet created — add rule conditions to activate");const c=new RuleSetView({model:l}),m=new s.Dialog({header:!1,size:"xl",body:c,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await m.render(!0,document.body),m.show()}async _showMetadataRulePicker(e,t){const i=/* @__PURE__ */new Set(["title","details","scope","category","source_ip","hostname","model_name","model_id","country_code","country_name","latitude","longitude","stack_trace","traceback","do_not_delete","delete_on_resolution"]),n=Object.entries(t).filter(([e,t])=>!i.has(e)&&null!==t&&""!==t&&"object"!=typeof t).map(([e,t])=>({key:e,value:t,type:_(t)}));if(!n.length)return 0;const o=[{type:"html",columns:12,html:'<div class="text-muted small mb-3">\n Select metadata fields to create as rule conditions.\n Each selected field becomes an <code>==</code> match rule.\n </div>'},...n.map(e=>{return{name:`rule__${e.key}`,type:"switch",label:`${e.key} = ${t=String(e.value),t.length>30?t.slice(0,30)+"…":t}`,tooltip:`${e.type}: ${String(e.value)}`,value:!1,columns:6};var t})],l=await s.Dialog.showForm({title:"Create Rules from Metadata",icon:"bi-list-check",size:"lg",fields:o,submitText:"Create Rules",cancelText:"Skip"});if(!l)return 0;const r=n.filter(e=>l[`rule__${e.key}`]);if(!r.length)return 0;const d=this.getApp();try{await Promise.all(r.map((t,s)=>(new a.Rule).save({parent:e.id,name:`Match ${t.key}`,field_name:t.key,comparator:"==",value:String(t.value),value_type:t.type,index:s})))}catch(c){d?.toast?.warning("Some rule conditions failed to save")}return r.length}}class IncidentTicketsSection extends t.View{constructor(e={}){super({className:"incident-tickets-section p-3",template:'\n <div class="d-flex align-items-center justify-content-between mb-3">\n <h6 class="mb-0"><i class="bi bi-ticket-perforated me-2"></i>Related Tickets</h6>\n <button class="btn btn-primary btn-sm" data-action="create-ticket">\n <i class="bi bi-plus-circle me-1"></i>Create Ticket\n </button>\n </div>\n <div data-container="tickets-table"></div>\n ',...e}),this.incident=e.incident}async onInit(){const e=new a.TicketList({params:{incident:this.incident.get("id"),sort:"-created"}});this.ticketsTable=new n.TableView({containerId:"tickets-table",collection:e,hideActivePillNames:["incident"],columns:[{key:"id",label:"ID",width:"60px",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime",sortable:!0,width:"160px"},{key:"status",label:"Status",formatter:"badge",width:"100px"},{key:"category",label:"Category",formatter:"badge",width:"120px"},{key:"priority",label:"Priority",width:"80px",sortable:!0},{key:"title",label:"Title"}],actions:["view"],showAdd:!1,paginated:!0,size:10,emptyMessage:"No tickets linked to this incident."}),this.addChild(this.ticketsTable)}async onActionCreateTicket(){const e=this.incident,t=`Incident #${e.get("id")}: ${e.get("category")||e.get("title")||"Investigation"}`,i={...a.TicketForms.create,fields:a.TicketForms.create.fields.map(s=>"title"===s.name?{...s,value:t}:"category"===s.name?{...s,value:"incident"}:"priority"===s.name?{...s,value:e.get("priority")||5}:"incident"===s.name?{...s,value:e.get("id"),type:"hidden"}:s)},n=await s.Dialog.showForm(i);if(!n)return;const o=new a.Ticket,l=await o.save({...n,incident:e.get("id")});l.success||200===l.status?(this.getApp()?.toast?.success("Ticket created"),this.ticketsTable?.collection?.fetch()):this.getApp()?.toast?.error("Failed to create ticket")}}class RelatedIncidentsSection extends t.View{constructor(e={}){super({className:"related-incidents-section p-3",template:'\n <div class="mb-3">\n <h6 class="mb-1"><i class="bi bi-diagram-2 me-2"></i>Related Incidents</h6>\n <p class="text-muted small mb-0">Incidents sharing the same source IP or category</p>\n </div>\n <div data-container="related-table"></div>\n ',...e}),this.incident=e.incident,this.sourceIP=e.sourceIP}async onInit(){const e={id__not:this.incident.get("id"),sort:"-created",size:10};if(this.sourceIP)e.source_ip=this.sourceIP;else{const t=this.incident.get("category");t&&(e.category=t)}const t=new a.IncidentList({params:e});this.relatedTable=new n.TableView({containerId:"related-table",collection:t,hideActivePillNames:["id__not","source_ip","category"],columns:[{key:"id",label:"ID",width:"60px",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime",sortable:!0,width:"160px"},{key:"status",label:"Status",formatter:"badge",width:"100px"},{key:"category",label:"Category",formatter:"badge"},{key:"priority",label:"Priority",width:"80px",sortable:!0},{key:"title",label:"Title",formatter:"truncate(60)|default('—')"}],actions:["view"],showAdd:!1,paginated:!0,size:10,emptyMessage:"No related incidents found."}),this.addChild(this.relatedTable)}}class IncidentView extends t.View{constructor(e={}){super({className:"incident-view",...e}),this.model=e.model||new a.Incident(e.data||{}),this.incidentIcon=S(this.model.get("status")),this.statusCfg=k(this.model.get("status")),this.priorityCfg=A(this.model.get("priority")),this.isProtected=!!this.model.get("metadata")?.do_not_delete,this.template='\n <div class="incident-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{incidentIcon.color}}">\n <i class="bi {{incidentIcon.icon}}"></i>\n </div>\n <div>\n <h4 class="mb-1">Incident #{{model.id}}</h4>\n {{#model.title}}\n <div class="text-muted mb-2">{{model.title|truncate(80)}}</div>\n {{/model.title}}\n <div class="d-flex align-items-center gap-2 flex-wrap">\n <span class="badge {{statusCfg.badge}}" data-bs-toggle="tooltip" title="{{statusCfg.help}}">\n <i class="bi {{statusCfg.icon}} me-1"></i>{{statusCfg.label}}\n </span>\n <span class="badge {{priorityCfg.bg}} {{priorityCfg.color}}" data-bs-toggle="tooltip" title="Priority {{model.priority}} — {{priorityCfg.label}} severity">\n P{{model.priority}} · {{priorityCfg.label}}\n </span>\n {{#model.category}}\n <span class="badge bg-light text-dark border">{{model.category}}</span>\n {{/model.category}}\n {{#isProtected|bool}}\n <span class="badge bg-warning text-dark"><i class="bi bi-shield-fill-check me-1"></i>Protected</span>\n {{/isProtected|bool}}\n </div>\n <div class="text-muted small mt-1">\n {{model.created|datetime}}\n {{#model.scope}} · {{model.scope}}{{/model.scope}}\n {{#model.hostname}} · {{model.hostname}}{{/model.hostname}}\n {{#model.event_count}} · {{model.event_count}} events{{/model.event_count}}\n </div>\n </div>\n </div>\n <div data-container="incident-context-menu"></div>\n </div>\n\n \x3c!-- SideNav --\x3e\n <div data-container="incident-sidenav"></div>\n </div>\n '}async onInit(){this._sourceIP=await this._resolveSourceIP(),this.getApp().showLoading(),await this.model.fetch({params:{graph:"detailed"}}),this.getApp().hideLoading(),this.overviewSection=new IncidentOverviewSection({model:this.model});const e=this.overviewSection;e.on("incident:updated",()=>this._handleIncidentUpdated()),e.on("create-ticket",()=>this._handleCreateTicket()),e.on("analyze-llm",()=>this._handleAnalyzeLlm()),e.on("ask-ai",()=>this._handleAskAi());const t=new a.IncidentEventList({params:{incident:this.model.get("id")}}),s=new n.TableView({collection:t,hideActivePillNames:["incident"],columns:[{key:"id",label:"ID",width:"50px",sortable:!0},{key:"created",label:"Date / Category",sortable:!0,width:"160px",template:'<div>{{{model.created|epoch|datetime}}}</div><div class="text-muted small">{{{model.category|badge}}}</div>'},{key:"source_ip",label:"Source",sortable:!0,width:"130px",template:'<div>{{model.hostname}}</div><div class="text-muted small">{{model.source_ip}}</div>',filter:{type:"text"}},{key:"title",label:"Title",sortable:!0,formatter:"truncate(80)|default('—')"},{key:"level",label:"Level",sortable:!0,width:"60px",filter:{type:"text"}}],showAdd:!1,actions:["view"],paginated:!0,size:10}),i=new RuleEngineSection({incident:this.model}),o=new IncidentTicketsSection({incident:this.model}),l=new IncidentHistoryAdapter(this.model.get("id")),r=[{key:"Overview",label:"Overview",icon:"bi-shield-exclamation",view:e},{key:"Events",label:"Events",icon:"bi-list-ul",view:s},{key:"Rule Engine",label:"Rule Engine",icon:"bi-gear-wide-connected",view:i},{key:"Tickets",label:"Tickets",icon:"bi-ticket-perforated",view:o},{key:"History",label:"History",icon:"bi-chat-left-text",view:new a.ChatView({adapter:l})},{type:"divider",label:"Investigation"},{key:"Related Incidents",label:"Related",icon:"bi-diagram-2",view:new RelatedIncidentsSection({incident:this.model,sourceIP:this._sourceIP})}],d=this.model.get("metadata")||{},c=this.model.get("ip_info");if(d.http_method||d.http_path){const e=new HttpRequestSection({metadata:d});r.push({key:"HTTP Request",label:"HTTP Request",icon:"bi-globe2",view:e})}if(c){const e=new IPIntelligenceSection({ipInfo:c});r.push({key:"IP Intelligence",label:"IP Intel",icon:"bi-shield-lock",view:e})}const m=d.stack_trace||d.traceback,u=!!m,b=Object.keys(d).length>0;if((u||b)&&r.push({type:"divider",label:"Forensics"}),u){const e=new StackTraceView({stackTrace:m});r.push({key:"Stack Trace",label:"Stack Trace",icon:"bi-code-square",view:e})}if(b){const e=this._buildMetadataSection(d);r.push({key:"Metadata",label:"Metadata",icon:"bi-braces",view:e})}this.sideNav=new SideNavView({containerId:"incident-sidenav",sections:r,activeSection:"Overview",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500}),this.addChild(this.sideNav),this._buildContextMenu()}_buildMetadataSection(e){const s=[],i=["source_ip","hostname","user_agent","http_url","http_method","http_status","country_code","region","city","request_path","user","component","component_id","error_class","error_message","rule_id","risk_score","action","trigger"];for(const t of i)void 0!==e[t]&&null!==e[t]&&s.push({name:t,label:t.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),cols:6});return s.length>0?new t.View({model:this.model,metadata:e,hasStructuredFields:s.length>0,template:'\n <div class="p-3">\n {{#hasStructuredFields|bool}}\n <h6 class="mb-3"><i class="bi bi-list-check me-2"></i>Key Fields</h6>\n <div data-container="structured-metadata" class="mb-4"></div>\n {{/hasStructuredFields|bool}}\n <h6 class="mb-2"><i class="bi bi-braces me-2"></i>Raw Metadata</h6>\n <pre class="bg-light p-3 border rounded small"><code>{{{model.metadata|json}}}</code></pre>\n </div>\n ',onInit(){if(s.length>0){const t={get:t=>e[t],attributes:e};this.structuredView=new r.default({containerId:"structured-metadata",model:t,columns:2,showEmptyValues:!1,fields:s}),this.addChild(this.structuredView)}}}):new t.View({model:this.model,template:'<pre class="bg-light p-3 border rounded small"><code>{{{model.metadata|json}}}</code></pre>'})}_buildContextMenu(){const t=(this.model.get("status")||"").toLowerCase(),s=[];s.push({label:"Change Status",icon:"bi-arrow-repeat",header:!0}),"open"!==t&&s.push({label:"Open",action:"set-status-open",icon:"bi-folder2-open"}),"investigating"!==t&&s.push({label:"Investigate",action:"set-status-investigating",icon:"bi-search"}),"paused"!==t&&s.push({label:"Pause",action:"set-status-paused",icon:"bi-pause-circle"}),"resolved"!==t&&s.push({label:"Resolve",action:"set-status-resolved",icon:"bi-check-circle"}),"ignored"!==t&&s.push({label:"Ignore",action:"set-status-ignored",icon:"bi-eye-slash"}),s.push({type:"divider"}),s.push({label:"Edit Incident",action:"edit-incident",icon:"bi-pencil"}),s.push({label:"Change Priority",action:"change-priority",icon:"bi-arrow-up-circle"}),this.model.get("metadata")?.do_not_delete?s.push({label:"Remove Protection",action:"remove-protection",icon:"bi-shield"}):s.push({label:"Protect from Deletion",action:"protect-incident",icon:"bi-shield-fill-check"}),s.push({type:"divider"}),this._sourceIP&&(s.push({label:`Block IP (${this._sourceIP})`,action:"block-source-ip",icon:"bi-slash-circle",class:"text-danger"}),s.push({label:`View GeoIP (${this._sourceIP})`,action:"view-source-geoip",icon:"bi-globe"})),s.push({label:"Create Ticket",action:"create-ticket",icon:"bi-ticket-perforated"}),s.push({label:"Merge Incidents",action:"merge-incidents",icon:"bi-union"}),s.push({type:"divider"}),s.push({label:"Ask AI",action:"ask-ai",icon:"bi-chat-dots"}),s.push({label:"LLM Analyze",action:"analyze-llm",icon:"bi-robot"}),s.push({type:"divider"}),s.push({label:"Delete Incident",action:"delete-incident",icon:"bi-trash",danger:!0});const i=new e.ContextMenu({containerId:"incident-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:s}});this.addChild(i)}async onAfterRender(){await super.onAfterRender(),window.bootstrap&&window.bootstrap.Tooltip&&this.element&&this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(e=>{const t=window.bootstrap.Tooltip.getInstance(e);t&&"function"==typeof t.dispose&&t.dispose(),new window.bootstrap.Tooltip(e)})}async _resolveSourceIP(){const e=this.model.get("metadata")||{};if(e.source_ip)return e.source_ip;if(e.ip)return e.ip;if(e.ip_address)return e.ip_address;try{const e=new a.IncidentEventList({params:{incident:this.model.get("id"),size:1,sort:"-created"}});await e.fetch();const t=e.first();if(t)return t.get("source_ip")||t.get("ip_address")||null}catch(t){}return null}async _setStatus(e){await this.model.save({status:e}),this.getApp()?.toast?.success(`Status changed to ${e}`),this._handleIncidentUpdated()}async onActionSetStatusOpen(){await this._setStatus("open")}async onActionSetStatusInvestigating(){await this._setStatus("investigating")}async onActionSetStatusPaused(){await this._setStatus("paused")}async onActionSetStatusResolved(){await this._setStatus("resolved")}async onActionSetStatusIgnored(){await this._setStatus("ignored")}async onActionProtectIncident(){await this.model.save({metadata:{do_not_delete:!0}}),this.getApp()?.toast?.success("Incident protected from deletion"),this._handleIncidentUpdated()}async onActionRemoveProtection(){await this.model.save({metadata:{do_not_delete:!1}}),this.getApp()?.toast?.success("Deletion protection removed"),this._handleIncidentUpdated()}async onActionChangePriority(){const e=await s.Dialog.showForm({title:"Change Priority",icon:"bi-arrow-up-circle",size:"sm",fields:[{name:"priority",type:"select",label:"Priority",value:this.model.get("priority")||5,options:[{value:10,label:"10 — Critical"},{value:9,label:"9 — Critical"},{value:8,label:"8 — High"},{value:7,label:"7 — High"},{value:6,label:"6 — Medium"},{value:5,label:"5 — Medium"},{value:4,label:"4 — Low"},{value:3,label:"3 — Low"},{value:2,label:"2 — Info"},{value:1,label:"1 — Info"}]}]});e&&(await this.model.save({priority:parseInt(e.priority)}),this.getApp()?.toast?.success(`Priority changed to ${e.priority}`),this._handleIncidentUpdated())}async onActionEditIncident(){await s.Dialog.showModelForm({title:`Edit Incident #${this.model.id}`,model:this.model,formConfig:a.IncidentForms.edit})&&this._handleIncidentUpdated()}async onActionBlockSourceIp(){if(!this._sourceIP)return;const e=await a.GeoLocatedIP.lookup(this._sourceIP);if(!e)return void this.getApp()?.toast?.error("Could not find GeoIP record for this IP");const t=await s.Dialog.showForm({title:`Block IP — ${this._sourceIP}`,icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,value:`Blocked from incident #${this.model.id}`},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!t)return;const i=await e.save({block:{reason:t.reason,ttl:parseInt(t.ttl)}});i.success||200===i.status?this.getApp()?.toast?.success(`IP ${this._sourceIP} blocked fleet-wide`):this.getApp()?.toast?.error("Failed to block IP")}async onActionViewSourceGeoip(){this._sourceIP&&await GeoIPView.show(this._sourceIP)}async onActionCreateTicket(){this._handleCreateTicket()}async _handleCreateTicket(){const e=`Incident #${this.model.get("id")}: ${this.model.get("category")||this.model.get("title")||"Investigation"}`,t=await s.Dialog.showForm({...a.TicketForms.create,fields:a.TicketForms.create.fields.map(t=>"title"===t.name?{...t,value:e}:"category"===t.name?{...t,value:"incident"}:"priority"===t.name?{...t,value:this.model.get("priority")||5}:"incident"===t.name?{...t,value:this.model.get("id"),type:"hidden"}:t)});if(!t)return;const i=new a.Ticket,n=await i.save({...t,incident:this.model.get("id")});n.success||200===n.status?this.getApp()?.toast?.success("Ticket created"):this.getApp()?.toast?.error("Failed to create ticket")}async onActionMergeIncidents(){const e=await s.Dialog.showForm({title:"Merge Incidents",icon:"bi-union",size:"sm",fields:[{name:"merge_ids",type:"text",label:"Incident IDs to merge into this one",required:!0,placeholder:"e.g., 102, 105, 108",help:"Comma-separated IDs. Events from those incidents will be merged here."}]});if(!e)return;const t=e.merge_ids.split(",").map(e=>parseInt(e.trim())).filter(e=>e&&e!==this.model.id);if(0===t.length)return;const i=await this.model.save({merge:t});i.success||200===i.status?(this.getApp()?.toast?.success(`Merged ${t.length} incident(s)`),this._handleIncidentUpdated()):this.getApp()?.toast?.error("Merge failed")}async onActionAskAi(){await this._handleAskAi()}async _handleAskAi(){await w(this,"incident.Incident")}async onActionAnalyzeLlm(){await this._handleAnalyzeLlm()}async _handleAnalyzeLlm(){if(!(await s.Dialog.confirm('Run LLM analysis on this incident? The AI agent will review all events, attempt to merge related incidents, and propose a new rule to catch similar patterns. The incident status will be set to "investigating".',"LLM Analysis",{confirmText:"Analyze",confirmClass:"btn-info"})))return;const e=this.getApp();e?.showLoading("Starting LLM analysis...");try{const t=await this.model.save({analyze:1});if(!t.success&&200!==t.status){const s=t.data?.error||t.error||"Failed to start analysis";return void e?.toast?.error(s)}e?.toast?.success("LLM analysis started — polling for results in the background..."),this._pollAnalysisProgress()}catch(t){e?.toast?.error(`Analysis failed: ${t.message}`)}finally{e?.hideLoading()}}_pollAnalysisProgress(){let e=0;const t=()=>{e++,e>60?this.getApp()?.toast?.error("Analysis is taking longer than expected. Check back later."):setTimeout(()=>{this.model.fetch().then(()=>{const e=this.model.get("metadata")||{};if(!e.analysis_in_progress)return e.llm_analysis?(this.getApp()?.toast?.success("LLM analysis complete"),this.overviewSection?.refreshAnalysis()):this.getApp()?.toast?.success("Analysis finished"),void this._handleIncidentUpdated();t()}).catch(()=>{t()})},5e3)};t()}async onActionDeleteIncident(){await s.Dialog.confirm(`Are you sure you want to delete incident #${this.model.id}? This action cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("incident:deleted",{model:this.model})}_handleIncidentUpdated(){this.incidentIcon=S(this.model.get("status")),this.statusCfg=k(this.model.get("status")),this.priorityCfg=A(this.model.get("priority")),this.render(),this.emit("incident:updated",{model:this.model})}}a.Incident.VIEW_CLASS=IncidentView;class IncidentTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_incidents",pageName:"Manage Incidents",router:"admin/incidents",Collection:a.IncidentList,formCreate:a.IncidentForms.create,formEdit:a.IncidentForms.edit,itemViewClass:IncidentView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-id",status:"new"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"status",label:"Status",filter:{type:"multiselect",options:["new","open","paused","resolved","qa","ignored"]}},{key:"created",label:"Created",formatter:"epoch|datetime",filter:{type:"daterange"}},{key:"scope",label:"Scope",sortable:!0,filter:{type:"text"}},{key:"category",label:"Category",sortable:!0,filter:{type:"text"}},{key:"priority",label:"Priority",filter:{type:"text"}},{key:"title",label:"title",formatter:"truncate(100)|default('No description')"}],filters:[{key:"category__not",label:"Not Category",filter:{type:"text"}},{key:"priority__gt",label:"Priority Greater Than",filter:{type:"number"}},{key:"priority__lt",label:"Priority Less Than",filter:{type:"number"}},{key:"metadata__rule_id",label:"Rule ID",filter:{type:"text"}},{key:"metadata__key",label:"Metadata Key",filter:{type:"text"}}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No incidents found. Click "Add Incident" to create your first incident.',batchBarLocation:"top",batchActions:[{label:"Open",icon:"bi bi-folder2-open",action:"open"},{label:"Resolve",icon:"bi bi-check-circle",action:"resolve"},{label:"Pause",icon:"bi bi-pause-circle",action:"pause"},{label:"Ignore",icon:"bi bi-x-circle",action:"ignore"},{label:"Merge",icon:"bi bi-merge",action:"merge"},{label:"Protect",icon:"bi bi-shield-fill-check",action:"protect"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchResolve(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to close ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"resolved"}))),this.tableView.collection.fetch())}async onActionBatchOpen(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to open ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"open"}))),this.tableView.collection.fetch())}async onActionBatchPause(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to pause ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"paused"}))),this.tableView.collection.fetch())}async onActionBatchIgnore(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to ignore ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"ignored"}))),this.tableView.collection.fetch())}async onActionBatchMerge(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp(),a=await i.showForm({title:`Merge ${s.length} incidents`,fields:[{name:"merge",type:"select",label:"Select Parent Incident",options:s.map(e=>({value:e.model.id,label:e.model.id})),required:!0}]});if(!a)return;const n=s.find(e=>e.model.id==a.merge)?.model;if(!n)return;const o=s.map(e=>e.model.id).filter(e=>e!=a.merge);await n.save({merge:o}),this.tableView.collection.fetch()}async onActionBatchProtect(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Protect ${s.length} incident(s) from deletion?`)&&(await Promise.all(s.map(e=>e.model.save({metadata:{do_not_delete:!0}}))),i.toast?.success(`${s.length} incident(s) protected`),this.tableView.collection.fetch())}}const C={user:s.User,userdevice:s.UserDevice,userdevicelocation:s.UserDeviceLocation,geolocatedip:a.GeoLocatedIP,member:n.Member,incident:a.Incident,incidentevent:a.IncidentEvent,ticket:a.Ticket,job:a.Job,log:n.Log,apikey:ApiKey};class EventView extends t.View{constructor(e={}){super({className:"event-view",...e}),this.model=e.model||new a.IncidentEvent(e.data||{}),this.eventIcon=this.getIconForEvent(this.model.get("level")),this.template='\n <div class="event-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{eventIcon.color}}">\n <i class="bi {{eventIcon.icon}}"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.title|default(\'System Event\')}}</h3>\n <div class="text-muted small">\n Category: {{model.category|capitalize}}\n </div>\n <div class="text-muted small mt-1">\n {{model.created|datetime}} from {{model.source_ip|default(\'Unknown IP\')}}\n </div>\n </div>\n </div>\n <div data-container="event-context-menu"></div>\n </div>\n\n \x3c!-- Body --\x3e\n <div data-container="event-tabs"></div>\n </div>\n '}getIconForEvent(e){return e>=40?{icon:"bi-exclamation-octagon-fill",color:"text-danger"}:e>=30?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:e>=20?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-bell-fill",color:"text-secondary"}}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",columns:2,fields:[{name:"id",label:"Event ID"},{name:"level",label:"Level"},{name:"hostname",label:"Hostname"},{name:"incident",label:"Incident ID"},{name:"model_name",label:"Related Model"},{name:"model_id",label:"Related Model ID"},{name:"details",label:"Details",columns:12}]});const s={Overview:this.overviewView},i=this.model.get("metadata")||{};i.stack_trace&&(this.stackTraceView=new StackTraceView({stackTrace:i.stack_trace}),s["Stack Trace"]=this.stackTraceView),Object.keys(i).length>0&&(this.metadataView=new t.View({model:this.model,template:'<pre class="bg-light p-3 border rounded"><code>{{{model.metadata|json}}}</code></pre>'}),s.Metadata=this.metadataView),this.tabView=new a.TabView({containerId:"event-tabs",tabs:s,activeTab:"Overview"}),this.addChild(this.tabView);const n=[{label:"View Incident",action:"view-incident",icon:"bi-shield-exclamation",disabled:!this.model.get("incident")},{label:"View Related Model",action:"view-model",icon:"bi-box-arrow-up-right",disabled:!this.model.get("model_id")},{type:"divider"},{label:"Delete Event",action:"delete-event",icon:"bi-trash",danger:!0}],o=new e.ContextMenu({containerId:"event-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:n}});this.addChild(o)}async onActionViewIncident(){const e=this.model.get("incident")||this.model.get("incident_id");if(!e)return this.getApp()?.toast?.warning("No incident linked to this event"),!0;const t=new a.Incident({id:e}),i=new IncidentView({model:t});await s.Dialog.showDialog({title:"Incident Details",body:i,size:"xl",scrollable:!0,header:!1,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]})}async onActionViewModel(){const e=this.model.get("model_name")||this.model.get("model_class"),t=this.model.get("model_id")||this.model.get("object_id");if(!e||!t)return this.getApp()?.toast?.warning("No related model linked to this event"),!0;const s=e.toLowerCase().replace(/[^a-z]/g,""),i=C[s];return i?i.VIEW_CLASS?void(await l.default.showModelById(i,t)):(this.getApp()?.toast?.warning(`No detail view available for ${e}`),!0):(this.getApp()?.toast?.warning(`Unknown model type: ${e}`),!0)}async onActionDeleteEvent(){await s.Dialog.confirm("Are you sure you want to delete this event? This action cannot be undone.","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("event:deleted",{model:this.model})}}a.IncidentEvent.VIEW_CLASS=EventView;class EventTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_events",pageName:"System Events",router:"admin/events",Collection:a.IncidentEventList,formEdit:a.IncidentEventForms.edit,itemViewClass:EventView,viewDialogOptions:{header:!1,size:"lg"},defaultQuery:{sort:"-id",category__not:"ossec"},columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"datetime",filter:{type:"daterange"}},{key:"level",label:"Level",sortable:!0,formatter:"badge",filter:{type:"select",options:[{value:"5",label:"Critical"},{value:"4",label:"Warning"},{value:"3",label:"Info"},{value:"2",label:"Debug"},{value:"1",label:"Trace"}]}},{key:"scope",label:"Scope",sortable:!0,formatter:"badge",filter:{type:"combobox",options:[{value:"account",label:"Account"},{value:"incident",label:"Incident"},{value:"ossec",label:"OSSEC"},{value:"fileman",label:"File Manager"},{value:"metrics",label:"Metrics"},{value:"jobs",label:"Jobs"},{value:"aws",label:"AWS"}]}},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"combobox",options:[{value:"rest_error",label:"Rest Error"},{value:"api_error",label:"API Error"},{value:"auth",label:"Auth"},{value:"database",label:"Database"}]}},{key:"title",label:"Title",sortable:!0,formatter:"truncate(50)"},{key:"source_ip",label:"Source IP",sortable:!0,filter:{type:"text"}},{key:"metadata.server",label:"Server",sortable:!0,filter:{type:"text"}}],filters:[{key:"category__not",label:"Not Category",filter:{type:"text"}},{key:"metadata__http_url__icontains",label:"URL Contains",filter:{type:"text"}},{key:"metadata__http_path__icontains",label:"Path Contains",filter:{type:"text"}},{key:"metadata__http_query_string__icontains",label:"Query String Contains",filter:{type:"text"}},{key:"metadata__rule_id",label:"Rule ID",filter:{type:"text"}},{key:"metadata__country_code",label:"Country",filter:{type:"text"}},{key:"metadata__region",label:"Region",filter:{type:"text"}},{key:"metadata__city__icontains",label:"City",filter:{type:"text"}},{key:"metadata__http_status",label:"HTTP Status",filter:{type:"text"}},{key:"model_name",label:"Model Name",filter:{type:"text"}},{key:"model_id",label:"Model ID",filter:{type:"text"}},{key:"metadata__user_email",label:"User Email",filter:{type:"text"}},{key:"metadata__http_user_agent__icontains",label:"User Agent Contains",filter:{type:"text"}}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No events found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class TicketNoteAdapter{constructor(e){this.ticketId=e,this.collection=new a.TicketNoteList({params:{parent:this.ticketId,sort:"created",size:100}})}async fetch(){await this.collection.fetch();const e=this.collection.models.map(e=>this.transform(e));return await Promise.all(e.map(async e=>{e.content&&(e.content=await this._renderMarkdown(e.content))})),e}transform(e){return{id:e.get("id"),type:e.get("user")?"user_comment":"system_event",author:{id:e.get("user.id"),name:e.get("user.display_name")||"System",avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]}}async addNote(e){const t=new a.TicketNote,s=await t.save({parent:this.ticketId,note:e.text,media:e.files&&e.files.length>0?e.files[0].id:null});return s.success&&await this.collection.fetch(),s}async _renderMarkdown(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}}const I={new:{badge:"bg-info",icon:"bi-bell-fill"},open:{badge:"bg-primary",icon:"bi-folder2-open"},in_progress:{badge:"bg-warning text-dark",icon:"bi-gear-fill"},pending:{badge:"bg-secondary",icon:"bi-pause-circle-fill"},resolved:{badge:"bg-success",icon:"bi-check-circle-fill"},qa:{badge:"bg-purple",icon:"bi-clipboard-check"},closed:{badge:"bg-dark",icon:"bi-x-circle-fill"},ignored:{badge:"bg-secondary",icon:"bi-eye-slash-fill"}};function P(e){const t=I[e]||{badge:"bg-secondary",icon:"bi-circle"};return`<span class="badge ${t.badge}"><i class="${t.icon} me-1"></i>${(e||"").replace("_"," ")}</span>`}class TicketDescriptionSection extends t.View{constructor(e={}){super({className:"ticket-description-section",...e}),this.descriptionHtml="",this.template='\n <div class="card mb-3">\n <div class="card-body">\n <div class="ticket-description-content">{{{descriptionHtml}}}</div>\n </div>\n </div>\n '}async onBeforeRender(){const e=this.model.get("description")||"";this.descriptionHtml=await async function(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}(e)}}class LinkedIncidentCard extends t.View{constructor(e={}){super({className:"linked-incident-card",...e}),this.incident=e.incident||{},this.incidentTitle=this.incident.title||"Untitled",this.incidentId=this.incident.id,this.incidentStatus=this.incident.status||"unknown",this.incidentPriority=this.incident.priority,this.incidentCategory=this.incident.category||"",this.incidentScope=this.incident.scope||"",this.template='\n <div class="card border-start border-3 border-warning mb-3">\n <div class="card-body py-2 px-3">\n <div class="d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center gap-2 flex-grow-1" style="min-width: 0;">\n <i class="bi bi-exclamation-triangle-fill text-warning flex-shrink-0"></i>\n <div style="min-width: 0;">\n <div class="fw-semibold text-truncate">Incident #{{incidentId}}: {{incidentTitle}}</div>\n <div class="text-muted small">\n {{{statusBadge}}}\n <span class="ms-2">Priority: {{incidentPriority}}</span>\n {{#incidentCategory}}\n <span class="ms-2">{{{incidentCategory|badge}}}</span>\n {{/incidentCategory}}\n </div>\n </div>\n </div>\n <button class="btn btn-outline-primary btn-sm flex-shrink-0 ms-2" data-action="open-incident">\n <i class="bi bi-box-arrow-up-right me-1"></i>Open\n </button>\n </div>\n </div>\n </div>\n '}async onBeforeRender(){this.statusBadge=P(this.incidentStatus)}async onActionOpenIncident(){if(this.incidentId)try{const e=new a.Incident({id:this.incidentId});await e.fetch({params:{graph:"detailed"}});const t=new IncidentView({model:e}),i=new s.Dialog({header:!1,size:"xl",body:t,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await i.render(!0,document.body),i.show()}catch(e){this.getApp()?.toast?.error("Failed to load incident")}}}class TicketView extends t.View{constructor(e={}){super({className:"ticket-view",...e}),this.model=e.model||new a.Ticket(e.data||{}),this.template='\n <div class="ticket-view-container d-flex flex-column h-100">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3 flex-shrink-0">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n {{{statusBadge}}}\n {{#model.category}}\n <span class="badge bg-secondary">{{model.category}}</span>\n {{/model.category}}\n <span class="text-muted small">Ticket #{{model.id}}</span>\n </div>\n <h4 class="mb-1">{{model.title}}</h4>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-flag-fill me-1"></i>Priority {{model.priority}}</span>\n {{#assigneeName}}\n <span><i class="bi bi-person-fill me-1"></i>{{assigneeName}}</span>\n {{/assigneeName}}\n {{#model.created}}\n <span><i class="bi bi-clock me-1"></i>{{model.created|relative}}</span>\n {{/model.created}}\n {{#model.modified}}\n <span><i class="bi bi-pencil me-1"></i>{{model.modified|relative}}</span>\n {{/model.modified}}\n </div>\n </div>\n <div class="d-flex align-items-center gap-2 flex-shrink-0">\n <button class="btn btn-outline-primary btn-sm" data-action="ask-ai" data-bs-toggle="tooltip" title="Chat with AI about this ticket">\n <i class="bi bi-robot me-1"></i>Ask AI\n </button>\n <div data-container="ticket-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Linked Incident --\x3e\n <div data-container="linked-incident"></div>\n\n \x3c!-- Description (collapsible) --\x3e\n {{#hasDescription|bool}}\n <div class="mb-3">\n <a class="text-muted small d-inline-flex align-items-center gap-1" data-bs-toggle="collapse" href="#ticket-desc-{{model.id}}" role="button" aria-expanded="false">\n <i class="bi bi-chevron-right"></i>\n <i class="bi bi-file-text me-1"></i>Description\n </a>\n <div class="collapse" id="ticket-desc-{{model.id}}">\n <div data-container="ticket-description" class="mt-2"></div>\n </div>\n </div>\n {{/hasDescription|bool}}\n\n \x3c!-- Chat / Notes --\x3e\n <div class="d-flex align-items-center justify-content-between mb-2">\n <h6 class="mb-0 text-muted"><i class="bi bi-chat-left-text me-1"></i>Notes</h6>\n <button class="btn btn-outline-secondary btn-sm" data-action="refresh-notes">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n <div class="flex-grow-1" style="min-height: 0;" data-container="chat-view"></div>\n </div>\n '}async onBeforeRender(){this.statusBadge=P(this.model.get("status"));const e=this.model.get("assignee");this.assigneeName=e?.display_name||("string"==typeof e?e:null),this.hasDescription=!!this.model.get("description")}async onInit(){const t=this.model.get("incident");t&&"object"==typeof t&&t.id&&(this.linkedIncident=new LinkedIncidentCard({containerId:"linked-incident",incident:t}),this.addChild(this.linkedIncident)),this.model.get("description")&&(this.descriptionView=new TicketDescriptionSection({containerId:"ticket-description",model:this.model}),this.addChild(this.descriptionView)),this.adapter=new TicketNoteAdapter(this.model.get("id")),this.chatView=new a.ChatView({containerId:"chat-view",adapter:this.adapter,theme:"compact",currentUserId:this.getCurrentUserId(),inputPlaceholder:"Add a note...",inputButtonText:"Add Note"}),this.addChild(this.chatView);const s=new e.ContextMenu({containerId:"ticket-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{type:"divider"},{label:"Change Status",action:"change-status",icon:"bi-tag"},{label:"Set Priority",action:"set-priority",icon:"bi-flag"},{label:"Assign User",action:"assign-user",icon:"bi-person"},{type:"divider"},{label:"Close Ticket",action:"close-ticket",icon:"bi-x-circle",class:"text-danger"}]}});this.addChild(s)}getCurrentUserId(){const e=window.app?.state?.user;return e?.id||null}async onActionRefreshNotes(){await this.chatView.refresh(),this.getApp()?.toast?.success("Notes refreshed")}async onActionAskAi(){await w(this,"incident.Ticket")}async onActionEditTicket(){await s.Dialog.showModelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:a.TicketForms.edit.fields})&&this.render()}async onActionChangeStatus(){const e=await s.Dialog.showForm({title:"Change Status",icon:"bi-tag",size:"sm",fields:[{name:"status",label:"Status",type:"select",options:["new","open","in_progress","pending","resolved","qa","closed","ignored"].map(e=>({value:e,label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())})),value:this.model.get("status"),required:!0}]});e&&(await this.model.save({status:e.status}),this.render())}async onActionSetPriority(){const e=await s.Dialog.showForm({title:"Set Priority",icon:"bi-flag",size:"sm",fields:[{name:"priority",label:"Priority",type:"select",value:this.model.get("priority")||5,options:[{value:10,label:"10 — Critical"},{value:9,label:"9 — Severe"},{value:8,label:"8 — High"},{value:7,label:"7 — Elevated"},{value:5,label:"5 — Normal"},{value:3,label:"3 — Low"},{value:1,label:"1 — Info"}],required:!0}]});e&&(await this.model.save({priority:parseInt(e.priority)}),this.render())}async onActionAssignUser(){const e=await s.Dialog.showForm({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:s.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this.model.save({assignee:e.assignee}),this.getApp()?.toast?.success("Ticket assigned"),this.render())}async onActionCloseTicket(){await s.Dialog.confirm(`Close ticket #${this.model.get("id")}?`,"Close Ticket",{confirmText:"Close",confirmClass:"btn-warning"})&&(await this.model.save({status:"closed"}),this.getApp()?.toast?.success("Ticket closed"),this.render())}}a.Ticket.VIEW_CLASS=TicketView;class TicketTablePage extends a.TablePage{constructor(e={}){super({name:"admin_tickets",pageName:"Tickets",router:"admin/tickets",Collection:a.TicketList,formCreate:a.TicketForms.create,formEdit:a.TicketForms.edit,itemViewClass:TicketView,viewDialogOptions:{header:!1},defaultQuery:{sort:"-priority",status:"open"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"title",label:"Title",sortable:!0},{key:"status",label:"Status",sortable:!0,editable:!0,editableOptions:{type:"select",options:["new","open","paused","resolved","qa","ignored"]},filter:{type:"multiselect",placeHolder:"Select Status",options:["new","open","paused","resolved","qa","ignored"]}},{key:"priority",label:"Priority",sortable:!0},{key:"category",label:"Category",sortable:!0,editable:!0,editableOptions:{type:"select",options:[...Object.keys(a.TicketCategories)]},filter:{type:"multiselect",placeHolder:"Select Category",options:[...Object.keys(a.TicketCategories)]}},{key:"assignee.display_name",label:"Assignee",sortable:!0,formatter:"default('Unassigned')"},{key:"incident.id",label:"Incident ID",sortable:!0},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No tickets found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}}class RuleSetTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_rulesets",pageName:"Rule Engine",router:"admin/rulesets",Collection:a.RuleSetList,itemViewClass:RuleSetView,formCreate:a.RuleSetForms.create,formEdit:a.RuleSetForms.edit,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"priority"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"is_active",label:"Active",width:"70px",sortable:!0,formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Active"},{value:"false",label:"Inactive"}]}},{key:"metadata.delete_on_resolution",label:"Auto-Delete",width:"90px",formatter:"yesnoicon"},{key:"name",label:"Name",sortable:!0},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"text",placeholder:"e.g., auth:failed"}},{key:"priority",label:"Priority",sortable:!0,width:"80px"},{key:"bundle_by",label:"Bundle By",width:"140px",formatter:e=>{const t=a.BundleByOptions.find(t=>t.value===e);return t?t.label:String(e)}},{key:"trigger_count",label:"Trigger",width:"80px",formatter:e=>null!=e?String(e):'<span class="text-muted">—</span>'},{key:"handler",label:"Handler",formatter:"truncate(40)|default('—')"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No rule sets found. Create one to start matching events automatically.",batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-toggle-on",action:"enable"},{label:"Disable",icon:"bi bi-toggle-off",action:"disable"},{label:"Delete",icon:"bi bi-trash",action:"delete",danger:!0}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Enable ${e.length} ruleset(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} ruleset(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Disable ${e.length} ruleset(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} ruleset(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Delete ${e.length} ruleset(s)? This cannot be undone.`)&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} ruleset(s) deleted`),this.tableView.collection.fetch())}}class EmailDomainTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_domains",pageName:"Email Domains",router:"admin/email/domains",Collection:a.EmailDomainList,formCreate:a.EmailDomainForms.create,formEdit:a.EmailDomainForms.edit,columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Domain",sortable:!0},{key:"region",label:"Region",sortable:!0,formatter:"default('—')"},{key:"receiving_enabled",label:"Receiving",formatter:"boolean|badge"},{key:"can_send",label:"Send Verified",formatter:"boolean|badge"},{key:"can_recv",label:"Recv Verified",formatter:"boolean|badge"},{key:"created",label:"Created",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No domains found. Click "Add Domain" to get started.',contextMenu:[{icon:"bi-shield-check",action:"edit-aws-creds",label:"Edit AWS Credentials"},{icon:"bi-rocket-takeoff",action:"onboard",label:"Onboard"},{icon:"bi-shield-check",action:"audit",label:"Audit"},{icon:"bi-arrow-repeat",action:"reconcile",label:"Reconcile"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditAwsCreds(e,t){const i=this.collection.get(t.dataset.id);return await s.Dialog.showModelForm({model:i,formConfig:a.EmailDomainForms.credentials}),!0}async onActionOnboard(e,t){const i=this.collection.get(t.dataset.id),n=new a.EmailDomain({id:i.id}),o=await s.Dialog.showForm(a.EmailDomainForms.onboard);if(o)try{const e=await n.onboard(o);if(!e.success)throw new Error(e.message||"Network error during onboarding");if(!e.data?.status)throw new Error(e.data?.error||"Onboarding failed");this.getApp()?.toast?.success("Domain onboarding completed successfully"),await this.refresh()}catch(l){console.error("Onboard error:",l),this.showError(l.message||"Failed to onboard domain")}}async onActionAudit(e,i){const n=this.collection.get(i.dataset.id),o=new a.EmailDomain({id:n.id});try{const e=await o.audit();if(!e.success)throw new Error(e.message||"Network error during audit");if(!e.data?.status)throw new Error(e.data?.error||"Audit failed");const i=e.data?.data||{};await s.Dialog.showDialog({title:`Audit Report - ${n.name}`,body:new t.View({template:'\n <div>\n <p class="text-muted">Drift report and status:</p>\n <pre class="bg-light p-3 rounded small"><code>{{{data.result|json}}}</code></pre>\n </div>\n ',data:{result:i}}),size:"lg"})}catch(l){console.error("Audit error:",l),this.showError(l.message||"Failed to audit domain")}}async onActionReconcile(e,t){const s=this.collection.get(t.dataset.id),i=new a.EmailDomain({id:s.id});try{const e=await i.reconcile();if(!e.success)throw new Error(e.message||"Network error during reconcile");if(!e.data?.status)throw new Error(e.data?.error||"Reconcile failed");this.getApp()?.toast?.success("Reconcile completed successfully"),await this.refresh()}catch(n){console.error("Reconcile error:",n),this.showError(n.message||"Failed to reconcile domain")}}}class EmailMailboxTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_mailboxes",pageName:"Mailboxes",router:"admin/email/mailboxes",Collection:a.MailboxList,formCreate:a.MailboxForms.create,formEdit:a.MailboxForms.edit,clickAction:"edit",columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"email",label:"Email",sortable:!0},{key:"domain.name",label:"Domain",sortable:!0,formatter:"default('—')"},{key:"allow_inbound",label:"Inbound",formatter:"boolean|badge"},{key:"allow_outbound",label:"Outbound",formatter:"boolean|badge"},{key:"is_system_default",label:"System Default",formatter:"boolean|badge"},{key:"is_domain_default",label:"Domain Default",formatter:"boolean|badge"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No mailboxes found. Click "Add Mailbox" to create one.',contextMenu:[{icon:"bi-envelope",action:"send-email",label:"Send Email"},{icon:"bi-envelope",action:"send-template-email",label:"Send Template Email"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionSendEmail(e,t){const i=this.collection.get(t.dataset.id),n=await s.Dialog.showForm({title:"Send Email",fields:[{name:"to",label:"To",type:"email",required:!0},{name:"subject",label:"Subject",type:"text",required:!0},{name:"body_html",label:"Body",type:"textarea",required:!0}]});n.from_email=i.get("email");const o=await a.Mailbox.sendEmail(n);if(o.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";o.data.details?e=o.data.details:o.data.error&&(e=o.data.error),this.getApp().toast.error(e)}}async onActionSendTemplateEmail(e,t){const i=this.collection.get(t.dataset.id),n=await s.Dialog.showForm({title:"Send Email",fields:[{name:"to",label:"To",type:"email",required:!0},{name:"subject",label:"Subject",type:"text",required:!0},{name:"template_name",label:"Template",type:"text",required:!0},{name:"template_context",label:"Context",type:"textarea",required:!0}]});n.from_email=i.get("email");const o=await a.Mailbox.sendEmail(n);if(o.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";o.data.details?e=o.data.details:o.data.error&&(e=o.data.error),this.getApp().toast.error(e)}}}class EmailHtmlPreviewView extends t.View{constructor(e={}){super({className:"email-html-preview",template:'\n <div class="email-html-preview-container">\n <div class="d-flex justify-content-between align-items-center mb-2">\n <small class="text-muted">HTML Preview (sandboxed)</small>\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="refresh-preview">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n <iframe\n id="email-preview-frame"\n class="border rounded w-100"\n style="height: 500px; background: white;"\n sandbox="allow-same-origin"\n frameborder="0"\n ></iframe>\n </div>\n ',...e})}async onAfterRender(){await super.onAfterRender(),this.renderHtmlInIframe()}renderHtmlInIframe(){const e=this.element.querySelector("#email-preview-frame");if(!e)return;const t=this.model.get("html_template")||"",s=e.contentDocument||e.contentWindow.document;s.open(),s.write(t),s.close()}async onActionRefreshPreview(e,t){this.renderHtmlInIframe()}}class EmailTemplateView extends t.View{constructor(e={}){super({className:"email-template-view",...e}),this.model=e.model||new a.EmailTemplate(e.data||{}),this.hasHtml=!!this.model.get("html_template"),this.hasText=!!this.model.get("text_template"),this.hasMetadata=this.model.get("metadata")&&Object.keys(this.model.get("metadata")).length>0,this.template='\n <div class="email-template-view-container p-3">\n \x3c!-- Header --\x3e\n <div class="template-header border-bottom pb-3 mb-3">\n <h4 class="mb-1">{{model.name}}</h4>\n <div class="text-muted">\n <strong>Subject:</strong> {{model.subject_template|default(\'Not set\')}}\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="template-tabs"></div>\n </div>\n '}async onInit(){const e={};this.hasHtml&&(e["HTML Preview"]=new EmailHtmlPreviewView({model:this.model})),this.hasText&&(e["Text Version"]=new t.View({model:this.model,template:'<pre class="email-text-content p-3 bg-light border rounded" style="white-space: pre-wrap; word-wrap: break-word;">{{model.text_template}}</pre>'})),this.hasMetadata&&(e.Metadata=new t.View({model:this.model,template:'<pre class="email-metadata-content p-3 bg-light border rounded"><code>{{model.metadata|json}}</code></pre>'})),this.tabView=new a.TabView({containerId:"template-tabs",tabs:e,activeTab:Object.keys(e)[0]||""}),this.addChild(this.tabView)}}class EmailTemplateTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_templates",pageName:"Email Templates",router:"admin/email/templates",Collection:a.EmailTemplateList,formCreate:a.EmailTemplateForms.create,formEdit:a.EmailTemplateForms.edit,itemViewClass:EmailTemplateView,clickAction:"edit",viewDialogOptions:{header:!1,size:"xl",scrollable:!0},formDialogConfig:{size:"fullscreen"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"created",label:"Created",formatter:"datetime"},{key:"modified",label:"Modified",formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No email templates found. Click "Add Template" to create your first one.',tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class EmailView extends t.View{constructor(e={}){super({className:"email-view",...e}),this.model=e.model||new a.SentMessage(e.data||{}),this.hasHtml=!!this.model.get("body_html"),this.hasText=!!this.model.get("body_text"),this.hasContext=this.model.get("template_context")&&Object.keys(this.model.get("template_context")).length>0,this.template='\n <div class="email-view-container p-3">\n \x3c!-- Email Header --\x3e\n <div class="email-header border-bottom pb-3 mb-3">\n <h4 class="mb-2">{{model.subject}}</h4>\n <div class="d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center">\n <div class="flex-shrink-0">\n <i class="bi bi-person-circle fs-2 text-secondary"></i>\n </div>\n <div class="ms-3">\n <div class="fw-bold">{{model.mailbox.email}}</div>\n <div class="text-muted small">To: {{model.to_addresses|list}}</div>\n </div>\n </div>\n <div class="text-end">\n <div class="text-muted small">{{model.created|datetime}}</div>\n <span class="badge {{model.status|badge}} mt-1">{{model.status|capitalize}}</span>\n </div>\n </div>\n </div>\n\n \x3c!-- Email Body Tabs --\x3e\n <div data-container="email-tabs"></div>\n </div>\n '}async onInit(){const e={};this.hasHtml&&(e.HTML=new t.View({model:this.model,template:'<div class="email-html-content border rounded p-3" style="height: 500px; overflow-y: auto;">{{{model.body_html}}}</div>'})),this.hasText&&(e.Text=new t.View({model:this.model,template:'<pre class="email-text-content p-3 bg-light border rounded" style="white-space: pre-wrap; word-wrap: break-word;">{{model.body_text}}</pre>'})),this.hasContext&&(e.Context=new t.View({model:this.model,template:'<pre class="email-context-content p-3 bg-light border rounded"><code>{{model.template_context|json}}</code></pre>'})),this.tabView=new a.TabView({containerId:"email-tabs",tabs:e,activeTab:this.hasHtml?"HTML":this.hasText?"Text":"Context"}),this.addChild(this.tabView)}}a.SentMessage.VIEW_CLASS=EmailView;class SentMessageTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_sent",pageName:"Sent Messages",router:"admin/email/sent",Collection:a.SentMessageList,itemViewClass:EmailView,viewDialogOptions:{header:!1,size:"xl",scrollable:!0},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"mailbox.email",label:"From",sortable:!0},{key:"to_addresses",label:"To",sortable:!1,formatter:"list"},{key:"subject",label:"Subject",sortable:!0},{key:"status",label:"Status",formatter:"badge"},{key:"status_reason",label:"Reason",formatter:"truncate(80)|default('—')"},{key:"created",label:"Created",formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No sent messages found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class PhoneNumber extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/phonehub/number",...t})}static async normalize(e,s="US"){const i={phone_number:e};s&&(i.country_code=s);const a=await t.rest.POST("/api/phonehub/number/normalize",i),n=a?.data??a;return!0===n?.status||!0===n?.success?{success:!0,phone_number:n?.data?.phone_number??n?.phone_number,data:n?.data??n,response:a}:{success:!1,error:n?.error||"Normalization failed",response:a}}static async lookup(e,s={}){const i=await t.rest.POST("/api/phonehub/number/lookup",{phone_number:e,...s}),a=i?.data??i;if(!0===a?.status||!0===a?.success){const e=a?.data??{};return{success:!0,model:new PhoneNumber(e,{endpoint:"/api/phonehub/number"}),data:e,response:i}}return{success:!1,error:a?.error||"Phone lookup failed",response:i}}}class PhoneNumberList extends t.Collection{constructor(e={}){super({ModelClass:PhoneNumber,endpoint:"/api/phonehub/number",size:10,...e})}}class SMS extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/phonehub/sms",...t})}static async send(e={}){const s=await t.rest.POST("/api/phonehub/sms/send",e),i=s?.data??s;if(!0===i?.status||!0===i?.success){const e=i?.data??{};return{success:!0,model:new SMS(e,{endpoint:"/api/phonehub/sms"}),data:e,response:s}}return{success:!1,error:i?.error||"Failed to send SMS",response:s}}}class SMSList extends t.Collection{constructor(e={}){super({ModelClass:SMS,endpoint:"/api/phonehub/sms",size:10,...e})}}class PhoneNumberView extends t.View{constructor(e={}){super({className:"phone-number-view",...e}),this.model=e.model||new PhoneNumber(e.data||{}),this.template='\n <div class="phone-number-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-telephone"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.phone_number|default(\'Unknown Number\')}}</h3>\n <div class="text-muted small">\n {{model.carrier|default(\'—\')}} {{#model.line_type}}· {{model.line_type|capitalize}}{{/model.line_type}}\n </div>\n <div class="text-muted small mt-1">\n {{#model.country_code}}Country: {{model.country_code}}{{/model.country_code}}\n {{#model.region}} · Region: {{model.region}}{{/model.region}}\n {{#model.state}} · State: {{model.state}}{{/model.state}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div data-container="phone-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="phone-tabs"></div>\n </div>\n '}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"phone_number",label:"Phone Number",cols:6},{name:"country_code",label:"Country Code",cols:6},{name:"region",label:"Region",cols:6},{name:"state",label:"State",cols:6},{name:"registered_owner",label:"Registered Owner",cols:6},{name:"owner_type",label:"Owner Type",formatter:"capitalize",cols:6},{name:"is_valid",label:"Valid",formatter:"yesnoicon",cols:4},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:4},{name:"is_voip",label:"VOIP",formatter:"yesnoicon",cols:4}]}),this.carrierView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"carrier",label:"Carrier",cols:6},{name:"line_type",label:"Line Type",formatter:"capitalize",cols:6},{name:"lookup_provider",label:"Lookup Provider",formatter:"capitalize",cols:6},{name:"lookup_count",label:"Lookup Count",cols:6},{name:"last_lookup_at",label:"Last Lookup",formatter:"datetime",cols:6},{name:"lookup_expires_at",label:"Cache Expires",formatter:"datetime",cols:6}]}),this.addressView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"address_line1",label:"Address Line 1",cols:12},{name:"address_city",label:"City",cols:4},{name:"address_state",label:"State",cols:4},{name:"address_zip",label:"ZIP",cols:4},{name:"address_country",label:"Country",cols:6}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6}]});const t={Overview:this.overviewView,Carrier:this.carrierView,Address:this.addressView,Metadata:this.metadataView};this.tabView=new a.TabView({containerId:"phone-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"phone-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh Lookup",action:"refresh-lookup",icon:"bi-arrow-repeat"},{type:"divider"},{label:"Delete Record",action:"delete-phone",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionRefreshLookup(){const e=this.model.get("phone_number");if(e)try{this.getApp()?.toast?.info?.("Refreshing lookup...");const t=await PhoneNumber.lookup(e,{force_refresh:!0});if(t.success&&t.data)this.model.set(t.data),await this.render(),this.getApp()?.toast?.success?.("Lookup refreshed");else{const e=t.error||"Lookup failed";this.getApp()?.toast?.error?.(e)}}catch(t){this.getApp()?.toast?.error?.(t.message||"Lookup failed")}else this.getApp()?.toast?.warning?.("No phone number to lookup")}async onActionDeletePhone(){if(await s.Dialog.confirm(`Are you sure you want to delete the record for "${this.model.get("phone_number")||"this number"}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"}))try{const e=await this.model.destroy();e?.success?this.emit("phone:deleted",{model:this.model}):this.getApp()?.toast?.error?.("Delete failed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Delete failed")}}static async show(e){const t=await PhoneNumber.lookup(e);if(t?.model){const e=new PhoneNumberView({model:t.model}),i=new s.Dialog({header:!1,size:"lg",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});return await i.render(!0,document.body),i.show(),i}return s.Dialog.alert({message:`Could not find phone data for number: ${e}`,type:"warning"}),null}}PhoneNumberView.MODEL_CLASS=PhoneNumber;class PhoneNumberTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_numbers",pageName:"Phone Numbers",router:"admin/phonehub/numbers",Collection:PhoneNumberList,itemView:PhoneNumberView,viewDialogOptions:{header:!1},columns:[{key:"phone_number",label:"Phone Number",sortable:!0},{key:"carrier",label:"Carrier",sortable:!0,formatter:"default('—')"},{key:"line_type",label:"Line Type",sortable:!0,formatter:"capitalize"},{key:"is_mobile",label:"Mobile",formatter:"yesnoicon"},{key:"is_voip",label:"VOIP",formatter:"yesnoicon"},{key:"is_valid",label:"Valid",formatter:"yesnoicon"},{key:"registered_owner",label:"Owner",sortable:!0,formatter:"default('—')"},{key:"owner_type",label:"Owner Type",formatter:"capitalize"},{key:"last_lookup_at|relative",label:"Last Lookup",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No phone numbers found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},tableViewOptions:{addButtonLabel:"Lookup",addButtonIcon:"bi-search",onAdd:e=>{e.preventDefault(),this.onLookup()}}})}async onLookup(){const e=await this.getApp().showForm({title:"Lookup Phone Number",fields:[{name:"number",type:"text",required:!0}]});if(e&&e.number){const t=await PhoneNumber.lookup(e.number);t.model&&this.tableView._onRowView(t)}}}class SMSView extends t.View{constructor(e={}){super({className:"sms-view",...e}),this.model=e.model||new SMS(e.data||{}),this.template='\n <div class="sms-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-chat-dots"></i>\n </div>\n <div>\n <h3 class="mb-1">\n {{#model.direction}}{{model.direction|capitalize}}{{/model.direction}}\n {{^model.direction}}Message{{/model.direction}}\n <small class="text-muted ms-2">\n {{#model.status}}[{{model.status|capitalize}}]{{/model.status}}\n </small>\n </h3>\n <div class="text-muted small">\n {{#model.from_number}}From: {{model.from_number}}{{/model.from_number}}\n {{#model.to_number}} · To: {{model.to_number}}{{/model.to_number}}\n </div>\n <div class="text-muted small mt-1">\n {{#model.provider}}Provider: {{model.provider|capitalize}}{{/model.provider}}\n {{#model.provider_message_id}} · SID: {{model.provider_message_id}}{{/model.provider_message_id}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div data-container="sms-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="sms-tabs"></div>\n </div>\n '}async onInit(){this.messageView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"direction",label:"Direction",formatter:"capitalize",cols:4},{name:"status",label:"Status",formatter:"capitalize",cols:4},{name:"from_number",label:"From",cols:6},{name:"to_number",label:"To",cols:6},{name:"body",label:"Message Body",cols:12}]}),this.deliveryView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"provider",label:"Provider",formatter:"capitalize",cols:6},{name:"provider_message_id",label:"Provider Message ID",cols:6},{name:"sent_at",label:"Sent At",formatter:"datetime",cols:6},{name:"delivered_at",label:"Delivered At",formatter:"datetime",cols:6},{name:"error_code",label:"Error Code",cols:6},{name:"error_message",label:"Error Message",cols:12}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6}]});const t={Message:this.messageView,Delivery:this.deliveryView,Metadata:this.metadataView};this.tabView=new a.TabView({containerId:"sms-tabs",tabs:t,activeTab:"Message"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"sms-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh-sms",icon:"bi-arrow-repeat"},{type:"divider"},{label:"Delete Message",action:"delete-sms",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionRefreshSms(){try{this.getApp()?.toast?.info?.("Refreshing message..."),await this.model.fetch(),await this.render(),this.getApp()?.toast?.success?.("Message refreshed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Refresh failed")}}async onActionDeleteSms(){if(await s.Dialog.confirm("Are you sure you want to delete this message?","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"}))try{const e=await this.model.destroy();e?.success?this.emit("sms:deleted",{model:this.model}):this.getApp()?.toast?.error?.("Delete failed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Delete failed")}}}SMSView.MODEL_CLASS=SMS;class SMSTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_sms",pageName:"SMS Messages",router:"admin/phonehub/sms",Collection:SMSList,itemView:SMSView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"direction",label:"Direction",sortable:!0},{key:"from_number",label:"From",sortable:!0,formatter:"default('—')"},{key:"to_number",label:"To",sortable:!0,formatter:"default('—')"},{key:"status",label:"Status",sortable:!0},{key:"provider",label:"Provider",sortable:!0,formatter:"default('—')"},{key:"body",label:"Message",formatter:"default('—')"},{key:"sent_at",label:"Sent At",sortable:!0,formatter:"datetime"},{key:"delivered_at",label:"Delivered At",sortable:!0,formatter:"datetime"},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No SMS messages found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class PushDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Push Notifications Dashboard",className:"push-dashboard-page"})}async getTemplate(){return'\n <div class="container-fluid">\n <h1 class="h3 mb-4">Push Notifications</h1>\n <div class="row">\n \x3c!-- Stat cards --\x3e\n </div>\n <div class="row">\n <div class="col-xl-8 col-lg-7">\n <div class="card shadow mb-4">\n <div class="card-header">Notifications Over Time</div>\n <div class="card-body" data-container="deliveries-chart"></div>\n </div>\n </div>\n <div class="col-xl-4 col-lg-5">\n <div class="card shadow mb-4">\n <div class="card-header">Delivery Status</div>\n <div class="card-body" data-container="status-chart"></div>\n </div>\n </div>\n </div>\n <div class="row">\n <div class="col-lg-6 mb-4" data-container="recent-deliveries"></div>\n <div class="col-lg-6 mb-4" data-container="failed-deliveries"></div>\n </div>\n </div>\n '}async onInit(){this.deliveriesChart=new i.MetricsChart({containerId:"deliveries-chart",endpoint:"/api/metrics/fetch",slugs:["push_sent","push_failed"],chartType:"line"}),this.addChild(this.deliveriesChart),this.statusChart=new i.PieChart({containerId:"status-chart",endpoint:"/api/account/devices/push/stats"}),this.addChild(this.statusChart),this.recentDeliveries=new n.TableView({containerId:"recent-deliveries",title:"Recent Deliveries",Collection:new a.PushDeliveryList({params:{_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"status",label:"Status",formatter:"badge"}]}),this.addChild(this.recentDeliveries),this.failedDeliveries=new n.TableView({containerId:"failed-deliveries",title:"Failed Deliveries",Collection:new a.PushDeliveryList({params:{status:"failed",_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"error_message",label:"Error"}]}),this.addChild(this.failedDeliveries)}}class PushConfigTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_configs",pageName:"Push Configurations",router:"admin/push/configs",Collection:a.PushConfigList,formCreate:a.PushConfigForms.create,formEdit:a.PushConfigForms.edit,columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"group.name",label:"Group",formatter:"default('Default')"},{key:"fcm_sender_id",label:"Project ID"},{key:"is_active",label:"Active",format:"boolean"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,actions:["edit","delete"],tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No push configurations found.",emptyIcon:"bi-gear"}})}}class PushTemplateTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_templates",pageName:"Push Templates",router:"admin/push/templates",Collection:a.PushTemplateList,formCreate:a.PushTemplateForms.create,formEdit:a.PushTemplateForms.edit,columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"category",label:"Category"},{key:"group.name",label:"Group",formatter:"default('Default')"},{key:"priority",label:"Priority"},{key:"is_active",label:"Active",format:"boolean"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No push templates found.",emptyIcon:"bi-file-earmark-text",actions:["edit","delete"]}})}}class PushDeliveryView extends t.View{constructor(e={}){super({className:"push-delivery-view",...e}),this.model=e.model}getTemplate(){return'\n <div class="p-3">\n <div class="phone-mockup">\n <div class="phone-screen">\n <div class="notification">\n <div class="notification-header">\n <i class="bi bi-app-indicator"></i>\n <strong>Your App</strong>\n <span class="ms-auto small text-muted">now</span>\n </div>\n <div class="notification-body">\n <div class="fw-bold">{{model.title}}</div>\n <div>{{model.body}}</div>\n </div>\n </div>\n </div>\n </div>\n <div class="mt-3">\n <h5>Delivery Details</h5>\n <p><strong>Status:</strong> <span class="badge {{model.status|badge}}">{{model.status}}</span></p>\n <p><strong>Error:</strong> {{model.error_message|default(\'None\')}}</p>\n </div>\n </div>\n '}}class PushDeliveryTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_deliveries",pageName:"Push Deliveries",router:"admin/push/deliveries",Collection:a.PushDeliveryList,itemViewClass:PushDeliveryView,viewDialogOptions:{header:!1,size:"md"},columns:[{key:"id",label:"ID",width:"70px"},{key:"created",label:"Timestamp",formatter:"datetime"},{key:"user.display_name",label:"User"},{key:"device.device_name",label:"Device"},{key:"title",label:"Title"},{key:"category",label:"Category"},{key:"status",label:"Status",formatter:"badge"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No deliveries found.",emptyIcon:"bi-send",actions:["view"]}})}}class PushDeviceView extends t.View{constructor(e={}){super({className:"push-device-view",...e}),this.model=e.model}getTemplate(){return'\n <div class="p-3">\n <h3>{{model.device_name}}</h3>\n <p class="text-muted">{{model.user.display_name}}</p>\n <div data-container="data-view"></div>\n </div>\n '}onInit(){this.dataView=new r.default({containerId:"data-view",model:this.model,fields:[{name:"platform",label:"Platform",format:"badge"},{name:"push_enabled",label:"Push Enabled",format:"boolean"},{name:"app_version",label:"App Version"},{name:"os_version",label:"OS Version"},{name:"last_seen",label:"Last Seen",format:"datetime"},{name:"push_preferences",label:"Preferences",format:"json"}]}),this.addChild(this.dataView)}}class PushDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_devices",pageName:"Registered Devices",router:"admin/push/devices",Collection:a.PushDeviceList,itemViewClass:PushDeviceView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px"},{key:"user.display_name",label:"User"},{key:"device_name",label:"Device Name"},{key:"platform",label:"Platform",formatter:"badge"},{key:"app_version",label:"App Version"},{key:"push_enabled",label:"Push Enabled",format:"boolean"},{key:"last_seen",label:"Last Seen",formatter:"datetime"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No devices found.",emptyIcon:"bi-phone",actions:["view","delete"]}})}}class JobStatsView extends t.View{constructor(e={}){super({className:"job-stats-section",...e}),this.stats={pending:0,running:0,completed:0,failed:0,scheduled:0},this.template='\n <div class="job-stats-header mb-4">\n <div class="row">\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Pending</h6>\n <h3 class="mb-1 fw-bold">{{stats.pending}}</h3>\n <span class="badge bg-primary-subtle text-primary">\n <i class="bi bi-hourglass"></i> Queued\n </span>\n </div>\n <div class="text-primary">\n <i class="bi bi-clock fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Running</h6>\n <h3 class="mb-1 fw-bold">{{stats.running}}</h3>\n <span class="badge bg-success-subtle text-success">\n <i class="bi bi-arrow-repeat"></i> Active\n </span>\n </div>\n <div class="text-success">\n <i class="bi bi-play-circle fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Scheduled</h6>\n <h3 class="mb-1 fw-bold">{{stats.scheduled}}</h3>\n <span class="badge bg-warning-subtle text-warning">\n <i class="bi bi-calendar-event"></i> Planned\n </span>\n </div>\n <div class="text-warning">\n <i class="bi bi-calendar3 fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Completed</h6>\n <h3 class="mb-1 fw-bold">{{stats.completed}}</h3>\n <span class="badge bg-info-subtle text-info">\n <i class="bi bi-check-circle"></i> Done\n </span>\n </div>\n <div class="text-info">\n <i class="bi bi-check-square fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Failed</h6>\n <h3 class="mb-1 fw-bold">{{stats.failed}}</h3>\n <span class="badge bg-danger-subtle text-danger">\n <i class="bi bi-x-octagon"></i> Errors\n </span>\n </div>\n <div class="text-danger">\n <i class="bi bi-exclamation-triangle fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}_onModelChange(){this.loadStats(),this.isMounted()&&this.render()}async loadStats(){this.stats=this.model.attributes.totals}}class JobHealthView extends t.View{constructor(e={}){super({className:"job-health-section",...e}),this.health={overall_status:"unknown",runners:{active:0,total:0},channels:[]},this.healthStatusClass="text-muted",this.schedulerStatusText="Unknown",this.schedulerStatusClass="text-muted",this.schedulerIcon="bi-question-circle-fill",this.template='\n <div class="job-health-header mb-4">\n <div class="card border-0 shadow-sm">\n <div class="card-body">\n <div class="row align-items-center">\n <div class="col-md-6">\n <div class="d-flex align-items-center">\n <div class="health-indicator me-3">\n <i class="bi bi-circle-fill fs-4 {{healthStatusClass}}"></i>\n </div>\n <div>\n <h5 class="mb-1">Service Health: {{health.overall_status|capitalize}}</h5>\n <small class="text-muted d-block">\n Workers: {{health.runners.active}}/{{health.runners.total}} active\n </small>\n <small class="text-muted d-block">\n Scheduler:\n <span class="{{schedulerStatusClass}} fw-bold">\n <i class="bi {{schedulerIcon}} me-1"></i>{{schedulerStatusText}}\n </span>\n </small>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="d-flex justify-content-end">\n <div class="btn-group">\n <button class="btn btn-sm btn-outline-primary" data-action="refresh-health">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <button class="btn btn-sm btn-outline-secondary" data-action="system-settings">\n <i class="bi bi-gear"></i> Settings\n </button>\n </div>\n </div>\n </div>\n </div>\n {{#health.channelsArray.length}}\n <div class="row mt-3">\n <div class="col-12">\n <small class="text-muted d-block mb-2">Channel Status:</small>\n <div class="d-flex flex-wrap gap-2">\n {{#health.channelsArray}}\n <span class="badge {{statusBadgeClass}} d-flex align-items-center">\n <i class="bi {{statusIcon}} me-1"></i>\n {{channel}} ({{queued}} queued, {{inflight}} inflight)\n </span>\n {{/health.channelsArray}}\n </div>\n </div>\n </div>\n {{/health.channelsArray.length}}\n </div>\n </div>\n </div>\n '}_onModelChange(){this.loadHealth(),this.isMounted()&&this.render()}async loadHealth(){if(!this.model._.totals)return;const e=this.model.attributes;let t="healthy";0===e.totals.runners_active?t="critical":e.scheduler.active||(t="warning"),this.health={overall_status:t,channels:e.channels,runners:{active:e.totals.runners_active,total:e.runners.length},totals:e.totals,scheduler:e.scheduler},this.healthStatusClass=this.getHealthStatusClass(this.health.overall_status),this.setupChannelDisplay(),this.setupSchedulerDisplay()}setupChannelDisplay(){this.health.channels&&(this.health.channelsArray=Object.values(this.health.channels).map(e=>{let t="healthy";const s=(e.queued_count||0)+(e.inflight_count||0);return s>50&&(t="warning"),(s>100||0===e.runners)&&(t="critical"),{...e,status:t,statusBadgeClass:this.getChannelBadgeClass(t),statusIcon:this.getChannelIcon(t),queued:e.queued_count||0,inflight:e.inflight_count||0,pending:e.queued_count||0,running:e.inflight_count||0}}))}setupSchedulerDisplay(){if(!this.health.scheduler)return this.schedulerStatusText="Unknown",this.schedulerStatusClass="text-muted",void(this.schedulerIcon="bi-question-circle-fill");this.health.scheduler.active?(this.schedulerStatusText="Running",this.schedulerStatusClass="text-success",this.schedulerIcon="bi-check-circle-fill"):(this.schedulerStatusText="Stopped",this.schedulerStatusClass="text-danger",this.schedulerIcon="bi-x-octagon-fill")}getHealthStatusClass(e){return{healthy:"text-success",warning:"text-warning",critical:"text-danger",unknown:"text-muted"}[e]||"text-muted"}getChannelBadgeClass(e){return{healthy:"bg-success",warning:"bg-warning",critical:"bg-danger"}[e]||"bg-secondary"}getChannelIcon(e){return{healthy:"bi-check-circle-fill",warning:"bi-exclamation-triangle-fill",critical:"bi-x-octagon-fill"}[e]||"bi-dash-circle-fill"}async onActionRefreshHealth(e,t){try{t.disabled=!0,await this.model.fetch()}catch(s){console.error("Failed to refresh health:",s)}finally{t.disabled=!1}}async onActionSystemSettings(){await s.Dialog.showAlert({title:"System Settings",message:"System settings interface coming soon!",type:"info"})}}class JobOverviewSection extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div class="row mb-4 g-3 align-items-stretch">\n <div class="col-lg-6" data-container="jobs-published-chart"></div>\n <div class="col-lg-6" data-container="jobs-failed-chart"></div>\n </div>\n <div data-container="job-health"></div>\n ',...e})}async onInit(){this.jobsPublishedChart=new i.MetricsMiniChartWidget({containerId:"jobs-published-chart",icon:"bi bi-upload",title:"Jobs Published",subtitle:"{{now_value}} {{now_label}}",granularity:"days",slugs:["jobs.published"],account:"global",chartType:"line",height:90,showSettings:!0,showTrending:!0,showDateRange:!1}),this.addChild(this.jobsPublishedChart),this.jobsFailedChart=new i.MetricsMiniChartWidget({containerId:"jobs-failed-chart",icon:"bi bi-exclamation-octagon",title:"Jobs Failed",subtitle:"{{now_value}} {{now_label}}",granularity:"days",slugs:["jobs.failed"],account:"global",chartType:"line",height:90,showSettings:!0,showTrending:!0,showDateRange:!1}),this.addChild(this.jobsFailedChart),this.jobHealthView=new JobHealthView({containerId:"job-health",model:this.options.model}),this.addChild(this.jobHealthView)}}class JobOperationsSection extends t.View{constructor(e={}){super({className:"job-operations-section",template:'\n <div class="card shadow-sm">\n <div class="card-header d-flex justify-content-between align-items-center">\n <h5 class="mb-0"><i class="bi bi-tools me-2"></i>Operations</h5>\n </div>\n <div class="card-body">\n <div class="d-flex flex-wrap gap-2">\n <button class="btn btn-outline-primary" data-action="run-simple-job">\n <i class="bi bi-play-circle me-2"></i>Run Simple Job\n </button>\n <button class="btn btn-outline-primary" data-action="run-test-jobs">\n <i class="bi bi-robot me-2"></i>Run Test Jobs\n </button>\n <button class="btn btn-outline-warning" data-action="clear-stuck">\n <i class="bi bi-wrench me-2"></i>Clear Stuck\n </button>\n <button class="btn btn-outline-warning" data-action="clear-channel">\n <i class="bi bi-eraser me-2"></i>Clear Channel\n </button>\n <button class="btn btn-outline-danger" data-action="purge-jobs">\n <i class="bi bi-trash me-2"></i>Purge Jobs\n </button>\n <button class="btn btn-outline-info" data-action="cleanup-consumers">\n <i class="bi bi-people me-2"></i>Cleanup Consumers\n </button>\n <button class="btn btn-outline-secondary" data-action="runner-broadcast">\n <i class="bi bi-wifi me-2"></i>Broadcast Command\n </button>\n </div>\n </div>\n </div>\n ',...e})}async onActionRunSimpleJob(e,t){await s.Dialog.showConfirm({title:"Run Simple Job",message:"This will run a simple test job to verify the job system is working correctly.",confirmText:"Run Test",confirmClass:"btn-success"})&&await this.executeJobAction(t,()=>a.Job.test(),"Test job started successfully")}async onActionRunTestJobs(e,t){await s.Dialog.showConfirm({title:"Run Test Jobs",message:"This will run a suite of test jobs to verify all job functionalities.",confirmText:"Run Tests",confirmClass:"btn-success"})&&await this.executeJobAction(t,()=>a.Job.tests(),"Test suite started successfully")}async onActionClearStuck(e,t){const i=[{value:"",label:"All Channels"},...(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel}))],n=await s.Dialog.showForm({title:"Clear Stuck Jobs",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,value:"",help:"Select specific channel or leave empty for all channels"}]}});n&&await this.executeJobAction(t,()=>a.Job.clearStuck(n.channel||null),e=>{const t=e.data.count||0;return`Cleared ${t} stuck job${1!==t?"s":""}${n.channel?` from channel "${n.channel}"`:""}`})}async onActionClearChannel(e,t){const i=(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel})),n=await s.Dialog.showForm({title:"Clear Channel",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,required:!0,help:"Select the channel to clear."}]}});n&&await this.executeJobAction(t,()=>a.Job.clearChannel(n.channel),`Channel "${n.channel}" cleared successfully.`)}async onActionPurgeJobs(e,t){const i=await s.Dialog.showForm({title:"Purge Old Jobs",formConfig:{fields:[{name:"days_old",type:"number",label:"Days Old",value:30,required:!0,help:"Delete jobs older than this many days."}]}});i&&await this.executeJobAction(t,()=>a.Job.purgeJobs(i.days_old),e=>`Purged ${e.data.count||0} old job(s).`)}async onActionCleanupConsumers(e,t){await s.Dialog.showConfirm({title:"Cleanup Consumers",message:"This will remove stale consumer records from the system. This is generally safe.",confirmText:"Cleanup",confirmClass:"btn-warning"})&&await this.executeJobAction(t,()=>a.Job.cleanConsumers(),e=>`Cleaned up ${e.data.count||0} consumer(s).`)}async onActionRunnerBroadcast(){const e=await s.Dialog.showForm({title:"Broadcast Command to All Runners",formConfig:a.JobRunnerForms.broadcast});if(e)try{const t=await a.JobRunner.broadcast(e.command,{},e.timeout);t.success?this.getApp().toast.success(`Broadcast command "${e.command}" sent successfully`):this.getApp().toast.error(t.data?.error||"Failed to broadcast command")}catch(t){console.error("Failed to broadcast command:",t),this.getApp().toast.error("Error broadcasting command: "+t.message)}}async executeJobAction(e,t,s){try{e.disabled=!0;const i=e.querySelector("i");i?.classList.add("spinning");const a=await t();if(a.success&&a.data?.status){const e="function"==typeof s?s(a):s;this.getApp().toast.success(e)}else this.getApp().toast.error(a.data?.error||"Operation failed")}catch(i){console.error("Job action failed:",i),this.getApp().toast.error("Error: "+i.message)}finally{e.disabled=!1;const t=e.querySelector("i");t?.classList.remove("spinning")}}}class JobDashboardPage extends e.Page{constructor(e={}){super({title:"Job Engine Dashboard",pageName:"Job Dashboard",className:"job-dashboard-page",...e}),this.pageSubtitle="Async job monitoring and runner management",this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.autoRefreshInterval=null,this.refreshRate=3e4,this.template='\n <div class="job-dashboard-container">\n \x3c!-- Page Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n <small class="text-info">\n <i class="bi bi-arrow-clockwise me-1"></i>\n Auto-refresh: {{refreshRateLabel}} | Last updated: {{lastUpdated}}\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all" title="Refresh All Data">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <div class="dropdown">\n <button class="btn btn-outline-secondary btn-sm dropdown-toggle"\n type="button" data-bs-toggle="dropdown">\n <i class="bi bi-gear"></i> Settings\n </button>\n <ul class="dropdown-menu dropdown-menu-end">\n <li><h6 class="dropdown-header">Auto Refresh</h6></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="5">5 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="10">10 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="30">30 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="0">Off</button></li>\n </ul>\n </div>\n </div>\n </div>\n\n \x3c!-- Stats Cards --\x3e\n <div data-container="job-stats"></div>\n\n \x3c!-- Charts + Health --\x3e\n <div data-container="job-overview"></div>\n\n \x3c!-- Operations --\x3e\n <div class="mt-4" data-container="job-operations"></div>\n </div>\n '}async onInit(){this.getApp()?.showLoading("Loading Job Engine...");try{this.jobStats=new a.JobsEngineStats,this.jobStatsView=new JobStatsView({containerId:"job-stats",model:this.jobStats}),this.addChild(this.jobStatsView),this.overviewSection=new JobOverviewSection({containerId:"job-overview",model:this.jobStats}),this.addChild(this.overviewSection),this.operationsSection=new JobOperationsSection({containerId:"job-operations",getChannels:()=>{const e=this.jobStats?.attributes;return e?.channels?Object.values(e.channels):[]}}),this.addChild(this.operationsSection),await this.jobStats.fetch()}finally{this.getApp()?.hideLoading()}}startAutoRefresh(){this.autoRefreshInterval&&clearInterval(this.autoRefreshInterval),this.refreshRate>0&&(this.autoRefreshInterval=setInterval(()=>this.refreshData(),this.refreshRate))}async refreshData(){try{await this.jobStats.fetch(),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.updateHeaderTimestamp()}catch(e){console.error("Failed to refresh jobs dashboard:",e)}}updateHeaderTimestamp(){const e=this.element?.querySelector(".text-info");e&&(e.innerHTML=`\n <i class="bi bi-arrow-clockwise me-1"></i>\n Auto-refresh: ${this.refreshRateLabel} | Last updated: ${this.lastUpdated}\n `)}get refreshRateLabel(){return 0===this.refreshRate?"Off":this.refreshRate/1e3+"s"}async onActionRefreshAll(e,t){try{const e=t.querySelector("i");e?.classList.add("spinning"),t.disabled=!0,await this.refreshData()}finally{const e=t.querySelector("i");e?.classList.remove("spinning"),t.disabled=!1}}async onActionSetRefreshRate(e,t){const s=1e3*parseInt(t.getAttribute("data-rate"));this.refreshRate=s,this.startAutoRefresh(),this.updateHeaderTimestamp();const i=0===s?"Off":s/1e3+"s";this.getApp().toast.success(`Auto-refresh set to ${i}`)}async onEnter(){this.startAutoRefresh()}async onExit(){this.autoRefreshInterval&&(clearInterval(this.autoRefreshInterval),this.autoRefreshInterval=null)}}function T(e){return null==e?"N/A":e>=1e9?(e/1e9).toFixed(2)+" GB":e>=1e6?(e/1e6).toFixed(2)+" MB":e>=1e3?(e/1e3).toFixed(2)+" KB":e+" B"}function D(e){return e>=80?"bg-danger-subtle text-danger":e>=60?"bg-warning-subtle text-warning":"bg-success-subtle text-success"}function V(e){return e>=80?"bg-danger":e>=60?"bg-warning":"bg-success"}class RunnerOverviewTab extends t.View{constructor(e={}){super({className:"runner-overview-tab",...e}),this.model=e.model||null}async onBeforeRender(){const e=this.model;if(!e)return;this.aliveBadgeClass=e.get("alive")?"bg-success-subtle text-success":"bg-danger-subtle text-danger",this.aliveIcon=e.get("alive")?"bi-check-circle-fill":"bi-x-circle-fill",this.aliveText=e.get("alive")?"Alive":"Dead";const t=e.get("started");if(this.startedText=t?new Date(t).toLocaleString():"N/A",t){const e=(Date.now()-new Date(t).getTime())/1e3;this.uptimeText=function(e){const t=Math.floor(e/86400),s=Math.floor(e%86400/3600),i=Math.floor(e%3600/60);return t>0?`${t}d ${s}h ${i}m`:s>0?`${s}h ${i}m`:`${i}m`}(e)}else this.uptimeText="N/A";const s=(i=e.get("last_heartbeat"))?(Date.now()-new Date(i).getTime())/1e3:null;var i;null!==s?(this.heartbeatText=new Date(e.get("last_heartbeat")).toLocaleString(),this.heartbeatAgeText=`${Math.round(s)}s ago`,this.heartbeatClass=s<30?"text-success":s<120?"text-warning":"text-danger"):(this.heartbeatText="N/A",this.heartbeatAgeText="",this.heartbeatClass="text-muted");const a=e.get("jobs_processed")||0,n=e.get("jobs_failed")||0;this.errorRate=a>0?(n/a*100).toFixed(2)+"%":"0.00%"}async getTemplate(){return'\n {{^model}}\n <div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>No runner data available.</div>\n {{/model}}\n\n {{#model}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold">\n <i class="bi bi-info-circle text-primary me-2"></i>Identity\n </h6>\n <span class="badge {{aliveBadgeClass}}">\n <i class="bi {{aliveIcon}} me-1"></i>{{aliveText}}\n </span>\n </div>\n <div class="card-body">\n <div class="row g-3">\n <div class="col-md-6">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase pe-3" style="width:38%;white-space:nowrap;font-size:0.72rem;">Runner ID</td>\n <td class="font-monospace small">{{model.runner_id}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Started</td>\n <td class="small">{{startedText}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Uptime</td>\n <td class="small fw-semibold">{{uptimeText}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Heartbeat</td>\n <td class="small {{heartbeatClass}}">\n {{heartbeatText}}\n {{#heartbeatAgeText}}\n <span class="text-muted">({{heartbeatAgeText}})</span>\n {{/heartbeatAgeText}}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n <div class="col-md-6">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase pe-3" style="width:38%;white-space:nowrap;font-size:0.72rem;">Jobs Done</td>\n <td class="small fw-bold text-success">{{model.jobs_processed|number}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Jobs Failed</td>\n <td class="small fw-bold text-danger">{{model.jobs_failed|number}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Error Rate</td>\n <td class="small">{{errorRate}}</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n {{#model.channels.length}}\n <div class="mt-3 pt-3 border-top">\n <div class="text-muted small fw-semibold text-uppercase mb-2" style="font-size:0.72rem;">Assigned Channels</div>\n <div class="d-flex flex-wrap gap-2">\n {{#model.channels}}\n <span class="badge bg-primary-subtle text-primary px-3 py-2">\n <i class="bi bi-circle-fill me-1" style="font-size:0.4rem;vertical-align:middle;"></i>{{.}}\n </span>\n {{/model.channels}}\n </div>\n </div>\n {{/model.channels.length}}\n </div>\n </div>\n\n <div class="alert alert-light border d-flex align-items-center gap-2 mb-0" style="font-size:0.83rem;">\n <i class="bi bi-cpu text-primary flex-shrink-0"></i>\n <span>CPU, memory, disk, and network detail is in the <strong>System Info</strong> tab.</span>\n </div>\n {{/model}}\n '}}class RunnerSysinfoTab extends t.View{constructor(e={}){super({className:"runner-sysinfo-tab",...e}),this.model=e.model||null,this.sysinfo=null,this.sysinfoError=null,this.loading=!1,this.loaded=!1}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,this.sysinfoError=null,await this.render(),await this.loadSysinfo(),this.loading=!1,await this.render())}async loadSysinfo(){try{const e=await this.getApp().rest.GET(`/api/jobs/runners/sysinfo/${this.model.get("runner_id")}`);if(e.success&&e.data){const t=e.data.data||e.data;if(t&&"error"===t.status)return void(this.sysinfoError=t.error||"Runner reported an error collecting sysinfo.");if(!e.data.status)return void(this.sysinfoError=e.data.error||"Could not load system info.");this.sysinfo=t.result||t,this.enrichSysinfo()}else this.sysinfoError="Could not load system info."}catch(e){this.sysinfoError=e.message||"Request failed."}}enrichSysinfo(){const e=this.sysinfo;if(!e)return;e.memory&&(e.memory.totalFmt=T(e.memory.total),e.memory.usedFmt=T(e.memory.used),e.memory.availableFmt=T(e.memory.available),e.memory.barClass=V(e.memory.percent),e.memory.badgeClass=D(e.memory.percent)),e.disk&&(e.disk.totalFmt=T(e.disk.total),e.disk.usedFmt=T(e.disk.used),e.disk.freeFmt=T(e.disk.free),e.disk.barClass=V(e.disk.percent),e.disk.badgeClass=D(e.disk.percent)),e.network&&(e.network.bytesRecvFmt=T(e.network.bytes_recv),e.network.bytesSentFmt=T(e.network.bytes_sent),e.network.errClass=e.network.errin>0||e.network.errout>0?"text-danger fw-bold":"text-success",e.network.dropClass=e.network.dropin>0||e.network.dropout>0?"text-warning fw-bold":"text-success");const t=e.cpu_load||0;e.cpuLoadBarClass=V(t),e.cpuLoadBadgeClass=D(t),e.cpu&&e.cpu.freq?e.cpu.freqText=`${Math.round(e.cpu.freq.current).toLocaleString()} MHz current · ${Math.round(e.cpu.freq.max).toLocaleString()} MHz max`:e.cpu&&(e.cpu.freqText=null),e.cpus_load&&e.cpus_load.length?e.cpuCores=e.cpus_load.map((e,t)=>({index:t,pct:e.toFixed(1),barClass:V(e)})):e.cpuCores=[],e.bootDatetime=e.boot_time?new Date(1e3*e.boot_time).toLocaleString():null,e.collectedText=e.datetime||null}async onActionRefreshSysinfo(){this.loaded=!1,this.sysinfo=null,this.sysinfoError=null,await this.onTabActivated()}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading system info…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n\n {{#sysinfoError|bool}}\n <div class="alert alert-warning d-flex align-items-start gap-2">\n <i class="bi bi-exclamation-triangle flex-shrink-0 mt-1"></i>\n <div>\n <strong>Could not load system info</strong><br>\n <span class="small">{{sysinfoError}}</span><br>\n <button class="btn btn-sm btn-outline-warning mt-2" data-action="refresh-sysinfo">\n <i class="bi bi-arrow-clockwise me-1"></i>Retry\n </button>\n </div>\n </div>\n {{/sysinfoError|bool}}\n\n {{#sysinfo|bool}}\n\n <div class="d-flex justify-content-end align-items-center gap-3 mb-3">\n {{#sysinfo.collectedText}}\n <small class="text-muted">Collected {{sysinfo.collectedText}}</small>\n {{/sysinfo.collectedText}}\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-sysinfo">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n \x3c!-- OS --\x3e\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-hdd-rack text-primary me-2"></i>Operating System</h6>\n </div>\n <div class="card-body p-0">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n {{#sysinfo.os}}\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="width:20%;font-size:0.72rem;white-space:nowrap;">Hostname</td>\n <td class="font-monospace small">{{.hostname}}</td>\n <td class="text-muted small fw-semibold text-uppercase pe-2" style="width:15%;font-size:0.72rem;white-space:nowrap;">OS</td>\n <td class="small">{{.system}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Release</td>\n <td class="font-monospace small">{{.release}}</td>\n <td class="text-muted small fw-semibold text-uppercase pe-2" style="font-size:0.72rem;">Machine</td>\n <td class="font-monospace small">{{.machine}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Version</td>\n <td colspan="3" class="font-monospace small text-muted" style="font-size:0.76rem;">{{.version}}</td>\n </tr>\n {{/sysinfo.os}}\n {{#sysinfo.bootDatetime}}\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Boot Time</td>\n <td colspan="3" class="small">{{sysinfo.bootDatetime}}</td>\n </tr>\n {{/sysinfo.bootDatetime}}\n </tbody>\n </table>\n </div>\n </div>\n\n \x3c!-- CPU --\x3e\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-cpu text-primary me-2"></i>CPU</h6>\n <span class="badge {{sysinfo.cpuLoadBadgeClass}}">{{sysinfo.cpu_load}}% overall</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">Overall Load</small>\n <small class="fw-bold">{{sysinfo.cpu_load}}%</small>\n </div>\n <div class="progress mb-2" style="height:8px;">\n <div class="progress-bar {{sysinfo.cpuLoadBarClass}}" style="width:{{sysinfo.cpu_load}}%;"></div>\n </div>\n {{#sysinfo.cpu}}\n <div class="text-muted small mb-3">\n {{.count}} logical cores\n {{#.freqText}} · {{.freqText}}{{/.freqText}}\n </div>\n {{/sysinfo.cpu}}\n\n {{#sysinfo.cpuCores.length}}\n <div class="row g-2">\n {{#sysinfo.cpuCores}}\n <div class="col-6 col-md-3">\n <div class="border rounded p-2 bg-light text-center">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.65rem;">Core {{.index}}</div>\n <div class="fw-bold small">{{.pct}}%</div>\n <div class="progress mt-1" style="height:4px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.pct}}%;"></div>\n </div>\n </div>\n </div>\n {{/sysinfo.cpuCores}}\n </div>\n {{/sysinfo.cpuCores.length}}\n </div>\n </div>\n\n \x3c!-- Memory --\x3e\n {{#sysinfo.memory}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-memory text-primary me-2"></i>Memory</h6>\n <span class="badge {{.badgeClass}}">{{.percent}}% used</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">RAM Usage</small>\n <small class="fw-bold">{{.usedFmt}} / {{.totalFmt}}</small>\n </div>\n <div class="progress mb-3" style="height:8px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.percent}}%;"></div>\n </div>\n <div class="row g-2 text-center">\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Total</div>\n <div class="fw-semibold small">{{.totalFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Used</div>\n <div class="fw-semibold small">{{.usedFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Available</div>\n <div class="fw-semibold small text-success">{{.availableFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Percent</div>\n <div class="fw-semibold small">{{.percent}}%</div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.memory}}\n\n \x3c!-- Disk --\x3e\n {{#sysinfo.disk}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-hdd text-primary me-2"></i>Disk (Root)</h6>\n <span class="badge {{.badgeClass}}">{{.percent}}% used</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">Disk Usage</small>\n <small class="fw-bold">{{.usedFmt}} / {{.totalFmt}}</small>\n </div>\n <div class="progress mb-3" style="height:8px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.percent}}%;"></div>\n </div>\n <div class="row g-2 text-center">\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Total</div>\n <div class="fw-semibold small">{{.totalFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Used</div>\n <div class="fw-semibold small">{{.usedFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Free</div>\n <div class="fw-semibold small text-success">{{.freeFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Percent</div>\n <div class="fw-semibold small">{{.percent}}%</div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.disk}}\n\n \x3c!-- Network --\x3e\n {{#sysinfo.network}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-diagram-3 text-primary me-2"></i>Network</h6>\n </div>\n <div class="card-body">\n <div class="row g-2">\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-down text-primary me-1"></i>Bytes Recv</div>\n <div class="fw-bold font-monospace small">{{.bytesRecvFmt}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-up text-primary me-1"></i>Bytes Sent</div>\n <div class="fw-bold font-monospace small">{{.bytesSentFmt}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-share text-primary me-1"></i>TCP Connections</div>\n <div class="fw-bold font-monospace small">{{.tcp_cons|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-down me-1"></i>Packets Recv</div>\n <div class="fw-bold font-monospace small">{{.packets_recv|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-up me-1"></i>Packets Sent</div>\n <div class="fw-bold font-monospace small">{{.packets_sent|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-exclamation-triangle me-1"></i>Errors In / Out</div>\n <div class="fw-bold font-monospace small {{.errClass}}">{{.errin}} / {{.errout}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-x-circle me-1"></i>Drops In</div>\n <div class="fw-bold font-monospace small {{.dropClass}}">{{.dropin}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-x-circle me-1"></i>Drops Out</div>\n <div class="fw-bold font-monospace small {{.dropClass}}">{{.dropout}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.network}}\n\n \x3c!-- Logged-in Users --\x3e\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-person-badge text-primary me-2"></i>Logged-in Users</h6>\n </div>\n {{#sysinfo.users.length}}\n <ul class="list-group list-group-flush">\n {{#sysinfo.users}}\n <li class="list-group-item font-monospace small">{{.name|default:\'unknown\'}}\n {{#.terminal}}<span class="text-muted ms-2">{{.terminal}}</span>{{/.terminal}}\n </li>\n {{/sysinfo.users}}\n </ul>\n {{/sysinfo.users.length}}\n {{^sysinfo.users.length}}\n <div class="card-body text-center text-muted py-4">\n <i class="bi bi-person-x fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No users currently logged in</div>\n </div>\n {{/sysinfo.users.length}}\n </div>\n\n {{/sysinfo|bool}}\n {{/loading|bool}}\n '}}class RunnerJobsTab extends t.View{constructor(e={}){super({className:"runner-jobs-tab",...e}),this.model=e.model||null,this.jobs=[],this.loading=!1,this.loaded=!1}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,await this.render(),await this.loadJobs(),this.loading=!1,await this.render())}async loadJobs(){try{const e=await this.getApp().rest.GET(`/api/jobs/job?runner_id=${this.model.get("runner_id")}&status=running&size=50`);if(e.success&&e.data&&e.data.status){const t=Date.now()/1e3;this.jobs=(e.data.data||[]).map(e=>{return{...e,durationText:e.started_at?(s=t-new Date(e.started_at).getTime()/1e3,s<60?`${Math.round(s)}s`:s<3600?`${Math.round(s/60)}m ${Math.round(s%60)}s`:`${Math.round(s/3600)}h ${Math.round(s%3600/60)}m`):"N/A",startedText:e.started_at?new Date(e.started_at).toLocaleTimeString():"N/A",attemptBadgeClass:e.attempt>1?"bg-danger-subtle text-danger":"bg-warning-subtle text-warning"};var s})}else this.jobs=[]}catch(e){this.jobs=[],this.showError("Could not load running jobs: "+e.message)}}async onActionRefreshJobs(){this.loaded=!1,this.jobs=[],await this.onTabActivated()}async onActionViewJob(e,t){const s=t.dataset.jobId;this.emit("job:view",{jobId:s,runner:this.model})}async onActionCancelJob(e,t){const i=t.dataset.jobId;if(await s.Dialog.confirm("Cancel this job? The runner will receive a cooperative cancel signal.","Cancel Job",{confirmText:"Cancel Job",confirmClass:"btn-warning"}))try{const e=await this.getApp().rest.POST(`/api/jobs/job/${i}`,{cancel_request:!0});e.success&&e.data&&e.data.status?(this.showSuccess("Cancel signal sent."),this.loaded=!1,await this.onTabActivated()):this.showError(e.data&&e.data.error||"Could not cancel job.")}catch(a){this.showError("Could not cancel job: "+a.message)}}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading running jobs…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n <div class="d-flex justify-content-between align-items-center mb-3">\n <small class="text-muted">{{jobs.length}} job(s) currently executing on this runner</small>\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-jobs">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n {{#jobs.length}}\n <div class="card border-0 shadow-sm">\n <div class="table-responsive">\n <table class="table table-sm table-hover align-middle mb-0">\n <thead class="table-light">\n <tr>\n <th class="ps-3 border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Job ID</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Function</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Channel</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Started</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Duration</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Attempt</th>\n <th class="border-0 text-end pe-3 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Actions</th>\n </tr>\n </thead>\n <tbody>\n {{#jobs}}\n <tr>\n <td class="ps-3">\n <span class="font-monospace text-primary small" title="{{.id}}">{{.id|truncate:12}}</span>\n </td>\n <td>\n <span class="font-monospace text-muted small" title="{{.func}}">{{.func|truncate:42}}</span>\n </td>\n <td>\n <span class="badge bg-primary-subtle text-primary">{{.channel}}</span>\n </td>\n <td><small class="text-muted">{{.startedText}}</small></td>\n <td><span class="badge bg-light text-secondary border">{{.durationText}}</span></td>\n <td><span class="badge {{.attemptBadgeClass}}">{{.attempt}}</span></td>\n <td class="text-end pe-3">\n <div class="btn-group btn-group-sm">\n <button class="btn btn-outline-primary btn-sm" data-action="view-job"\n data-job-id="{{.id}}" title="View job details">\n <i class="bi bi-eye"></i>\n </button>\n <button class="btn btn-outline-warning btn-sm" data-action="cancel-job"\n data-job-id="{{.id}}" title="Cancel job">\n <i class="bi bi-x-circle"></i>\n </button>\n </div>\n </td>\n </tr>\n {{/jobs}}\n </tbody>\n </table>\n </div>\n </div>\n {{/jobs.length}}\n\n {{^jobs.length}}\n <div class="text-center text-muted py-5">\n <i class="bi bi-list-task fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No jobs currently executing on this runner</div>\n </div>\n {{/jobs.length}}\n {{/loading|bool}}\n '}}class RunnerLogsTab extends t.View{constructor(e={}){super({className:"runner-logs-tab",...e}),this.model=e.model||null,this.logs=[],this.filteredLogs=[],this.logFilter="all",this.loading=!1,this.loaded=!1,this.filterAllClass="btn-primary",this.filterDebugClass="btn-outline-secondary",this.filterInfoClass="btn-outline-primary",this.filterWarnClass="btn-outline-warning",this.filterErrorClass="btn-outline-danger"}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,await this.render(),await this.loadLogs(),this.loading=!1,await this.render())}async loadLogs(){try{const e=await this.getApp().rest.GET(`/api/jobs/job?runner_id=${this.model.get("runner_id")}&status=running&size=50`),t=[];if(e.success&&e.data&&e.data.status&&(e.data.data||[]).forEach(e=>t.push(e.id)),!t.length)return void(this.logs=[]);const s=t.slice(0,5).map(e=>this.getApp().rest.GET(`/api/jobs/logs?job_id=${e}&sort=-created&size=20`).then(e=>e.success&&e.data&&e.data.status&&e.data.data||[]).catch(()=>[])),i=await Promise.all(s),a=[].concat(...i);a.sort((e,t)=>new Date(t.created)-new Date(e.created)),this.logs=a.slice(0,50).map(e=>({...e,levelBadgeClass:this.getLogLevelClass(e.kind),kindDisplay:(e.kind||"info").toUpperCase(),createdText:new Date(e.created).toLocaleTimeString()}))}catch(e){this.logs=[],this.showError("Could not load logs: "+e.message)}}getLogLevelClass(e){return{debug:"bg-secondary-subtle text-secondary",info:"bg-primary-subtle text-primary",warn:"bg-warning-subtle text-warning",error:"bg-danger-subtle text-danger"}[e]||"bg-secondary-subtle text-secondary"}async onBeforeRender(){this.filteredLogs="all"===this.logFilter?this.logs:this.logs.filter(e=>e.kind===this.logFilter),this.filterAllClass="all"===this.logFilter?"btn-primary":"btn-outline-secondary",this.filterDebugClass="debug"===this.logFilter?"btn-secondary":"btn-outline-secondary",this.filterInfoClass="info"===this.logFilter?"btn-primary":"btn-outline-primary",this.filterWarnClass="warn"===this.logFilter?"btn-warning":"btn-outline-warning",this.filterErrorClass="error"===this.logFilter?"btn-danger":"btn-outline-danger"}async onActionFilterLogs(e,t){this.logFilter=t.dataset.kind||"all",await this.render()}async onActionRefreshLogs(){this.loaded=!1,this.logs=[],this.logFilter="all",await this.onTabActivated()}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading logs…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-white border-bottom py-2 d-flex align-items-center gap-2 flex-wrap">\n <small class="text-muted fw-semibold me-1">Filter:</small>\n <button class="btn btn-sm {{filterAllClass}}" data-action="filter-logs" data-kind="all">All</button>\n <button class="btn btn-sm {{filterDebugClass}}" data-action="filter-logs" data-kind="debug">Debug</button>\n <button class="btn btn-sm {{filterInfoClass}}" data-action="filter-logs" data-kind="info">Info</button>\n <button class="btn btn-sm {{filterWarnClass}}" data-action="filter-logs" data-kind="warn">Warning</button>\n <button class="btn btn-sm {{filterErrorClass}}" data-action="filter-logs" data-kind="error">Error</button>\n <div class="ms-auto d-flex align-items-center gap-2">\n <small class="text-muted">{{filteredLogs.length}} entries</small>\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-logs">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n </div>\n\n <div style="max-height:420px;overflow-y:auto;">\n {{#filteredLogs.length}}\n {{#filteredLogs}}\n <div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom font-monospace" style="font-size:0.78rem;">\n <span class="text-muted flex-shrink-0 pt-1" style="min-width:65px;">{{.createdText}}</span>\n <span class="badge {{.levelBadgeClass}} flex-shrink-0" style="margin-top:1px;">{{.kindDisplay}}</span>\n <span class="flex-grow-1 text-break">{{.message}}</span>\n </div>\n {{/filteredLogs}}\n {{/filteredLogs.length}}\n\n {{^filteredLogs.length}}\n <div class="text-center text-muted py-5">\n <i class="bi bi-journal fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No log entries</div>\n </div>\n {{/filteredLogs.length}}\n </div>\n </div>\n {{/loading|bool}}\n '}}class RunnerActionsTab extends t.View{constructor(e={}){super({className:"runner-actions-tab",...e}),this.model=e.model||null,this.pingResult=null}async onActionPing(){this.pingResult=null,await this.render();try{const e=await this.getApp().rest.POST("/api/jobs/runners/ping",{runner_id:this.model.get("runner_id"),timeout:2});e.success&&e.data?this.pingResult=e.data.responsive?'<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Runner is responsive</span>':'<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>Runner did not respond within 2s</span>':this.pingResult='<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Ping request failed</span>'}catch(e){this.pingResult=`<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`}await this.render()}async onActionShutdown(){if(await s.Dialog.confirm(`Send a graceful shutdown to <strong class="font-monospace">${this.model.get("runner_id")}</strong>?<br><br>The runner will finish its current job then exit. This is fire-and-forget.`,"Shutdown Runner",{confirmText:"Shutdown",confirmClass:"btn-danger"}))try{const e=await this.getApp().rest.POST("/api/jobs/runners/shutdown",{runner_id:this.model.get("runner_id"),graceful:!0});e.success&&e.data&&e.data.status?(this.showSuccess("Shutdown command sent to runner."),this.emit("runner:shutdown",{runner:this.model})):this.showError(e.data&&e.data.error||"Shutdown command failed.")}catch(e){this.showError("Shutdown failed: "+e.message)}}async onActionBroadcast(){const e=this.element&&this.element.querySelector('[data-field="broadcast-command"]'),t=this.element&&this.element.querySelector('[data-field="broadcast-timeout"]'),i=e?e.value:"status",a=t&&parseFloat(t.value)||2;s.Dialog.showBusy({message:`Broadcasting "${i}" to all runners…`});try{const e=await this.getApp().rest.POST("/api/jobs/runners/broadcast",{command:i,timeout:a});s.Dialog.hideBusy(),e.success&&e.data?await s.Dialog.showCode(JSON.stringify(e.data,null,2),"json",{title:`Broadcast Response — ${i}`,size:"lg"}):this.showError(e.data&&e.data.error||"Broadcast failed.")}catch(n){s.Dialog.hideBusy(),this.showError("Broadcast failed: "+n.message)}}async onActionExport(){try{const e={runner:this.model.toJSON?this.model.toJSON():this.model,exported_at:/* @__PURE__ */(new Date).toISOString()},t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),s=URL.createObjectURL(t),i=Object.assign(document.createElement("a"),{href:s,download:`runner-${this.model.get("runner_id")}-${Date.now()}.json`});document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(s),this.showSuccess("Runner data exported.")}catch(e){this.showError("Export failed: "+e.message)}}async getTemplate(){return'\n <p class="text-muted small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Actions operate on runner <strong class="font-monospace">{{model.runner_id}}</strong> unless otherwise noted.\n </p>\n\n <div class="d-flex align-items-center gap-2 mb-3">\n <span class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;letter-spacing:0.09em;white-space:nowrap;">Runner Control</span>\n <hr class="flex-grow-1 my-0">\n </div>\n\n <div class="row g-3 mb-4">\n\n \x3c!-- Ping --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-success-subtle text-success flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-broadcast-pin"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Ping Runner</div>\n <div class="text-muted small">Verify this runner is truly responsive, not just alive on paper.</div>\n </div>\n </div>\n {{#pingResult|bool}}\n <div class="small">{{{pingResult}}}</div>\n {{/pingResult|bool}}\n <button class="btn btn-sm btn-outline-success mt-auto" data-action="ping">\n <i class="bi bi-broadcast-pin me-1"></i>Ping Now\n </button>\n </div>\n </div>\n </div>\n\n \x3c!-- Shutdown --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-danger-subtle text-danger flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-power"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Graceful Shutdown</div>\n <div class="text-muted small">Runner finishes its current job then exits. Fire-and-forget.</div>\n </div>\n </div>\n <button class="btn btn-sm btn-outline-danger mt-auto" data-action="shutdown">\n <i class="bi bi-power me-1"></i>Shutdown\n </button>\n </div>\n </div>\n </div>\n\n \x3c!-- Export --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-secondary-subtle text-secondary flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-download"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Export Snapshot</div>\n <div class="text-muted small">Download runner identity data as a JSON file.</div>\n </div>\n </div>\n <button class="btn btn-sm btn-outline-secondary mt-auto" data-action="export">\n <i class="bi bi-download me-1"></i>Export JSON\n </button>\n </div>\n </div>\n </div>\n\n </div>\n\n <div class="d-flex align-items-center gap-2 mb-3">\n <span class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;letter-spacing:0.09em;white-space:nowrap;">Broadcast Command</span>\n <hr class="flex-grow-1 my-0">\n </div>\n\n <div class="card border-0 shadow-sm">\n <div class="card-body">\n <p class="text-muted small mb-3">\n Send a command to <strong>all active runners</strong> simultaneously and collect replies within the timeout window.\n </p>\n <div class="row g-2 align-items-end">\n <div class="col-md-4">\n <label class="form-label fw-semibold small text-muted mb-1">Command</label>\n <select class="form-select form-select-sm" data-field="broadcast-command">\n <option value="status">status</option>\n <option value="pause">pause</option>\n <option value="resume">resume</option>\n <option value="reload">reload</option>\n <option value="shutdown">shutdown</option>\n </select>\n </div>\n <div class="col-md-3">\n <label class="form-label fw-semibold small text-muted mb-1">Timeout (s)</label>\n <input type="number" class="form-control form-control-sm"\n data-field="broadcast-timeout" value="2.0" min="0.5" step="0.5" />\n </div>\n <div class="col-md-5">\n <button class="btn btn-primary btn-sm w-100" data-action="broadcast">\n <i class="bi bi-megaphone me-1"></i>Broadcast to All Runners\n </button>\n </div>\n </div>\n </div>\n </div>\n '}}class RunnerDetailsView extends t.View{constructor(e={}){super({className:"runner-details-view",...e}),this.model=e.model instanceof a.JobRunner?e.model:new a.JobRunner(e.model||e.data||{})}async onInit(){if(!this.model)return;const e=new a.TabView({containerId:"runner-tabs",tabs:{Overview:new RunnerOverviewTab({model:this.model}),"System Info":new RunnerSysinfoTab({model:this.model}),"Running Jobs":new RunnerJobsTab({model:this.model}),Logs:new RunnerLogsTab({model:this.model}),Actions:new RunnerActionsTab({model:this.model})}});this.addChild(e)}async getTemplate(){return'<div data-container="runner-tabs"></div>'}static async show(e,t={}){const i=e instanceof a.JobRunner?e:new a.JobRunner(e),n=new RunnerDetailsView({model:i});return await s.Dialog.showDialog({title:`<i class="bi bi-cpu me-2"></i><span class="font-monospace">${i.get("runner_id")}</span>`,body:n,size:"xl",scrollable:!0,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}],...t})}}a.JobRunner.VIEW_CLASS=RunnerDetailsView;class JobRunnersSection extends t.View{constructor(e={}){super({className:"job-runners-section",template:'\n <div class="card shadow-sm h-100">\n <div class="card-header d-flex justify-content-between align-items-center">\n <h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Job Runners</h5>\n <small class="text-muted">Heartbeat & status</small>\n </div>\n <div class="card-body p-0" data-container="runner-table"></div>\n </div>\n ',...e})}async onInit(){this.runnersTable=new n.TableView({containerId:"runner-table",Collection:a.JobRunnerList,searchable:!0,filterable:!1,paginated:!0,itemView:RunnerDetailsView,viewDialogOptions:{title:"Runner Details",size:"xl",scrollable:!0},tableOptions:{striped:!1,hover:!0,size:"sm"},columns:[{key:"runner_id",label:"Runner",formatter:"truncate_middle(16)"},{key:"alive",label:"Status",formatter:e=>{const t=!0===e;return`<span class="badge ${t?"bg-success":"bg-danger"}"><i class="${t?"bi-check-circle-fill":"bi-x-octagon-fill"} me-1"></i>${t?"ALIVE":"DEAD"}</span>`}},{key:"last_heartbeat",label:"Heartbeat",formatter:e=>{if(!e)return"Never";const t=new Date(e),s=/* @__PURE__ */new Date-t,i=Math.floor(s/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:`${Math.floor(i/3600)}h ago`}}]}),this.addChild(this.runnersTable)}}class JobRunnersPage extends e.Page{constructor(e={}){super({title:"Job Runners",pageName:"Job Runners",className:"job-runners-page",...e}),this.template='\n <div class="job-runners-container">\n <p class="text-muted mb-3">Registered job runners and their heartbeat status</p>\n <div data-container="runners-section"></div>\n </div>\n '}async onInit(){this.runnersSection=new JobRunnersSection({containerId:"runners-section"}),this.addChild(this.runnersSection)}}class JobDetailsView extends t.View{constructor(e={}){super({className:"job-details-view",...e}),this.model=e.model||new a.Job(e.data||{}),this.tabView=null,this.overviewView=null,this.payloadView=null,this.eventsView=null,this.logsView=null,this.autoRefreshInterval=null,this.template='\n <div class="job-details-container">\n \x3c!-- Job Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left Side: Primary Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">\n <i class="bi {{model.statusIcon}} text-secondary" style="font-size: 40px;"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.func|truncate(32)|default(\'Unknown Function\')}}</h3>\n <div class="text-muted small">\n <span>ID: {{model.id}}</span>\n <span class="mx-2">|</span>\n <span>Channel: <span class="badge bg-primary">{{model.channel}}</span></span>\n {{#model.runner_id}}\n <span class="mx-2">|</span>\n <span>Runner: {{model.runner_id|truncate(16)}}</span>\n {{/model.runner_id}}\n </div>\n <div class="text-muted small mt-2">\n <div>Created: {{model.created|datetime}}</div>\n {{#model.started_at}}\n <div>Started: {{model.started_at|datetime}}</div>\n {{/model.started_at}}\n {{#model.finished_at}}\n <div>Finished: {{model.finished_at|datetime}}</div>\n {{/model.finished_at}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center gap-2">\n <span class="badge {{model.statusBadgeClass}} fs-6">\n <i class="bi {{model.statusIcon}}"></i> {{model.status|uppercase}}\n </span>\n {{#model.cancel_requested}}\n <span class="badge bg-warning ms-1">\n <i class="bi bi-exclamation-triangle"></i> Cancel Requested\n </span>\n {{/model.cancel_requested}}\n </div>\n {{#model.formattedDuration}}\n <div class="text-muted small mt-1">Duration: {{model.formattedDuration}}</div>\n {{/model.formattedDuration}}\n </div>\n <div data-container="job-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tab Container --\x3e\n <div data-container="job-details-tabs"></div>\n </div>\n '}async onInit(){this.overviewView=new t.View({template:'\n <div class="job-overview-tab">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <div class="row">\n <div class="col-md-6">\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Job ID</label>\n <div class="font-monospace">{{model.id}}</div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Function</label>\n <div class="font-monospace">{{model.func}}</div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Channel</label>\n <div>\n <span class="badge bg-primary">{{model.channel}}</span>\n </div>\n </div>\n {{#model.runner_id}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Runner</label>\n <div class="font-monospace small">{{model.runner_id}}</div>\n </div>\n {{/model.runner_id}}\n </div>\n <div class="col-md-6">\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Status</label>\n <div>\n <span class="badge {{model.statusBadgeClass}} fs-6">\n <i class="bi {{model.statusIcon}}"></i> {{model.status|uppercase}}\n </span>\n {{#model.cancel_requested}}\n <span class="badge bg-warning ms-1">\n <i class="bi bi-exclamation-triangle"></i> Cancel Requested\n </span>\n {{/model.cancel_requested}}\n </div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Created</label>\n <div>{{model.created|datetime}}</div>\n </div>\n {{#model.started_at}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Started</label>\n <div>{{model.started_at|datetime}}</div>\n </div>\n {{/model.started_at}}\n {{#model.finished_at}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Finished</label>\n <div>{{model.finished_at|datetime}}</div>\n </div>\n {{/model.finished_at}}\n {{#model.duration_ms}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Duration</label>\n <div>{{model.formattedDuration}}</div>\n </div>\n {{/model.duration_ms}}\n </div>\n </div>\n </div>\n </div>\n </div>\n ',model:this.model}),this.payloadView=new t.View({template:'\n <div class="job-payload-tab">\n <pre class="bg-light p-3 rounded"><code>{{{model.payload|json}}}</code></pre>\n </div>\n ',model:this.model});const s=new a.JobEventList({params:{job:this.model.get("id"),size:10}});this.eventsView=new n.TableView({collection:s,hideActivePillNames:["job"],columns:[{key:"at",label:"Timestamp",formatter:"datetime",sortable:!0},{key:"event",label:"Event",formatter:"badge"},{key:"details|json",label:"Details"}]});const i=new a.JobLogList({params:{job:this.model.get("id"),size:10}});this.logsView=new n.TableView({collection:i,hideActivePillNames:["job"],columns:[{key:"created|datetime",label:"Created",sortable:!0},{key:"kind",label:"Kind",formatter:"badge"},{key:"message",label:"Message"}]}),this.tabView=new a.TabView({tabs:{Overview:this.overviewView,Payload:this.payloadView,Events:this.eventsView,Logs:this.logsView},activeTab:"Overview",containerId:"job-details-tabs"}),this.addChild(this.tabView);const o=[{label:"Refresh",action:"refresh-job",icon:"bi-arrow-clockwise"}];this.model.canCancel&&this.model.canCancel()&&o.push({label:"Cancel Job",action:"cancel-job",icon:"bi-x-circle",class:"text-danger"}),this.model.canRetry&&this.model.canRetry()&&o.push({label:"Retry Job",action:"retry-job",icon:"bi-arrow-repeat",class:"text-primary"});const l=new e.ContextMenu({containerId:"job-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:o}});this.addChild(l),await this.model.fetch({params:{graph:"detail"}})}async onBeforeRender(){await this.prepareJobData()}async prepareJobData(){this.model&&(this.model._.statusBadgeClass=this.model.getStatusBadgeClass?this.model.getStatusBadgeClass():"bg-secondary",this.model._.statusIcon=this.model.getStatusIcon?this.model.getStatusIcon():"bi-question-circle",this.model._.formattedDuration=this.model.getFormattedDuration?this.model.getFormattedDuration():"N/A")}async loadJobDetails(){if(this.model?.get("id"))try{this.model.getDetailedStatus&&await this.model.getDetailedStatus(),await this.prepareJobData()}catch(e){console.error("Failed to load job details:",e)}}async onActionRefreshJob(){await this.model.fetch({params:{graph:"detail"}})}async onActionCancelJob(){if(confirm("Are you sure you want to cancel this job?"))try{const e=await this.model.cancel();e.success?(await this.loadJobDetails(),await this.render(),this.emit("job-cancelled",{job:this.model})):alert("Failed to cancel job: "+(e.data?.error||"Unknown error"))}catch(e){console.error("Failed to cancel job:",e),alert("Failed to cancel job: "+e.message)}}async onActionRetryJob(){const e=await s.Dialog.showForm({title:"Retry Job",formConfig:a.JobForms.retry});if(e)try{const t=await this.model.retry(e.delay||0);t.success?this.emit("job-retried",{job:this.model,newJobId:t.newJobId}):alert("Failed to retry job: "+(t.data?.error||"Unknown error"))}catch(t){console.error("Failed to retry job:",t),alert("Failed to retry job: "+t.message)}}startAutoRefresh(){this.autoRefreshInterval&&clearInterval(this.autoRefreshInterval),this.model?.isActive&&this.model.isActive()&&(this.autoRefreshInterval=setInterval(async()=>{try{await this.loadJobDetails(),this.isMounted()&&await this.render()}catch(e){console.error("Auto-refresh failed:",e)}},5e3))}stopAutoRefresh(){this.autoRefreshInterval&&(clearInterval(this.autoRefreshInterval),this.autoRefreshInterval=null)}async onDestroy(){this.stopAutoRefresh(),await super.onDestroy()}static async show(e,t={}){const i=new JobDetailsView({model:e});return await s.Dialog.showDialog({title:`<i class="bi bi-info-circle me-2"></i>Job Details - ${e.get("id")}`,body:i,size:"xl",scrollable:!0,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}],onHide:()=>i.stopAutoRefresh(),...t})}}a.Job.VIEW_CLASS=JobDetailsView;const M={running:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"runner_id",label:"Runner",template:"\n <span class=\"font-monospace\">{{model.runner_id|truncate_middle(12)|default('n/a')}}</span>\n "},{key:"status",label:"State",formatter:(e,t)=>{const s=t.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${e.toUpperCase()}</span>`}},{key:"created",label:"Started",formatter:"datetime"}],pending:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"priority",label:"Priority",formatter:(e=0)=>`<span class="badge ${e>=8?"bg-danger":e>=5?"bg-warning":"bg-secondary"}">${e}</span>`},{key:"modified",label:"Queued",formatter:"relative"}],scheduled:[{key:"id",label:"Job",formatter:"truncate_middle(12)"},{key:"run_at",label:"Scheduled For",formatter:"datetime"},{key:"channel",label:"Channel",formatter:"badge"}],failed:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"last_error",label:"Error",template:"\n <div class=\"text-danger small\">{{model.last_error|truncate(80)|default('Unknown error')}}</div>\n "},{key:"modified",label:"Failed",formatter:"relative"}],all:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id}}</div>\n <div class="text-muted small">{{model.func|default(\'Unknown\')}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge"},{key:"status",label:"Status",formatter:(e,t)=>{const s=t.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${e?.toUpperCase()||"UNKNOWN"}</span>`}},{key:"created",label:"Created",formatter:"datetime"},{key:"finished_at",label:"Finished",formatter:"datetime"},{key:"duration_ms",label:"Duration",formatter:"duration"}]},R=[{key:"status",label:"Status",type:"select",options:[{label:"Pending",value:"pending"},{label:"Running",value:"running"},{label:"Completed",value:"completed"},{label:"Failed",value:"failed"},{label:"Canceled",value:"canceled"},{label:"Expired",value:"expired"}]},{key:"channel",label:"Channel",type:"text"},{key:"func__icontains",label:"Function",type:"text"}],L=[{icon:"bi-x-circle-fill",label:"Cancel Jobs",action:"cancel-jobs"}];class JobTableSection extends t.View{constructor(e={}){const{status:t,sort:s="-created",extraParams:i={},columns:a,title:n,selectable:o=!1,batchActions:l,...r}=e;super({className:"job-table-section",template:'<div data-container="job-table"></div>',...r}),this.status=t,this.sort=s,this.extraParams=i,this.columnConfig=a,this.sectionTitle=n,this.selectable=o,this.batchActionConfig=l}async onInit(){const e={size:25,sort:this.sort,...this.extraParams};this.status&&(e.status=this.status);const t=("string"==typeof this.columnConfig?M[this.columnConfig]:this.columnConfig)||M[this.status]||M.all,s=this.selectable,i=this.batchActionConfig||(s?L:void 0),o=!this.status,l={containerId:"job-table",Collection:a.JobList,collectionParams:e,columns:t,searchable:!0,filterable:o,paginated:!0,itemView:JobDetailsView,hideActivePills:this.status?["status"]:[],viewDialogOptions:{title:"Job Details",size:"xl",scrollable:!0},tableOptions:{striped:!1,hover:!0,size:"sm"}};s&&(l.selectable=!0,l.batchBarLocation="top",l.batchActions=i),o&&(l.filters=R,l.tableOptions.striped=!0,l.tableOptions.responsive=!0),this.tableView=new n.TableView(l),s&&this.tableView.on("action:batch-cancel-jobs",async(e,t,s)=>{const i=this.tableView.getSelectedItems();await Promise.all(i.map(e=>e.model.cancel())),this.getApp().toast.success("Jobs cancelled successfully"),this.tableView.collection.fetch()}),this.addChild(this.tableView)}}class JobsTablePage extends e.Page{constructor(e={}){super({title:"Jobs",pageName:"Jobs",className:"jobs-table-page",...e}),this.template='\n <div class="jobs-table-container">\n <p class="text-muted mb-3">All jobs across all channels and statuses</p>\n <div data-container="jobs-section"></div>\n </div>\n '}async onInit(){this.jobTableSection=new JobTableSection({containerId:"jobs-section",sort:"-created",title:"All Jobs"}),this.addChild(this.jobTableSection)}}class ScheduledTask extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/jobs/scheduled_task",...t})}}class ScheduledTaskList extends t.Collection{constructor(e={}){super({ModelClass:ScheduledTask,endpoint:"/api/jobs/scheduled_task",size:25,...e})}}class TaskResult extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/jobs/task_result",...t})}}class TaskResultList extends t.Collection{constructor(e={}){super({ModelClass:TaskResult,endpoint:"/api/jobs/task_result",size:25,...e})}}const E=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],N={create:{title:"Create Scheduled Task",fields:[{name:"name",type:"text",label:"Name",placeholder:"Daily report",required:!0,columns:12},{name:"description",type:"textarea",label:"Description",placeholder:"What this task does...",columns:12},{name:"task_type",type:"select",label:"Task Type",required:!0,columns:6,options:[{value:"llm",label:"LLM Prompt"},{value:"job",label:"Backend Job"},{value:"webhook",label:"Webhook"}]},{name:"enabled",type:"switch",label:"Enabled",columns:6,value:!0},{name:"run_times",type:"text",label:"Run Times (HH:MM)",placeholder:"09:00",required:!0,columns:6,help:'Comma-separated 24h times, max 2. e.g. "09:00, 17:00"'},{name:"run_days",type:"text",label:"Run Days",placeholder:"0,1,2,3,4",columns:6,help:"Comma-separated day numbers (Mon=0). Leave empty for every day."},{name:"run_once",type:"switch",label:"Run Once",columns:6,help:"Task runs once then disables itself."},{name:"max_retries",type:"number",label:"Max Retries",columns:6,value:0},{name:"notify",type:"text",label:"Notify",placeholder:"in_app, email",columns:12,help:"Comma-separated: email, in_app, sms, push"}]},edit:{title:"Edit Scheduled Task",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"description",type:"textarea",label:"Description",columns:12},{name:"enabled",type:"switch",label:"Enabled",columns:6},{name:"run_times",type:"text",label:"Run Times (HH:MM)",required:!0,columns:6,help:"Comma-separated 24h times, max 2."},{name:"run_days",type:"text",label:"Run Days",columns:6,help:"Comma-separated day numbers (Mon=0). Leave empty for every day."},{name:"run_once",type:"switch",label:"Run Once",columns:6},{name:"max_retries",type:"number",label:"Max Retries",columns:6},{name:"notify",type:"text",label:"Notify",placeholder:"in_app, email",columns:12,help:"Comma-separated: email, in_app, sms, push"}]}};class ScheduledTaskView extends t.View{constructor(e={}){super({className:"scheduled-task-view",...e}),this.model=e.model||new ScheduledTask(e.data||{})}getTemplate(){const e=this.model.get("run_days")||[];this.dayDisplay=0===e.length||7===e.length?"Every day":e.map(e=>E[e]||e).join(", ");const t=this.model.get("run_times")||[];this.timeDisplay=t.join(", ")||"—";const s=this.model.get("notify")||[];return this.notifyDisplay=s.length>0?s.join(", "):"None",'\n <div class="scheduled-task-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-clock-history"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.name}}</h3>\n <div class="text-muted small">\n {{model.task_type|uppercase}} task\n {{#model.run_once}}\n <span class="mx-1">·</span> <span class="badge bg-info">Run Once</span>\n {{/model.run_once}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.enabled|boolean(\'bg-success\',\'bg-secondary\')}}">\n {{model.enabled|boolean(\'Enabled\',\'Disabled\')}}\n </span>\n </div>\n </div>\n </div>\n\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|relative}}</div>\n </div>\n <div data-container="task-context-menu"></div>\n </div>\n </div>\n\n {{#model.description}}\n <p class="text-muted mb-3">{{model.description}}</p>\n {{/model.description}}\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Schedule</h6>\n <p class="mb-0 small">{{timeDisplay}} · {{dayDisplay}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Notifications</h6>\n <p class="mb-0 small">{{notifyDisplay}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Execution</h6>\n <p class="mb-0 small">\n Runs: {{model.run_count|default(\'0\')}}\n <span class="mx-2">|</span>\n Last run: {{model.last_run|relative|default(\'Never\')}}\n {{#model.max_retries}}\n <span class="mx-2">|</span>\n Max retries: {{model.max_retries}}\n {{/model.max_retries}}\n </p>\n </div>\n {{#model.last_error}}\n <div class="list-group-item list-group-item-danger">\n <h6 class="mb-1 text-muted">Last Error</h6>\n <p class="mb-0 small font-monospace">{{model.last_error}}</p>\n </div>\n {{/model.last_error}}\n {{#model.job_config}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Configuration</h6>\n <pre class="mb-0 small">{{model.job_config|json}}</pre>\n </div>\n {{/model.job_config}}\n </div>\n\n \x3c!-- Recent Results --\x3e\n <h6 class="text-muted mb-2">Recent Results</h6>\n <div data-ref="results-container">\n <div class="text-center text-muted small py-3">Loading...</div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("enabled"),s=new e.ContextMenu({containerId:"task-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-task",icon:"bi-pencil"},t?{label:"Disable",action:"disable-task",icon:"bi-pause-circle"}:{label:"Enable",action:"enable-task",icon:"bi-play-circle"},{type:"divider"},{label:"Delete",action:"delete-task",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onAfterRender(){await super.onAfterRender(),await this._loadResults()}async _loadResults(){const e=this.element?.querySelector('[data-ref="results-container"]');if(e)try{const t=new TaskResultList({params:{task:this.model.get("id"),sort:"-created",size:10}});await t.fetch();const s=t.models||[];if(0===s.length)return void(e.innerHTML='<div class="text-center text-muted small py-3">No results yet.</div>');let i='<div class="list-group">';s.forEach(e=>{const t=e.get("status"),s=e.get("created");i+=`\n <div class="list-group-item d-flex justify-content-between align-items-center py-2">\n <div class="d-flex align-items-center gap-2">\n <i class="bi ${"success"===t?"bi-check-circle-fill":"bi-x-circle-fill"} ${"success"===t?"text-success":"text-danger"}"></i>\n <span class="small">${this._escapeHtml(t)}</span>\n </div>\n <span class="text-muted small">${this._escapeHtml(s||"")}</span>\n </div>`}),i+="</div>",e.innerHTML=i}catch(t){e.innerHTML='<div class="text-center text-muted small py-3">Failed to load results.</div>'}}async onActionEditTask(){const e=this.getApp();await e.showModelForm({title:`Edit Task — ${this.model.get("name")}`,model:this.model,formConfig:N.edit})&&this.render()}async onActionDisableTask(){const e=this.getApp();e.showLoading();const t=await this.model.save({enabled:!1});e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task disabled"),this.render()):e.toast.error("Failed to disable task")}async onActionEnableTask(){const e=this.getApp();e.showLoading();const t=await this.model.save({enabled:!0});e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task enabled"),this.render()):e.toast.error("Failed to enable task")}async onActionDeleteTask(){const e=this.getApp();if(!(await e.confirm({title:"Delete Scheduled Task",message:`Permanently delete "${this.model.get("name")}" and all its results? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete task")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}}ScheduledTask.VIEW_CLASS=ScheduledTaskView,ScheduledTask.ADD_FORM=N.create,ScheduledTask.EDIT_FORM=N.edit;class ScheduledTaskTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_scheduled_tasks",pageName:"Scheduled Tasks",router:"admin/scheduled-tasks",Collection:ScheduledTaskList,itemViewClass:ScheduledTaskView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"name",label:"Name",sortable:!0},{key:"task_type",label:"Type",width:"90px",formatter:"uppercase|badge"},{key:"enabled",label:"Status",width:"100px",formatter:"boolean('Enabled|bg-success','Disabled|bg-secondary')|badge"},{key:"run_times",label:"Schedule",render(e,t){const s=e||[],i=t.get("run_days")||[];return`${s.join(", ")||"—"} · ${0===i.length||7===i.length?"Every day":i.map(e=>E[e]||e).join(", ")}`}},{key:"run_count",label:"Runs",width:"70px",sortable:!0},{key:"last_run",label:"Last Run",formatter:"relative",sortable:!0},{key:"created",label:"Created",formatter:"relative",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New Task",filters:[{key:"enabled",label:"Status",type:"select",options:[{value:"",label:"All"},{value:"true",label:"Enabled"},{value:"false",label:"Disabled"}]},{key:"task_type",label:"Type",type:"select",options:[{value:"",label:"All"},{value:"llm",label:"LLM"},{value:"job",label:"Job"},{value:"webhook",label:"Webhook"}]}],emptyMessage:"No scheduled tasks found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BlockedIPsTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_blocked_ips",pageName:"Blocked IPs",router:"admin/security/blocked-ips",Collection:a.GeoLocatedIPList,itemViewClass:GeoIPView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-modified",is_blocked:!0},columns:[{key:"ip_address",label:"IP Address",sortable:!0,template:"<code>{{model.ip_address}}</code>"},{key:"threat_level",label:"Threat Level",sortable:!0,filter:{type:"select",options:["none","low","medium","high","critical"]}},{key:"country_code",label:"Country",sortable:!0,filter:{type:"text"}},{key:"city",label:"City",sortable:!0},{key:"blocked_reason",label:"Reason",formatter:"truncate(40)"},{key:"blocked_at",label:"Blocked At",formatter:"datetime",sortable:!0},{key:"blocked_until",label:"Expires",formatter:"datetime",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,selectable:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No blocked IPs. The firewall has no active IP blocks.",batchBarLocation:"top",batchActions:[{label:"Unblock",icon:"bi bi-unlock",action:"unblock"},{label:"Whitelist",icon:"bi bi-check-circle",action:"whitelist"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchUnblock(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Unblock ${e.length} IP${e.length>1?"s":""}?`)&&(await Promise.all(e.map(e=>e.model.save({unblock:"Bulk unblock from admin"}))),this.getApp().toast.success(`${e.length} IP(s) unblocked`),this.tableView.collection.fetch())}async onActionBatchWhitelist(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Whitelist ${e.length} IP${e.length>1?"s":""}?`)&&(await Promise.all(e.map(e=>e.model.save({whitelist:"Bulk whitelist from admin"}))),this.getApp().toast.success(`${e.length} IP(s) whitelisted`),this.tableView.collection.fetch())}}class LogView extends t.View{constructor(e={}){super({className:"log-view",...e}),this.model=e.model||new n.Log(e.data||{}),this.logIcon=this.getIconForLog(this.model.get("level")),this.template='\n <div class="log-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{logIcon.color}}">\n <i class="bi {{logIcon.icon}}"></i>\n </div>\n <div>\n <h4 class="mb-1">\n <span class="badge bg-secondary">{{model.method}}</span> {{model.path}}\n </h4>\n <div class="text-muted small">\n {{model.created|datetime}}\n </div>\n </div>\n </div>\n <div data-container="log-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="log-tabs"></div>\n </div>\n '}getIconForLog(e){const t=e?.toLowerCase();return"error"===t||"critical"===t?{icon:"bi-x-octagon-fill",color:"text-danger"}:"warning"===t?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:"info"===t?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-journal-text",color:"text-secondary"}}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",columns:2,fields:[{name:"id",label:"Log ID"},{name:"level",label:"Level",format:"badge"},{name:"kind",label:"Kind"},{name:"ip",label:"IP Address",template:'<a href="#" data-action="view-ip">{{model.ip}}</a>'},{name:"uid",label:"User ID"},{name:"username",label:"Username"},{name:"duid",label:"Device ID",template:'<a href="#" data-action="view-device">{{model.duid|truncate_middle(32)}}</a>'},{name:"model_name",label:"Related Model"},{name:"model_id",label:"Related Model ID"},{name:"user_agent",label:"User Agent",columns:12}]});const s=this.model.get("log");let i=s;try{const e=JSON.parse(s);i=JSON.stringify(e,null,2)}catch(o){}this.logContentView=new t.View({template:`\n <div class="position-relative">\n <button class="btn btn-sm btn-outline-secondary position-absolute top-0 end-0 mt-2 me-2" data-action="copy-log">\n <i class="bi bi-clipboard"></i> Copy\n </button>\n <pre class="bg-light p-3 border rounded" style="max-height: 600px; overflow-y: auto;"><code>${i}</code></pre>\n </div>\n `,onActionCopyLog:()=>{navigator.clipboard.writeText(i),this.getApp()?.toast?.success("Log content copied to clipboard.")}}),this.tabView=new a.TabView({containerId:"log-tabs",tabs:{Log:this.logContentView,Details:this.overviewView},activeTab:"Log"}),this.addChild(this.tabView);const n=new e.ContextMenu({containerId:"log-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View User",action:"view-user",icon:"bi-person",disabled:!this.model.get("uid")},{label:"View Device",action:"view-device",icon:"bi-phone",disabled:!this.model.get("duid")},{type:"divider"},{label:"Delete Log",action:"delete-log",icon:"bi-trash",danger:!0}]}});this.addChild(n)}async onActionViewIp(e){e.preventDefault();const t=this.model.get("ip");t&&GeoIPView.show(t)}async onActionViewDevice(e){e.preventDefault();const t=this.model.get("duid");t&&DeviceView.show(t)}async onActionViewUser(){this.model.get("uid")}async onActionDeleteLog(){await s.Dialog.confirm("Are you sure you want to delete this log entry? This action cannot be undone.","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("log:deleted",{model:this.model})}}n.Log.VIEW_CLASS=LogView;class FirewallLogTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_firewall_log",pageName:"Firewall Log",router:"admin/security/firewall-log",Collection:n.LogList,itemViewClass:LogView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-created",kind__startswith:"firewall:"},columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,filter:{type:"daterange"}},{key:"kind",label:"Action",sortable:!0,filter:{type:"text"}},{key:"message",label:"Details",formatter:"truncate(80)"},{key:"path",label:"IP / Path",formatter:"truncate(40)"},{key:"user",label:"Admin",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No firewall log entries found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BouncerDevice extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/device",...t})}}class BouncerDeviceList extends t.Collection{constructor(e={}){super({ModelClass:BouncerDevice,endpoint:"/api/account/bouncer/device",size:25,...e})}}class BouncerSignal extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/signal",...t})}}class BouncerSignalList extends t.Collection{constructor(e={}){super({ModelClass:BouncerSignal,endpoint:"/api/account/bouncer/signal",size:25,...e})}}class BouncerSignature extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/signature",...t})}}class BouncerSignatureList extends t.Collection{constructor(e={}){super({ModelClass:BouncerSignature,endpoint:"/api/account/bouncer/signature",size:25,...e})}}const F={create:{title:"Create Signature",fields:[{name:"sig_type",type:"select",label:"Signature Type",required:!0,columns:6,options:[{value:"user_agent",label:"User Agent"},{value:"ip_pattern",label:"IP Pattern"},{value:"fingerprint",label:"Fingerprint"},{value:"behavior",label:"Behavior"},{value:"header",label:"Header"},{value:"cookie",label:"Cookie"}]},{name:"value",type:"text",label:"Value",required:!0,columns:6,help:"The pattern or value to match against."},{name:"confidence",type:"number",label:"Confidence",columns:6,default:80,min:0,max:100,help:"Confidence level from 0 to 100."},{name:"notes",type:"textarea",label:"Notes",columns:12,help:"Optional notes about this signature."},{name:"is_active",type:"switch",label:"Active",columns:6,default:!0}]},edit:{title:"Edit Signature",fields:[{name:"sig_type",type:"select",label:"Signature Type",required:!0,columns:6,options:[{value:"user_agent",label:"User Agent"},{value:"ip_pattern",label:"IP Pattern"},{value:"fingerprint",label:"Fingerprint"},{value:"behavior",label:"Behavior"},{value:"header",label:"Header"},{value:"cookie",label:"Cookie"}]},{name:"value",type:"text",label:"Value",required:!0,columns:6,help:"The pattern or value to match against."},{name:"confidence",type:"number",label:"Confidence",columns:6,default:80,min:0,max:100,help:"Confidence level from 0 to 100."},{name:"notes",type:"textarea",label:"Notes",columns:12,help:"Optional notes about this signature."},{name:"is_active",type:"switch",label:"Active",columns:6,default:!0}]}};class BouncerSignalView extends t.View{constructor(e={}){super({className:"bouncer-signal-view",...e}),this.template='\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 72px; height: 72px;">\n <i class="bi bi-activity text-secondary" style="font-size: 36px;"></i>\n </div>\n <div>\n <h3 class="mb-1">Signal Assessment</h3>\n <div class="text-muted small">\n <span>IP: <code>{{model.ip_address}}</code></span>\n <span class="mx-2">|</span>\n <span>Stage: {{model.stage}}</span>\n <span class="mx-2">|</span>\n <span>Page: {{model.page_type|default(\'—\')}}</span>\n </div>\n <div class="mt-1">\n <span class="badge {{decisionBadge}} me-1">{{model.decision|uppercase}}</span>\n <span class="text-muted small">Risk Score: <strong>{{model.risk_score}}</strong></span>\n </div>\n </div>\n </div>\n <div data-container="signal-context-menu"></div>\n </div>\n <div data-container="signal-tabs"></div>\n '}get decisionBadge(){return{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[this.model?.get("decision")]||"bg-secondary"}async onInit(){await this.model.fetch({params:{graph:"detail"}});const s=new t.View({model:this.model,template:'\n <div class="row">\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Assessment</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Decision</label><div><span class="badge {{decisionBadge}}">{{model.decision|uppercase}}</span></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Risk Score</label><div>{{model.risk_score}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Stage</label><div>{{model.stage}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Page Type</label><div>{{model.page_type|default(\'—\')}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">IP Address</label><div><code>{{model.ip_address}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">MUID</label><div><code>{{model.muid|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Created</label><div>{{model.created|datetime}}</div></div>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Triggered Signals</h6>\n {{#model.triggered_signals.length}}\n <div class="d-flex flex-wrap gap-1">\n {{#model.triggered_signals}}\n <span class="badge bg-warning text-dark">{{.}}</span>\n {{/model.triggered_signals}}\n </div>\n {{/model.triggered_signals.length}}\n {{^model.triggered_signals.length}}\n <span class="text-muted">No signals triggered</span>\n {{/model.triggered_signals.length}}\n </div>\n </div>\n </div>\n </div>\n '}),i=new t.View({model:this.model,template:'\n <div class="card border-0 bg-light">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Raw Signals (Client)</h6>\n <pre class="bg-white p-3 rounded border"><code>{{{model.raw_signals|json}}}</code></pre>\n </div>\n </div>\n '}),n=new t.View({model:this.model,template:'\n <div class="card border-0 bg-light">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Server Signals</h6>\n <pre class="bg-white p-3 rounded border"><code>{{{model.server_signals|json}}}</code></pre>\n </div>\n </div>\n '});this.tabView=new a.TabView({tabs:{Overview:s,"Raw Signals":i,"Server Signals":n},activeTab:"Overview",containerId:"signal-tabs"}),this.addChild(this.tabView);const o=new e.ContextMenu({containerId:"signal-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh",icon:"bi-arrow-clockwise"}]}});this.addChild(o)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}class BouncerSignalTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_signals",pageName:"Bouncer Signals",router:"admin/security/bouncer-signals",Collection:BouncerSignalList,itemViewClass:BouncerSignalView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-created"},columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,filter:{type:"daterange"}},{key:"ip_address",label:"IP",template:"<code>{{model.ip_address}}</code>",filter:{type:"text"}},{key:"decision",label:"Decision",formatter:e=>`<span class="badge ${{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[e]||"bg-secondary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["allow","monitor","block"]}},{key:"risk_score",label:"Risk",sortable:!0,formatter:e=>{const t=e||0;return`<span class="text-${t>=80?"danger":t>=50?"warning":t>=20?"info":"success"} fw-semibold">${t}</span>`}},{key:"page_type",label:"Page",filter:{type:"text"}},{key:"stage",label:"Stage",filter:{type:"select",options:["assess","submit","event"]}},{key:"muid",label:"Device",formatter:"truncate_middle(12)"}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No bouncer signals recorded yet.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BouncerDeviceView extends t.View{constructor(e={}){super({className:"bouncer-device-view",...e}),this.template='\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 72px; height: 72px;">\n <i class="bi bi-fingerprint text-secondary" style="font-size: 36px;"></i>\n </div>\n <div>\n <h3 class="mb-1">Device</h3>\n <div class="text-muted small">\n <span>MUID: <code>{{model.muid}}</code></span>\n {{#model.duid}}\n <span class="mx-2">|</span>\n <span>DUID: <code>{{model.duid|truncate_middle(16)}}</code></span>\n {{/model.duid}}\n </div>\n <div class="mt-1">\n <span class="badge {{riskBadge}} me-1">{{model.risk_tier|uppercase|default(\'UNKNOWN\')}}</span>\n <span class="text-muted small">\n {{model.event_count}} events · {{model.block_count}} blocks\n </span>\n </div>\n </div>\n </div>\n <div data-container="device-context-menu"></div>\n </div>\n <div data-container="device-tabs"></div>\n '}get riskBadge(){return{blocked:"bg-danger",high:"bg-danger",medium:"bg-warning",low:"bg-success",unknown:"bg-secondary"}[this.model?.get("risk_tier")]||"bg-secondary"}async onInit(){await this.model.fetch({params:{graph:"detail"}});const s=new t.View({model:this.model,template:'\n <div class="row">\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Device Info</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">MUID</label><div><code>{{model.muid}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">DUID</label><div><code>{{model.duid|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Fingerprint ID</label><div><code>{{model.fingerprint_id|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Risk Tier</label><div><span class="badge {{riskBadge}}">{{model.risk_tier|uppercase|default(\'UNKNOWN\')}}</span></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Last Seen IP</label><div><code>{{model.last_seen_ip|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Last Seen</label><div>{{model.last_seen|datetime|default(\'—\')}}</div></div>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Activity</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Event Count</label><div>{{model.event_count}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Block Count</label><div>{{model.block_count}}</div></div>\n <h6 class="fw-bold mb-2 mt-3">Linked MUIDs</h6>\n {{#model.linked_muids.length}}\n <div class="d-flex flex-wrap gap-1">\n {{#model.linked_muids}}\n <code class="badge bg-light text-dark border">{{.}}</code>\n {{/model.linked_muids}}\n </div>\n {{/model.linked_muids.length}}\n {{^model.linked_muids.length}}\n <span class="text-muted">No linked devices</span>\n {{/model.linked_muids.length}}\n </div>\n </div>\n </div>\n </div>\n '}),i=new n.TableView({Collection:BouncerSignalList,collectionParams:{size:10,sort:"-created",muid:this.model.get("muid")},columns:[{key:"created",label:"Time",formatter:"relative"},{key:"ip_address",label:"IP",template:"<code>{{model.ip_address}}</code>"},{key:"decision",label:"Decision",formatter:e=>`<span class="badge ${{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[e]||"bg-secondary"}">${(e||"—").toUpperCase()}</span>`},{key:"risk_score",label:"Risk"},{key:"page_type",label:"Page"}],searchable:!0,paginated:!0,tableOptions:{hover:!0,size:"sm"}}),o=new n.TableView({Collection:a.IncidentList,collectionParams:{size:10,sort:"-created",category__startswith:"security:bouncer",search:this.model.get("muid")},columns:[{key:"created",label:"Created",formatter:"epoch|datetime"},{key:"status",label:"Status"},{key:"category",label:"Category"},{key:"title",label:"Title",formatter:"truncate(60)"}],searchable:!0,paginated:!0,tableOptions:{hover:!0,size:"sm"}});this.tabView=new a.TabView({tabs:{Overview:s,Signals:i,Incidents:o},activeTab:"Overview",containerId:"device-tabs"}),this.addChild(this.tabView);const l=new e.ContextMenu({containerId:"device-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh",icon:"bi-arrow-clockwise"}]}});this.addChild(l)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}class BouncerDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_devices",pageName:"Bouncer Devices",router:"admin/security/bouncer-devices",Collection:BouncerDeviceList,itemViewClass:BouncerDeviceView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-last_seen"},columns:[{key:"muid",label:"MUID",template:"<code>{{model.muid|truncate_middle(16)}}</code>",sortable:!0,filter:{type:"text"}},{key:"risk_tier",label:"Risk Tier",sortable:!0,formatter:e=>`<span class="badge ${{blocked:"bg-danger",high:"bg-danger",medium:"bg-warning",low:"bg-success",unknown:"bg-secondary"}[e]||"bg-secondary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["unknown","low","medium","high","blocked"]}},{key:"event_count",label:"Events",sortable:!0},{key:"block_count",label:"Blocks",sortable:!0},{key:"last_seen_ip",label:"Last IP",template:'<code>{{model.last_seen_ip|default("—")}}</code>'},{key:"last_seen",label:"Last Seen",formatter:"relative",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No bouncer devices tracked yet.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BotSignatureTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bot_signatures",pageName:"Bot Signatures",router:"admin/security/bot-signatures",Collection:BouncerSignatureList,formCreate:F.create,formEdit:F.edit,viewDialogOptions:{size:"lg"},defaultQuery:{sort:"-modified"},columns:[{key:"sig_type",label:"Type",formatter:"badge",sortable:!0,filter:{type:"select",options:["user_agent","ip_pattern","fingerprint","behavior","header","cookie"]}},{key:"value",label:"Value",formatter:"truncate(60)",filter:{type:"text"}},{key:"source",label:"Source",formatter:e=>`<span class="badge ${"auto"===e?"bg-info":"bg-primary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["auto","manual"]}},{key:"confidence",label:"Confidence",sortable:!0,formatter:e=>`${e||0}%`},{key:"hit_count",label:"Hits",sortable:!0},{key:"is_active",label:"Active",formatter:e=>e?'<span class="badge bg-success">ON</span>':'<span class="badge bg-secondary">OFF</span>',filter:{type:"select",options:[{label:"Active",value:"true"},{label:"Inactive",value:"false"}]}},{key:"expires_at",label:"Expires",formatter:'datetime|default("Never")'}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,selectable:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No bot signatures. Click "Add" to create a manual signature.',batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-check-circle",action:"enable"},{label:"Disable",icon:"bi bi-pause-circle",action:"disable"},{label:"Delete",icon:"bi bi-trash",action:"delete"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} signature(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} signature(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Delete ${e.length} signature${e.length>1?"s":""}? This cannot be undone.`)&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} signature(s) deleted`),this.tableView.collection.fetch())}}const $=[{value:"country",label:"Country"},{value:"abuse",label:"Abuse Feed"},{value:"datacenter",label:"Datacenter"},{value:"custom",label:"Custom"}],z=[{value:"ipdeny",label:"IPDeny (Country Zones)"},{value:"abuseipdb",label:"AbuseIPDB"},{value:"manual",label:"Manual"}],j=[{value:"cn",label:"China"},{value:"ru",label:"Russia"},{value:"kp",label:"North Korea"},{value:"ir",label:"Iran"},{value:"ng",label:"Nigeria"},{value:"ro",label:"Romania"},{value:"br",label:"Brazil"},{value:"in",label:"India"},{value:"pk",label:"Pakistan"},{value:"id",label:"Indonesia"},{value:"vn",label:"Vietnam"},{value:"ua",label:"Ukraine"},{value:"th",label:"Thailand"},{value:"ph",label:"Philippines"},{value:"bd",label:"Bangladesh"},{value:"eg",label:"Egypt"},{value:"tr",label:"Turkey"},{value:"mx",label:"Mexico"},{value:"ar",label:"Argentina"},{value:"co",label:"Colombia"}];class IPSet extends t.Model{constructor(e={}){super(e,{endpoint:"/api/incident/ipset"})}}class IPSetList extends t.Collection{constructor(e={}){super({ModelClass:IPSet,endpoint:"/api/incident/ipset",...e})}}const B={create:{title:"Create IP Set",size:"md",fields:[{name:"kind",type:"select",label:"What do you want to block?",required:!0,options:[{value:"country",label:"Country — Block all traffic from a country"},{value:"abuse",label:"Abuse Feed — Import known attacker IPs"},{value:"datacenter",label:"Datacenter — Block datacenter/hosting ranges"},{value:"custom",label:"Custom — Define your own CIDR list"}],value:"country",columns:12},{name:"country_code",type:"select",label:"Country",required:!0,options:j,help:"Select a country to block. CIDRs are fetched automatically from IPDeny.",columns:8,showWhen:{field:"kind",value:"country"}},{name:"source_key",type:"text",label:"API Key",required:!0,placeholder:"Your AbuseIPDB API key",help:"Get a free key at abuseipdb.com. Never stored in plaintext.",columns:12,showWhen:{field:"kind",value:"abuse"}},{name:"source_url",type:"url",label:"Source URL",required:!0,placeholder:"https://example.com/datacenter-ranges.txt",help:"URL to a plain text file with one CIDR per line.",columns:12,showWhen:{field:"kind",value:"datacenter"}},{name:"data",type:"textarea",label:"CIDR List",rows:8,placeholder:"# One CIDR per line\n192.0.2.0/24\n198.51.100.0/24\n203.0.113.0/24",help:"Enter IP ranges in CIDR notation. Lines starting with # are ignored.",columns:12,showWhen:{field:"kind",value:"custom"}},{name:"name",type:"text",label:"Name",required:!0,placeholder:"e.g., abuse_ips, dc_aws",help:"Unique identifier. Used as the kernel ipset name.",columns:6,showWhen:{field:"kind",value:"country",negate:!0}},{name:"description",type:"text",label:"Description",placeholder:"Human-readable label",columns:6,showWhen:{field:"kind",value:"country",negate:!0}},{name:"is_enabled",type:"switch",label:"Enable immediately",value:!0,help:"When enabled, CIDRs are synced to the fleet and traffic is blocked.",columns:4}]},edit:{title:"Edit IP Set",size:"md",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:6},{name:"kind",type:"select",label:"Kind",options:$,disabled:!0,columns:3},{name:"is_enabled",type:"switch",label:"Enabled",columns:3},{name:"description",type:"text",label:"Description",columns:12},{name:"source",type:"select",label:"Source",options:z,columns:6},{name:"source_url",type:"url",label:"Source URL",columns:6},{name:"source_key",type:"text",label:"API Key",placeholder:"Leave blank to keep current key",help:"Write-only — current value is never shown.",columns:12}]}};IPSet.EDIT_FORM=B.edit;class IPSetView extends t.View{constructor(e={}){super({className:"ipset-view",...e}),this.model=e.model||new IPSet(e.data||{});const t=this.model.get("kind")||"",s=$.find(e=>e.value===t);this.kindLabel=s?s.label:t,this.isEnabled=!!this.model.get("is_enabled"),this.enabledLabel=this.isEnabled?"Enabled":"Disabled",this.enabledBadge=this.isEnabled?"bg-success":"bg-secondary",this.template='\n <div class="ipset-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-shield-shaded"></i>\n </div>\n <div>\n <h4 class="mb-1">{{model.name}}</h4>\n {{#model.description}}\n <div class="text-muted mb-1">{{model.description}}</div>\n {{/model.description}}\n <div class="d-flex align-items-center gap-2">\n <span class="badge bg-primary">{{kindLabel}}</span>\n <span class="badge {{enabledBadge}}">{{enabledLabel}}</span>\n {{#model.cidr_count}}\n <span class="badge bg-light text-dark border">{{model.cidr_count}} CIDRs</span>\n {{/model.cidr_count}}\n </div>\n </div>\n </div>\n <div data-container="ipset-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="ipset-tabs"></div>\n </div>\n '}async onInit(){const s=this.model.get("source")||"",i=z.find(e=>e.value===s),n=i?i.label:s,o=this.model.get("last_synced"),l=this.model.get("sync_error"),d=[{name:"name",label:"Name",cols:6},{name:"kind",label:"Kind",template:this.kindLabel,cols:3},{name:"is_enabled",label:"Enabled",formatter:"yesnoicon",cols:3},{name:"description",label:"Description",cols:12},{name:"source",label:"Source",template:n,cols:4},{name:"source_url",label:"Source URL",cols:8},{name:"cidr_count",label:"CIDRs Loaded",cols:4},{name:"last_synced",label:"Last Synced",template:o?new Date(o).toLocaleString():"Never",cols:4},{name:"sync_error",label:"Sync Status",template:l?`<span class="text-danger">${l}</span>`:'<span class="text-success"><i class="bi bi-check-circle me-1"></i>OK</span>',cols:4}];this.configView=new r.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:d}),this.cidrView=new t.View({className:"p-3",ipsetModel:this.model,cidrData:null,cidrLoading:!0,template:'\n {{#cidrLoading|bool}}\n <div class="text-center py-4 text-muted">\n <div class="spinner-border spinner-border-sm me-2" role="status"></div>\n Loading CIDR data...\n </div>\n {{/cidrLoading|bool}}\n {{^cidrLoading|bool}}\n {{#cidrData}}\n <div class="d-flex justify-content-between align-items-center mb-2">\n <span class="text-muted small">{{ipsetModel.cidr_count}} CIDRs loaded</span>\n <button class="btn btn-outline-secondary btn-sm" data-action="copy-cidrs" data-bs-toggle="tooltip" title="Copy to clipboard">\n <i class="bi bi-clipboard me-1"></i>Copy\n </button>\n </div>\n <pre class="bg-light border rounded p-3 small" style="max-height: 500px; overflow-y: auto;"><code>{{cidrData}}</code></pre>\n {{/cidrData}}\n {{^cidrData}}\n <div class="text-center py-5 text-muted">\n <i class="bi bi-database fs-1 mb-2 d-block"></i>\n <p>No CIDRs loaded.</p>\n <button class="btn btn-primary btn-sm" data-action="refresh-source">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh Source\n </button>\n </div>\n {{/cidrData}}\n {{/cidrLoading|bool}}\n ',async onInit(){try{const e=await this.ipsetModel.rest.GET(`${this.ipsetModel.endpoint}/${this.ipsetModel.id}`,{graph:"detailed"});if(e.success||200===e.status){const t=e.data?.data||e.data;this.cidrData=t?.data||null}}catch(e){}this.cidrLoading=!1},async onActionCopyCidrs(){this.cidrData&&(await navigator.clipboard.writeText(this.cidrData),this.getApp()?.toast?.success("CIDRs copied to clipboard"))},async onActionRefreshSource(){const e=await this.ipsetModel.save({refresh_source:1});e.success||200===e.status?this.getApp()?.toast?.success("Refreshing source — this may take a moment"):this.getApp()?.toast?.error("Failed to refresh source")}}),this.tabView=new a.TabView({containerId:"ipset-tabs",tabs:{Configuration:this.configView,"CIDR Data":this.cidrView},activeTab:"Configuration"}),this.addChild(this.tabView);const c=this.model.get("is_enabled"),m=new e.ContextMenu({containerId:"ipset-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Sync to Fleet",action:"sync-fleet",icon:"bi-broadcast"},{label:"Refresh Source",action:"refresh-source",icon:"bi-arrow-clockwise"},{type:"divider"},c?{label:"Disable",action:"disable-ipset",icon:"bi-toggle-off"}:{label:"Enable",action:"enable-ipset",icon:"bi-toggle-on"},{label:"Edit IP Set",action:"edit-ipset",icon:"bi-pencil"},{type:"divider"},{label:"Delete IP Set",action:"delete-ipset",icon:"bi-trash",danger:!0}]}});this.addChild(m)}async onActionSyncFleet(){const e=await this.model.save({sync:1});e.success||200===e.status?this.getApp()?.toast?.success("Syncing to fleet..."):this.getApp()?.toast?.error("Sync failed")}async onActionRefreshSource(){const e=await this.model.save({refresh_source:1});e.success||200===e.status?this.getApp()?.toast?.success("Refreshing source data..."):this.getApp()?.toast?.error("Refresh failed")}async onActionEnableIpset(){const e=await this.model.save({enable:1});e.success||200===e.status?(this.getApp()?.toast?.success("IP Set enabled and synced"),await this.render()):this.getApp()?.toast?.error("Failed to enable")}async onActionDisableIpset(){if(!(await s.Dialog.confirm("Disable this IP set? It will be removed from iptables on all fleet instances.","Disable IP Set")))return;const e=await this.model.save({disable:1});e.success||200===e.status?(this.getApp()?.toast?.success("IP Set disabled and removed from fleet"),await this.render()):this.getApp()?.toast?.error("Failed to disable")}async onActionEditIpset(){await s.Dialog.showModelForm({title:`Edit IP Set — ${this.model.get("name")}`,model:this.model,formConfig:B.edit})&&(await this.render(),this.getApp()?.toast?.success("IP Set updated"))}async onActionDeleteIpset(){if(await s.Dialog.confirm(`Delete IP set "${this.model.get("name")}"? This will remove it from all fleet instances. This cannot be undone.`,"Delete IP Set",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("IP Set deleted"),this.emit("ipset:deleted",{model:this.model});const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}catch(e){this.getApp()?.toast?.error(`Delete failed: ${e.message}`)}}}IPSet.VIEW_CLASS=IPSetView;class IPSetTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_ipsets",pageName:"IP Sets",router:"admin/security/ipsets",Collection:IPSetList,itemViewClass:IPSetView,formEdit:B.edit,onAdd:()=>this._handleAdd(),viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"name"},columns:[{key:"is_enabled",label:"Active",width:"70px",sortable:!0,formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Enabled"},{value:"false",label:"Disabled"}]}},{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",sortable:!0,width:"120px",formatter:e=>{const t=$.find(t=>t.value===e);return`<span class="badge bg-primary bg-opacity-75">${t?t.label:e}</span>`},filter:{type:"select",options:$}},{key:"description",label:"Description",formatter:"truncate(40)|default('—')"},{key:"cidr_count",label:"CIDRs",width:"80px",sortable:!0},{key:"source",label:"Source",width:"110px",formatter:e=>{const t=z.find(t=>t.value===e);return t?t.label:e||"—"}},{key:"last_synced|datetime",label:"Last Synced",width:"160px",sortable:!0},{key:"sync_error",label:"Status",width:"80px",formatter:e=>e?'<span class="text-danger" title="'+e+'"><i class="bi bi-exclamation-triangle"></i> Error</span>':'<span class="text-success"><i class="bi bi-check-circle"></i></span>'}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No IP sets configured. Create one to start blocking traffic at the network level.",batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-toggle-on",action:"enable"},{label:"Disable",icon:"bi bi-toggle-off",action:"disable"},{label:"Sync to Fleet",icon:"bi bi-broadcast",action:"sync"},{label:"Refresh Source",icon:"bi bi-arrow-clockwise",action:"refresh"},{label:"Delete",icon:"bi bi-trash",action:"delete",danger:!0}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async _handleAdd(){const e=await l.default.form({...B.create});if(!e)return;if("country"===e.kind&&e.country_code){const t=e.country_code,s=j.find(e=>e.value===t);e.name=`country_${t}`,e.source="ipdeny",e.description=s?`Country block: ${s.label}`:`Country block: ${t.toUpperCase()}`,delete e.country_code}else"abuse"===e.kind?e.source="abuseipdb":"datacenter"!==e.kind&&"custom"!==e.kind||(e.source="manual");const t=new IPSet,s=await t.save(e);s?.data?.status?(this.getApp()?.toast?.success("IP Set created"),this.tableView?.collection?.fetch()):l.default.showError(s?.data?.error||"Failed to create IP Set")}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Enable ${e.length} IP set(s)? They will be synced to the fleet.`)&&(await Promise.all(e.map(e=>e.model.save({enable:1}))),this.getApp().toast.success(`${e.length} IP set(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Disable ${e.length} IP set(s)? They will be removed from the fleet.`)&&(await Promise.all(e.map(e=>e.model.save({disable:1}))),this.getApp().toast.success(`${e.length} IP set(s) disabled`),this.tableView.collection.fetch())}async onActionBatchSync(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Sync ${e.length} IP set(s) to all fleet instances?`)&&(await Promise.all(e.map(e=>e.model.save({sync:1}))),this.getApp().toast.success(`${e.length} IP set(s) syncing to fleet`),this.tableView.collection.fetch())}async onActionBatchRefresh(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Refresh source data for ${e.length} IP set(s)?`)&&(await Promise.all(e.map(e=>e.model.save({refresh_source:1}))),this.getApp().toast.success(`${e.length} IP set(s) refreshing`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await s.Dialog.confirm(`Delete ${e.length} IP set(s)? They will be removed from all fleet instances. This cannot be undone.`,"Delete IP Sets",{confirmText:"Delete",confirmClass:"btn-danger"})&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} IP set(s) deleted`),this.tableView.collection.fetch())}}class LogTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_logs",pageName:"Manage Logs",router:"admin/logs",Collection:n.LogList,itemViewClass:LogView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"created|epoch|datetime",label:"Timestamp",sortable:!0,filter:{type:"daterange"}},{key:"level",label:"Level",sortable:!0,formatter:"badge",filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{key:"method",label:"Method",filter:{type:"text"}},{key:"path",label:"Path",filter:{type:"text"}},{key:"username",label:"User",filter:{type:"text"}},{key:"ip",label:"IP",filter:{type:"text"}},{key:"duid",label:"Browser ID",formatter:"truncate_middle(16)",filter:{type:"text"}}],defaultQuery:{sort:"-created"},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No log entries found.",batchBarLocation:"top",batchActions:[{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Archive",icon:"bi bi-archive",action:"batch-archive"},{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Mark as Reviewed",icon:"bi bi-check2",action:"batch-reviewed"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class MetricsPermissionsView extends t.View{constructor(e={}){super({className:"metrics-permissions-view",...e}),this.model=e.model||new a.MetricsPermission(e.data||{}),this.template='\n <div class="container p-3">\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div>\n <h3 class="mb-1">Permissions for {{model.account}}</h3>\n </div>\n <div data-container="context-menu"></div>\n </div>\n <div data-container="data-view"></div>\n </div>\n '}async onInit(){this.dataView=new r.default({containerId:"data-view",model:this.model,fields:[{name:"view_permissions",label:"View Permissions",format:"list|badge"},{name:"write_permissions",label:"Write Permissions",format:"list|badge"}]}),this.addChild(this.dataView);const t=new e.ContextMenu({containerId:"context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit",icon:"bi-pencil"},{label:"Delete",action:"delete",icon:"bi-trash",danger:!0}]}});this.addChild(t)}async onActionEdit(){const e=await s.Dialog.showModelForm({title:`Edit Permissions for ${this.model.get("account")}`,model:this.model,formConfig:a.MetricsForms.edit});e&&(this.model.set(e.data.data),this.render())}async onActionDelete(){await s.Dialog.confirm(`Are you sure you want to delete all permissions for ${this.model.get("account")}?`)&&(await this.model.destroy(),this.emit("deleted",this.model))}}a.MetricsPermission.VIEW_CLASS=MetricsPermissionsView;class MetricsPermissionsTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_metrics_permissions",pageName:"Metrics Permissions",router:"admin/metrics/permissions",Collection:a.MetricsPermissionList,formEdit:a.MetricsForms.edit,itemViewClass:MetricsPermissionsView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"account",label:"Account",sortable:!0},{key:"view_permissions",label:"View Permissions",formatter:"list|badge"},{key:"write_permissions",label:"Write Permissions",formatter:"list|badge"}],selectable:!0,searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No metrics permissions found.",emptyIcon:"bi-bar-chart-line",actions:["view","edit","delete"]}})}}class Setting extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/settings",...t})}}class SettingList extends t.Collection{constructor(e={}){super({ModelClass:Setting,endpoint:"/api/settings",size:25,...e})}}const O={create:{title:"Create Setting",fields:[{name:"key",type:"text",label:"Key",placeholder:"WEBHOOK_SECRET",required:!0,columns:12,help:"A unique configuration key name."},{name:"value",type:"textarea",label:"Value",required:!0,columns:12,help:"The configuration value. For secrets, this will be masked after creation."},{type:"collection",name:"parent",label:"Parent Group",Collection:s.GroupList,labelField:"name",valueField:"id",maxItems:10,placeholder:"Search groups...",emptyFetch:!1,debounceMs:300,columns:12},{name:"is_secret",type:"switch",label:"Secret",columns:6,help:"Mark as secret to mask the value in API responses."}]},edit:{title:"Edit Setting",fields:[{name:"key",type:"text",label:"Key",columns:12,disabled:!0},{name:"value",type:"textarea",label:"Value",columns:12,help:"Enter a new value to replace the current one."},{name:"is_secret",type:"switch",label:"Secret",columns:12,help:"Mark as secret to mask the value in API responses."},{type:"collection",name:"parent",label:"Parent Group",Collection:s.GroupList,labelField:"name",valueField:"id",maxItems:10,placeholder:"Search groups...",emptyFetch:!1,debounceMs:300,columns:12}]}};class SettingView extends t.View{constructor(e={}){super({className:"setting-view",...e}),this.model=e.model||new Setting(e.data||{}),this.template='\n <div class="setting-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left: Icon & Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-gear"></i>\n </div>\n <div>\n <h3 class="mb-1 font-monospace">{{model.key|default(\'Unnamed Setting\')}}</h3>\n <div class="text-muted small">\n ID: {{model.id}}\n <span class="mx-2">|</span>\n Scope: {{model.group.name|default(\'Global\')}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.is_secret|boolean(\'bg-warning text-dark\',\'bg-secondary\')}}">\n {{model.is_secret|boolean(\'Secret\',\'Plain\')}}\n </span>\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Meta & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|datetime}}</div>\n </div>\n <div data-container="setting-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Key</h6>\n <p class="mb-0 font-monospace">{{model.key}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Value</h6>\n {{#model.is_secret|bool}}\n <p class="mb-0 text-muted font-monospace">{{model.display_value|default(\'******\')}}</p>\n <small class="text-muted">This is a secret value. Enter a new value to replace it.</small>\n {{/model.is_secret|bool}}\n {{^model.is_secret|bool}}\n <p class="mb-0 font-monospace">{{model.value|default(model.display_value)|default(\'—\')}}</p>\n {{/model.is_secret|bool}}\n </div>\n {{#model.group}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Group</h6>\n <p class="mb-0">{{model.group.name|default(model.group)}}</p>\n </div>\n {{/model.group}}\n </div>\n </div>\n '}async onInit(){const t=new e.ContextMenu({containerId:"setting-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-setting",icon:"bi-pencil"},{type:"divider"},{label:"Delete Setting",action:"delete-setting",icon:"bi-trash",danger:!0}]}});this.addChild(t)}async onActionEditSetting(){const e=this.getApp();await e.showModelForm({title:`Edit Setting — ${this.model.get("key")}`,model:this.model,formConfig:O.edit})&&this.render()}async onActionDeleteSetting(){const e=this.getApp();if(!(await e.confirm({title:"Delete Setting",message:`Permanently delete "${this.model.get("key")}"? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("Setting deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete setting")}}Setting.VIEW_CLASS=SettingView,Setting.ADD_FORM=O.create,Setting.EDIT_FORM=O.edit;class SettingTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_settings",pageName:"Settings",router:"admin/settings",Collection:SettingList,itemViewClass:SettingView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"key",label:"Key",sortable:!0},{key:"display_value",label:"Value",formatter:"default('—')"},{key:"group.name",label:"Group",sortable:!0,formatter:"default('Global')"},{key:"is_secret",label:"Secret",formatter:"boolean('Secret|bg-warning text-dark','Plain|bg-secondary')|badge",width:"100px"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}],defaultQuery:{sort:"key"},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New Setting",emptyMessage:"No settings found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class FileManagerTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_file_managers",pageName:"Manage Storage Backends",router:"admin/file-managers",Collection:s.FileManagerList,formCreate:s.FileManagerForms.create,formEdit:s.FileManagerForms.edit,columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Name",formatter:"default('Unnamed Backend')"},{key:"backend_url",label:"Backend URL",sortable:!0},{key:"is_default",label:"Default",formatter:"boolean|badge"},{key:"is_active",label:"Active",formatter:"boolean|badge"},{key:"is_public",label:"Public",formatter:"boolean|badge"},{key:"backend_type",label:"Type",formatter:"default('Unknown')"},{key:"created",label:"Created",formatter:"epoch|datetime"}],contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Name"},{icon:"bi-shield",action:"edit-credentials",label:"Edit Credentials"},{icon:"bi-person",action:"edit-owners",label:"Edit Owners"},{divider:!0},{icon:"bi-copy",action:"clone",label:"Clone Manager"},{divider:!0},{icon:"bi-check",action:"test-connection",label:"Test Connection"},{icon:"bi-question-circle",action:"check-cors",label:"Check CORS"},{icon:"bi-wrench",action:"fix-cors",label:"Fix CORS"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No storage backends found. Click "Add Storage Backend" to configure your first backend.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditOwners(e,t){const i=this.collection.get(t.dataset.id),a=await s.Dialog.showModelForm({title:"Edit Owners",model:i,fields:s.FileManagerForms.owners.fields});if(!a)return!0;a.success?this.getApp().toast.success("Owners Updated successfully"):this.getApp().toast.error("Owners update failed")}async onActionCheckCors(e,t){const i=this.collection.get(t.dataset.id),a=await i.save({check_cors:!0});return a.success&&a.data.status?await s.Dialog.showData({title:`Audit Report - ${i._.name}`,data:a.data,size:"lg"}):this.getApp().toast.error("Connection test failed"),!0}async onActionTestConnection(e,t){const s=this.collection.get(t.dataset.id),i=await s.save({test_connection:!0});return i.success&&i.data.status?this.getApp().toast.success("Connection test successful"):this.getApp().toast.error("Connection test failed"),!0}async onActionEditCredentials(e,t){const i=this.collection.get(t.dataset.id),a=await s.Dialog.showModelForm({title:"Edit Credentials",model:i,fields:s.FileManagerForms.credentials.fields});return!a||(a.success&&a.data.status?this.getApp().toast.success("Credentials updated successfully"):this.getApp().toast.error("Failed to update credentials"),!0)}async onActionClone(e,t){if(!(await s.Dialog.showConfirm({title:"Clone File Manager",message:"This will create a clone with the same credentials."})))return!0;const i=this.collection.get(t.dataset.id),a=await i.save({clone:!0});return a.success&&a.data.status?(this.getApp().toast.success("Connection cloned successfully"),this.collection.fetch()):this.getApp().toast.error("Failed to clone connection"),!0}}class FileView extends t.View{constructor(e={}){super({className:"file-view",...e}),this.model=e.model||new s.File(e.data||{}),this.isImage="image"===this.model.get("category");const i=this.model.get("renditions")||{};this.renditionsCollection=new t.Collection(Object.values(i)),this.template='\n <div class="file-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Thumbnail & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="file-thumbnail" style="width: 80px; height: 80px;">\n {{#isImage}}\n <a href="{{model.url}}" target="_blank" title="View original file">\n <img src="{{model.renditions.thumbnail.url|default(model.url)}}" class="img-fluid rounded" style="width: 80px; height: 80px; object-fit: cover;">\n </a>\n {{/isImage}}\n {{^isImage}}\n <div class="avatar-placeholder rounded bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">\n <i class="bi bi-file-earmark-text text-secondary" style="font-size: 40px;"></i>\n </div>\n {{/isImage}}\n </div>\n <div>\n <h3 class="mb-1" style="word-break: break-all;">{{model.filename|truncate(40)}}</h3>\n <div class="text-muted small">\n <span><i class="bi bi-hdd"></i> {{model.file_size|filesize}}</span>\n <span class="mx-2">|</span>\n <span>{{model.content_type}}</span>\n </div>\n <div class="text-muted small mt-1">\n Uploaded: {{model.created|datetime}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center gap-2 justify-content-end">\n <span class="badge {{model.upload_status|badge}}">{{model.upload_status|capitalize}}</span>\n </div>\n <div class="text-muted small mt-1">\n Public: {{{model.is_public|yesnoicon}}}\n </div>\n </div>\n <div data-container="file-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tab Container --\x3e\n <div data-container="file-tabs"></div>\n </div>\n '}async onInit(){this.infoView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"ID"},{name:"filename",label:"Filename"},{name:"storage_filename",label:"Storage Filename"},{name:"content_type",label:"Content Type"},{name:"file_size",label:"File Size",format:"filesize"},{name:"category",label:"Category"},{name:"upload_status",label:"Status",format:"badge"},{name:"created",label:"Created",format:"datetime"},{name:"modified",label:"Modified",format:"datetime"},{name:"user.display_name",label:"Uploaded By"},{name:"file_manager.name",label:"Storage Backend"},{name:"storage_file_path",label:"Storage Path"},{name:"url",label:"Public URL",format:"url"},{name:"is_public",label:"Is Public",format:"boolean"}]}),this.renditionsView=new n.TableView({collection:this.renditionsCollection,columns:[{key:"role",label:"Role",formatter:"badge"},{key:"filename",label:"Filename",formatter:"truncate(40)"},{key:"file_size",label:"Size",formatter:"filesize"},{key:"content_type",label:"Content Type"},{key:"actions",label:"Actions",template:'\n <a href="{{url}}" target="_blank" class="btn btn-sm btn-outline-primary" title="View">\n <i class="bi bi-eye"></i>\n </a>\n <a href="{{url}}" download="{{filename}}" class="btn btn-sm btn-outline-secondary" title="Download">\n <i class="bi bi-download"></i>\n </a>\n '}]});const t={Info:this.infoView};t.Renditions=this.renditionsView,this.tabView=new a.TabView({tabs:t,activeTab:"Info",containerId:"file-tabs"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"file-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View",action:"view-file",icon:"bi-eye"},{label:"Download",action:"download-file",icon:"bi bi-download"},{label:"Edit Details",action:"edit-file",icon:"bi bi-pencil"},{type:"divider"},this.model.get("is_public")?{label:"Make Private",action:"make-private",icon:"bi bi-lock"}:{label:"Make Public",action:"make-public",icon:"bi bi-unlock"},{type:"divider"},{label:"Delete File",action:"delete-file",icon:"bi bi-trash",danger:!0}]}});this.addChild(s)}async onActionViewFile(){const e=this.model.get("content_type"),t=this.model.get("url");if(e.startsWith("image/")){const e=this.model.get("renditions")||{},s=[{src:t,alt:"Original"},...Object.values(e).map(e=>({src:e.url,alt:e.role}))];c.LightboxGallery.show(s,{fitToScreen:!1})}else"application/pdf"===e?c.PDFViewer.showDialog(t,{title:this.model.get("filename")}):window.open(t,"_blank")}async onActionDownloadFile(){const e=this.model.get("url");if(e){const t=document.createElement("a");t.href=e,t.download=this.model.get("filename"),document.body.appendChild(t),t.click(),document.body.removeChild(t)}}async onActionEditFile(){await s.Dialog.showModelForm({title:`Edit File - ${this.model.get("filename")}`,model:this.model,formConfig:s.FileForms.edit})&&this.render()}async onActionMakePublic(){await this.model.save({is_public:!0}),this.render()}async onActionMakePrivate(){await this.model.save({is_public:!1}),this.render()}async onActionDeleteFile(){await s.Dialog.confirm(`Are you sure you want to delete the file "${this.model.get("filename")}"? This action cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("file:deleted",{model:this.model})}}s.File.VIEW_CLASS=FileView;class FileTablePage extends a.TablePage{constructor(e={}){super({name:"admin_files",pageName:"Manage Files",router:"admin/files",Collection:s.FileList,formEdit:s.FileForms.edit,itemViewClass:FileView,onAdd:async e=>{await this.handleFileUpload(e)},viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"filename",label:"Filename"},{key:"content_type",label:"Type",formatter:"default('Unknown')"},{key:"file_size",label:"Size",formatter:"filesize"},{key:"group.name",label:"Group",formatter:"default('No Group')"},{key:"upload_status",label:"Status",formatter:"badge"},{key:"created",label:"Uploaded",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No files found. Click "Add File" to upload your first file.',batchBarLocation:"top",batchActions:[{label:"Download",icon:"bi bi-download",action:"batch-download"},{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Move to Group",icon:"bi bi-folder",action:"batch-move"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e}),this.enableFileDrop({acceptedTypes:["*/*"],maxFileSize:104857600,multiple:!1,validateOnDrop:!0})}async handleFileUpload(e){e&&e.preventDefault();const t=document.createElement("input");t.type="file",t.accept="*/*",t.multiple=!1,t.style.display="none",t.addEventListener("change",async e=>{const i=e.target.files[0];if(!i)return;const a=104857600;if(i.size>a)this.showError(`File size (${this._formatFileSize(i.size)}) exceeds maximum (${this._formatFileSize(a)})`);else try{const e=new s.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const a=e.upload({file:i,name:i.name,description:`File uploaded on ${/* @__PURE__ */(new Date).toLocaleDateString()}`,showToast:!0,onProgress:e=>{e.percentage},onComplete:e=>{this.refresh()},onError:e=>{console.error("Upload failed:",e),this.showError("Upload failed: "+e.message)},...t});await a}catch(n){console.error("Error starting file upload:",n),this.showError("Failed to start file upload: "+n.message)}finally{t.remove()}}),document.body.appendChild(t),t.click()}_formatFileSize(e){if(0===e)return"0 Bytes";const t=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,t)).toFixed(2))+" "+["Bytes","KB","MB","GB"][t]}async onFileDrop(e,t,i){const a=e[0];a.name,a.type,a.size;try{const e=new s.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const i=e.upload({file:a,name:a.name,description:`File uploaded via drag & drop on ${/* @__PURE__ */(new Date).toLocaleDateString()}`,showToast:!0,onProgress:e=>{e.percentage},onComplete:e=>{this.refresh()},onError:e=>{console.error("Upload failed:",e),this.showError("Upload failed: "+e.message)},...t});await i}catch(n){console.error("Error starting file upload:",n),this.showError("Failed to start file upload: "+n.message)}}}o.applyFileDropMixin(FileTablePage);class S3BucketTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_s3_buckets",pageName:"Manage S3 Buckets",router:"admin/s3-buckets",Collection:a.S3BucketList,formCreate:a.S3BucketForms.create,formEdit:a.S3BucketForms.edit,columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"name",label:"Bucket Name",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No S3 buckets found. Click "Add S3 Bucket" to create your first bucket.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Make Public",icon:"bi bi-unlock",action:"batch-public"},{label:"Make Private",icon:"bi bi-lock",action:"batch-private"},{label:"Empty Bucket",icon:"bi bi-bucket",action:"batch-empty"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class UserDeviceLocationView extends t.View{constructor(e={}){super({className:"udl-view",...e}),this.model=e.model||new s.UserDeviceLocation(e.data||{}),this._ud=this.model.get("user_device")||{},this._di=this._ud.device_info||{},this._geo=this.model.get("geolocation")||{},this.deviceIcon=this._getDeviceIcon(),this.browserFull=this._getBrowser(),this.osFull=this._getOS(),this.deviceFull=this._getDevice(),this.locationSummary=this._getLocationSummary(),this.countryFlag=this._geo.country_code||"",this.threatLevel=this._geo.threat_level||"unknown",this.threatColor=this._getThreatColor(),this.hasCoordinates=!(!this._geo.latitude||!this._geo.longitude),this.template='\n <style>\n .udl-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }\n .udl-identity { display: flex; align-items: center; gap: 1rem; }\n .udl-icon-wrap { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; flex-shrink: 0; }\n .udl-title { font-size: 1.15rem; font-weight: 600; margin: 0; line-height: 1.3; }\n .udl-subtitle { font-size: 0.8rem; color: #6c757d; margin-top: 0.15rem; }\n .udl-right { display: flex; align-items: flex-start; gap: 0.75rem; }\n .udl-threat-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.04em; }\n .udl-threat-value { font-size: 1rem; font-weight: 600; }\n .udl-threat-flags { display: flex; gap: 0.35rem; margin-top: 0.35rem; }\n .udl-flag { font-size: 0.65rem; padding: 0.15em 0.45em; border-radius: 3px; font-weight: 600; }\n </style>\n\n <div class="udl-header">\n <div class="udl-identity">\n <div class="udl-icon-wrap bg-primary bg-opacity-10 text-primary">\n <i class="bi {{deviceIcon}}"></i>\n </div>\n <div>\n <h4 class="udl-title">{{locationSummary}}</h4>\n <div class="udl-subtitle">\n {{browserFull}} <span class="text-muted">on</span> {{deviceFull}}\n <span class="text-muted mx-1">·</span>\n {{model.ip_address}}\n </div>\n </div>\n </div>\n <div class="udl-right">\n <div class="text-end">\n <div class="udl-threat-label">Threat Level</div>\n <div class="udl-threat-value {{threatColor}}">{{threatLevel|capitalize}}</div>\n <div class="udl-threat-flags">\n {{#_geo.is_vpn}}<span class="udl-flag bg-warning text-dark">VPN</span>{{/_geo.is_vpn}}\n {{#_geo.is_tor}}<span class="udl-flag bg-danger text-white">Tor</span>{{/_geo.is_tor}}\n {{#_geo.is_proxy}}<span class="udl-flag bg-warning text-dark">Proxy</span>{{/_geo.is_proxy}}\n {{#_geo.is_datacenter}}<span class="udl-flag bg-secondary text-white">DC</span>{{/_geo.is_datacenter}}\n {{#_geo.is_cloud}}<span class="udl-flag bg-info text-white">Cloud</span>{{/_geo.is_cloud}}\n </div>\n </div>\n <div data-container="udl-context-menu"></div>\n </div>\n </div>\n\n <div data-container="udl-sidenav" style="min-height: 300px;"></div>\n '}_getDeviceIcon(){const e=this._di?.user_agent?.family?.toLowerCase()||"",t=this._di?.os?.family?.toLowerCase()||"",s=this._di?.device?.family?.toLowerCase()||"";return e.includes("chrome")?"bi-browser-chrome":e.includes("firefox")?"bi-browser-firefox":e.includes("safari")?"bi-browser-safari":e.includes("edge")?"bi-browser-edge":t.includes("mac")||t.includes("ios")?"bi-apple":t.includes("windows")?"bi-windows":t.includes("android")?"bi-android2":s.includes("iphone")?"bi-phone":s.includes("ipad")?"bi-tablet":"bi-geo-alt"}_getBrowser(){const e=this._di?.user_agent||{};return e.family?`${e.family} ${e.major||""}`.trim():"Unknown Browser"}_getOS(){const e=this._di?.os||{},t=[e.major,e.minor].filter(Boolean).join(".");return e.family?`${e.family} ${t}`.trim():"Unknown OS"}_getDevice(){const e=this._di?.device||{},t=[e.brand,e.family].filter(Boolean);return t.length?t.join(" "):"Unknown Device"}_getLocationSummary(){const e=[this._geo.city,this._geo.region,this._geo.country_name].filter(Boolean);return e.length?e.join(", "):"Unknown Location"}_getThreatColor(){const e=(this._geo.threat_level||"").toLowerCase();return"high"===e||this._geo.is_threat?"text-danger":"medium"===e||this._geo.is_suspicious?"text-warning":"low"===e?"text-success":"text-muted"}async onInit(){const s=this._geo,i=this._di,o=[{key:"location",label:"Location",icon:"bi-geo-alt",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n </style>\n\n <div class="udl-section-label">Geography</div>\n <div class="udl-field-row">\n <div class="udl-field-label">City</div>\n <div class="udl-field-value">${s.city||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Region</div>\n <div class="udl-field-value">${s.region||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Country</div>\n <div class="udl-field-value">${s.country_name||"—"} ${s.country_code?`<span class="text-muted">(${s.country_code})</span>`:""}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Postal Code</div>\n <div class="udl-field-value">${s.postal_code||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Timezone</div>\n <div class="udl-field-value">${s.timezone||"—"}</div>\n </div>\n ${s.latitude?`\n <div class="udl-field-row">\n <div class="udl-field-label">Coordinates</div>\n <div class="udl-field-value">${s.latitude}, ${s.longitude}</div>\n </div>`:""}\n\n <div class="udl-section-label">Network</div>\n <div class="udl-field-row">\n <div class="udl-field-label">IP Address</div>\n <div class="udl-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.ip_address}}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ISP</div>\n <div class="udl-field-value">${s.isp||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ASN</div>\n <div class="udl-field-value">${s.asn||"—"} ${s.asn_org?`<span class="text-muted small">(${s.asn_org})</span>`:""}</div>\n </div>\n\n <div class="udl-section-label">Timestamps</div>\n <div class="udl-field-row">\n <div class="udl-field-label">First Seen</div>\n <div class="udl-field-value">{{model.first_seen|epoch|datetime|default('—')}}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Last Seen</div>\n <div class="udl-field-value">{{model.last_seen|epoch|datetime|default('—')}}</div>\n </div>\n `})},{key:"device",label:"Device",icon:"bi-laptop",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .udl-ua-string { font-family: ui-monospace, monospace; font-size: 0.73rem; color: #6c757d; word-break: break-all; line-height: 1.5; padding: 0.5rem 0.75rem; background: #f8f9fa; border-radius: 6px; margin-top: 0.25rem; }\n </style>\n\n <div class="udl-section-label">Browser</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Name</div>\n <div class="udl-field-value">${i?.user_agent?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Version</div>\n <div class="udl-field-value">${[i?.user_agent?.major,i?.user_agent?.minor,i?.user_agent?.patch].filter(Boolean).join(".")||"—"}</div>\n </div>\n\n <div class="udl-section-label">Operating System</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Name</div>\n <div class="udl-field-value">${i?.os?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Version</div>\n <div class="udl-field-value">${[i?.os?.major,i?.os?.minor,i?.os?.patch].filter(Boolean).join(".")||"—"}</div>\n </div>\n\n <div class="udl-section-label">Hardware</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Brand</div>\n <div class="udl-field-value">${i?.device?.brand||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Family</div>\n <div class="udl-field-value">${i?.device?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Model</div>\n <div class="udl-field-value">${i?.device?.model||"—"}</div>\n </div>\n\n ${i?.string?`\n <div class="udl-section-label">User Agent String</div>\n <div class="udl-ua-string">${i.string}</div>`:""}\n `})},{key:"risk",label:"Risk",icon:"bi-shield-exclamation",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .udl-risk-icon { font-size: 0.85rem; margin-right: 0.35rem; }\n .udl-risk-yes { color: #dc3545; }\n .udl-risk-no { color: #adb5bd; }\n </style>\n\n <div class="udl-section-label">Threat Assessment</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Threat Level</div>\n <div class="udl-field-value ${this.threatColor}" style="font-weight: 600;">${s.threat_level||"Unknown"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Risk Score</div>\n <div class="udl-field-value">${null!=s.risk_score?s.risk_score:"—"}</div>\n </div>\n\n <div class="udl-section-label">Detection Flags</div>\n ${this._riskRow("VPN","bi-shield",s.is_vpn)}\n ${this._riskRow("Tor Exit Node","bi-shield-lock",s.is_tor)}\n ${this._riskRow("Proxy","bi-diagram-3",s.is_proxy)}\n ${this._riskRow("Cloud Provider","bi-cloud",s.is_cloud)}\n ${this._riskRow("Datacenter","bi-hdd-stack",s.is_datacenter)}\n ${this._riskRow("Mobile","bi-phone",s.is_mobile)}\n\n <div class="udl-section-label">Reputation</div>\n ${this._riskRow("Known Attacker","bi-exclamation-triangle",s.is_known_attacker)}\n ${this._riskRow("Known Abuser","bi-flag",s.is_known_abuser)}\n ${this._riskRow("Threat","bi-shield-exclamation",s.is_threat)}\n ${this._riskRow("Suspicious","bi-question-circle",s.is_suspicious)}\n `})}];if(this.hasCoordinates)try{const e=new(0,(await Promise.resolve().then(()=>require("./chunks/MetricsCountryMapView-ww-c8cxk.js")).then(e=>e.MapView$1)).default)({markers:[{lat:this._geo.latitude,lng:this._geo.longitude,popup:`<strong>${this.model.get("ip_address")}</strong><br>${this.locationSummary}`}],tileLayer:"light",zoom:6,height:400});o.push({key:"map",label:"Map",icon:"bi-map",view:e})}catch(d){}const l=this.model.get("ip_address");if(l){const e=new n.TableView({collection:new a.IncidentEventList({params:{size:10,source_ip:l}}),hideActivePillNames:["source_ip"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]});o.push({type:"divider",label:"Activity"}),o.push({key:"events",label:"Events",icon:"bi-calendar-event",view:e});const t=new n.TableView({collection:new n.LogList({params:{size:10,ip:l}}),permissions:"view_logs",hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime"},{key:"level",label:"Level",sortable:!0},{key:"kind",label:"Kind"},{name:"log",label:"Log"}]});o.push({key:"logs",label:"Logs",icon:"bi-journal-text",view:t,permissions:"view_logs"})}this.sideNavView=new SideNavView({containerId:"udl-sidenav",activeSection:this.hasCoordinates?"map":"location",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:o}),this.addChild(this.sideNavView);const r=new e.ContextMenu({containerId:"udl-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[...this._ud?.user?[{label:"View User",action:"view-user",icon:"bi-person"}]:[],...this._ud?.id?[{label:"View Device",action:"view-device",icon:"bi-laptop"}]:[],...this.hasCoordinates?[{label:"Open in Maps",action:"open-in-maps",icon:"bi-box-arrow-up-right"}]:[],{type:"divider"},{label:"Delete Record",action:"delete-record",icon:"bi-trash",danger:!0}]}});this.addChild(r)}_riskRow(e,t,s){return`\n <div class="udl-field-row">\n <div class="udl-field-label"><i class="bi ${t} me-1 ${s?"udl-risk-yes":"udl-risk-no"}"></i>${e}</div>\n <div class="udl-field-value">${s?'<i class="bi bi-check-circle-fill udl-risk-icon udl-risk-yes"></i>Yes':'<i class="bi bi-dash-circle udl-risk-icon udl-risk-no"></i>No'}</div>\n </div>`}async onActionViewUser(){const e=this._ud?.user?.id||this._ud?.user;e&&this.emit("view-user",{userId:e})}async onActionViewDevice(){const e=this._ud?.id;e&&this.emit("view-device",{deviceId:e})}async onActionOpenInMaps(){this.hasCoordinates&&window.open(`https://www.google.com/maps/search/?api=1&query=${this._geo.latitude},${this._geo.longitude}`,"_blank")}async onActionDeleteRecord(){return!(await s.Dialog.confirm("Are you sure you want to delete this location record?","Delete Location Record"))||((await this.model.destroy()).success&&this.emit("location:deleted",{model:this.model}),!0)}static async show(e){const t=new s.UserDeviceLocation({id:e});return await t.fetch(),t.id?s.Dialog.showDialog({title:!1,size:"lg",body:new UserDeviceLocationView({model:t}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(s.Dialog.alert({message:`Could not find location record: ${e}`,type:"warning"}),null)}}s.UserDeviceLocation.VIEW_CLASS=UserDeviceLocationView;const U={ec2:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"memory",label:"Memory Usage",unit:"%"},{key:"disk",label:"Disk Usage",unit:"%"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"},{key:"disk_read",label:"Disk Read Ops",unit:"ops"},{key:"disk_write",label:"Disk Write Ops",unit:"ops"},{key:"status_check",label:"Status Check",unit:""}],rds:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"conns",label:"Active Connections",unit:""},{key:"free_storage",label:"Free Storage",unit:"bytes"},{key:"free_memory",label:"Freeable Memory",unit:"bytes"},{key:"read_iops",label:"Read IOPS",unit:"ops/s"},{key:"write_iops",label:"Write IOPS",unit:"ops/s"},{key:"read_latency",label:"Read Latency",unit:"s"},{key:"write_latency",label:"Write Latency",unit:"s"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"}],redis:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"conns",label:"Current Connections",unit:""},{key:"cache_memory",label:"Cache Memory Used",unit:"bytes"},{key:"cache_hits",label:"Cache Hits",unit:""},{key:"cache_misses",label:"Cache Misses",unit:""},{key:"replication_lag",label:"Replication Lag",unit:"s"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"}]},q={ec2:"bi-pc-display",rds:"bi-database",redis:"bi-lightning-charge"},H={ec2:"EC2 Instance",rds:"RDS Database",redis:"ElastiCache Redis"};function W(e){return"%"===e?{label:"%",beginAtZero:!0,max:100}:"bytes"===e?{label:"Bytes",beginAtZero:!0}:"s"===e?{label:"Seconds",beginAtZero:!0}:{beginAtZero:!0}}class CloudWatchResourceView extends t.View{constructor(e={}){super({className:"cloudwatch-resource-view",...e}),this.resourceType=e.resourceType||"ec2",this.slug=e.slug||"",this.resource=e.resource||{}}async getTemplate(){const e=U[this.resourceType]||[],t=q[this.resourceType]||"bi-cloud",s=H[this.resourceType]||"Resource";this.resource.state||this.resource.status;const i=this._buildMetaItems().map(e=>`<span class="me-3" style="font-size: 0.78rem; color: #6c757d;">${e}</span>`).join("");return`\n <style>\n .cwrv-header { padding: 1rem 0; border-bottom: 1px solid #e9ecef; margin-bottom: 1rem; }\n .cwrv-name { font-size: 1.15rem; font-weight: 700; }\n .cwrv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n @media (max-width: 768px) { .cwrv-grid { grid-template-columns: 1fr; } }\n </style>\n\n <div class="cwrv-header">\n <div class="cwrv-name">\n <i class="bi ${t} me-2"></i>${this.slug}\n <span class="badge bg-secondary ms-2" style="font-size: 0.7rem;">${s}</span>\n </div>\n <div class="mt-1">${i}</div>\n </div>\n\n <div class="cwrv-grid">\n ${e.map((e,t)=>`<div id="cwrv-chart-${t}"></div>`).join("")}\n </div>\n `}async onInit(){const e=U[this.resourceType]||[];for(let t=0;t<e.length;t++){const s=e[t],i=new CloudWatchChart({containerId:`cwrv-chart-${t}`,account:this.resourceType,category:s.key,slug:this.slug,title:s.label,height:200,yAxis:W(s.unit),showGranularity:!0,showDateRange:!1,defaultDateRange:"24h",granularity:"hours"});this.addChild(i)}}_buildMetaItems(){const e=this.resource;switch(this.resourceType){case"ec2":return[e.instance_type,e.private_ip,e.public_ip].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-cpu","bi-hdd-network","bi-globe"][t]} me-1"></i>${e}`);case"rds":return[e.engine,e.instance_class].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-database","bi-cpu"][t]} me-1"></i>${e}`);case"redis":return[e.engine,e.node_type,e.num_nodes?`${e.num_nodes} node${e.num_nodes>1?"s":""}`:""].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-lightning","bi-cpu","bi-diagram-3"][t]} me-1"></i>${e}`);default:return[]}}static async show(e,t,i={},a={}){const n=new CloudWatchResourceView({resourceType:e,slug:t,resource:i}),o=q[e]||"bi-cloud",l=H[e]||"Resource";await s.Dialog.showDialog(n,{header:`<i class="bi ${o} me-2"></i>${t} <small class="text-muted">— ${l}</small>`,size:"xl",scrollable:!0})}}class AssistantConversationListView extends t.View{constructor(e={}){super({className:"assistant-conversation-list",...e}),this.collection=e.collection,this.activeId=null}getTemplate(){return'\n <div class="conversation-list-header">\n <button class="btn btn-outline-secondary w-100" data-action="new-conversation">\n <i class="bi bi-plus-lg me-1"></i> New conversation\n </button>\n </div>\n <div class="conversation-list-items" data-container="items"></div>\n '}async onInit(){await this.collection.fetch()}async onAfterRender(){this._renderItems()}_renderItems(){const e=this.element.querySelector('[data-container="items"]');if(!e)return;e.innerHTML="";const t=this.collection.models||[];if(0===t.length)return void(e.innerHTML='\n <div class="text-center text-muted small p-4">\n No conversations yet.<br>Start by typing a message.\n </div>\n ');const s=this._groupByDate(t);for(const[i,a]of s){const t=document.createElement("div");t.className="conversation-date-header px-3 py-1 text-muted small fw-semibold text-uppercase",t.textContent=i,e.appendChild(t),a.forEach(t=>{const s=t.get("id"),i=t.get("title")||t.get("summary")||"New conversation",a=t.get("modified")||t.get("created"),n=this._relativeTime(a),o=s===this.activeId,l=document.createElement("div");l.className="conversation-item px-3 py-2"+(o?" active":""),l.dataset.id=s,l.innerHTML=`\n <div class="d-flex align-items-start">\n <div class="flex-grow-1 overflow-hidden">\n <div class="text-truncate conversation-title">${this._escapeHtml(i)}</div>\n ${n?`<div class="conversation-time text-muted">${n}</div>`:""}\n </div>\n <button class="btn btn-sm btn-link text-muted p-0 ms-2 conversation-delete" data-action="delete-conversation" data-id="${s}" title="Delete">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n `,l.addEventListener("click",e=>{e.target.closest('[data-action="delete-conversation"]')||(this.setActive(s),this.emit("conversation:select",{id:s,model:t}))}),e.appendChild(l)})}}_groupByDate(e){const s=/* @__PURE__ */new Date,i=new Date(s.getFullYear(),s.getMonth(),s.getDate()),a=new Date(i);a.setDate(a.getDate()-1);const n=/* @__PURE__ */new Map;n.set("Today",[]),n.set("Yesterday",[]),n.set("Earlier",[]),e.forEach(e=>{const s=e.get("created")||e.get("modified"),o=new Date(t.dataFormatter.normalizeEpoch(s)),l=new Date(o.getFullYear(),o.getMonth(),o.getDate());l>=i?n.get("Today").push(e):l>=a?n.get("Yesterday").push(e):n.get("Earlier").push(e)});const o=[];for(const[t,l]of n)l.length>0&&o.push([t,l]);return o}setActive(e){this.activeId=e,this.element.querySelectorAll(".conversation-item").forEach(t=>{t.classList.toggle("active",String(t.dataset.id)===String(e))})}onActionNewConversation(){this.setActive(null),this.emit("conversation:new")}async onActionDeleteConversation(e,t){const i=t.dataset.id;if(!(await s.Dialog.confirm({title:"Delete Conversation",message:"Are you sure you want to delete this conversation? This cannot be undone.",confirmText:"Delete",confirmClass:"btn-danger"})))return;const a=this.collection.models.find(e=>String(e.get("id"))===String(i));a&&(await a.destroy(),await this.refresh(),this.emit("conversation:deleted",{id:i}))}async refresh(){await this.collection.fetch(),this._renderItems()}_relativeTime(e){if(!e)return"";const s=new Date(t.dataFormatter.normalizeEpoch(e));if(isNaN(s))return"";const i=Date.now()-s.getTime(),a=Math.floor(i/1e3);if(a<60)return"just now";const n=Math.floor(a/60);if(n<60)return`${n}m ago`;const o=Math.floor(n/60);if(o<24)return`${o}h ago`;const l=Math.floor(o/24);return l<7?`${l}d ago`:`${s.toLocaleString("default",{month:"short"})} ${s.getDate()}`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}}class AssistantView extends t.View{constructor(e={}){super({className:"assistant-view",...e}),this.app=e.app,this.ws=this.app?.ws,this.conversationId=null,this._wsHandlers={},this._messageIdCounter=0,this._hasMessages=!1,this._activePlans={}}getTemplate(){return`\n <div class="assistant-layout">\n <div class="assistant-sidebar" data-container="conversation-list"></div>\n <div class="assistant-main">\n <div class="assistant-welcome" data-ref="welcome">\n <div class="assistant-welcome-content">\n <div class="assistant-welcome-icon">\n <i class="bi bi-stars"></i>\n </div>\n <h3 class="assistant-welcome-title">Hi ${this._escapeHtml(this.app?.activeUser?.get("first_name")||"there")}</h3>\n <p class="assistant-welcome-subtitle">How can I help you today?</p>\n <div class="assistant-suggestions">\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="Show me a summary of recent activity">\n <i class="bi bi-activity"></i>\n <span>Recent activity summary</span>\n </button>\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="How many active users are there?">\n <i class="bi bi-people"></i>\n <span>Active user count</span>\n </button>\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="Show me system health metrics">\n <i class="bi bi-heart-pulse"></i>\n <span>System health check</span>\n </button>\n </div>\n </div>\n </div>\n <div class="assistant-chat-area" data-container="chat-area"></div>\n <div class="assistant-input-wrapper">\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message the assistant..." rows="1" data-ref="input"></textarea>\n <button class="assistant-send-btn" data-action="send" type="button" title="Send message" data-ref="send-btn">\n <i class="bi bi-arrow-up"></i>\n </button>\n <button class="assistant-stop-btn d-none" data-action="stop" type="button" title="Stop generating" data-ref="stop-btn">\n <i class="bi bi-stop-fill"></i>\n </button>\n </div>\n <div class="assistant-input-footer">\n <span class="assistant-connection-indicator" data-ref="status">\n <span class="status-dot connected"></span>\n </span>\n <span class="text-muted">Press Enter to send, Shift+Enter for new line</span>\n </div>\n </div>\n </div>\n </div>\n `}async onInit(){this.conversations=new AssistantConversationList,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new a.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView),this.conversationListView.on("conversation:select",e=>this._onConversationSelect(e)),this.conversationListView.on("conversation:new",()=>this._onNewConversation()),this.conversationListView.on("conversation:deleted",e=>this._onConversationDeleted(e)),this._subscribeWS()}async onAfterRender(){await super.onAfterRender();const e=this.element.querySelector('[data-ref="input"]');e&&(e.addEventListener("input",()=>this._autoResize(e)),e.addEventListener("keydown",e=>this._handleKeydown(e)),setTimeout(()=>e.focus(),100)),this._updateConnectionStatus()}_autoResize(e){e.style.height="auto",e.style.height=Math.min(e.scrollHeight,200)+"px"}_handleKeydown(e){"Enter"!==e.key||e.shiftKey||(e.preventDefault(),this._sendMessage())}onActionUseSuggestion(e,t){const s=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!s)return;const i=this.element.querySelector('[data-ref="input"]');i&&(i.value=s,this._autoResize(i)),this._sendMessage()}onActionSend(){this._sendMessage()}async _sendMessage(){const e=this.element.querySelector('[data-ref="input"]');if(!e)return;const t=e.value.trim();t&&(e.value="",e.style.height="auto",this._showChatArea(),await this.chatView.adapter.addNote({text:t,files:[]}))}_showChatArea(){if(this._hasMessages)return;this._hasMessages=!0;const e=this.element.querySelector('[data-ref="welcome"]'),t=this.element.querySelector('[data-container="chat-area"]');e&&e.classList.add("d-none"),t&&t.classList.remove("d-none")}_showWelcome(){this._hasMessages=!1;const e=this.element.querySelector('[data-ref="welcome"]'),t=this.element.querySelector('[data-container="chat-area"]');e&&e.classList.remove("d-none"),t&&t.classList.add("d-none")}_setInputEnabled(e){const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),i=this.element?.querySelector('[data-ref="stop-btn"]');t&&(t.disabled=!e),s&&s.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),this._responseTimeout&&clearTimeout(this._responseTimeout),e||(this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4))}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Response cancelled.");const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_onResponseTimeout(){this._responseTimeout=null,this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Request timed out. Please try again.")}_createAdapter(){return{fetch:async()=>{if(!this.conversationId)return[];try{const e=new AssistantConversation({id:this.conversationId});return await e.fetch({graph:"detail"}),(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean)}catch(e){return 404===e.status&&(this._onNewConversation(),this._showSystemMessage("Conversation not found.")),[]}},addNote:async e=>{if(!e.text||!e.text.trim())return{success:!1};const t={id:"local-"+ ++this._messageIdCounter,role:"user",author:{id:this.app?.activeUser?.id,name:this.app?.activeUser?.get("display_name")||"You"},content:e.text,timestamp:/* @__PURE__ */(new Date).toISOString()};if(this.chatView.addMessage(t),this._setInputEnabled(!1),this.ws&&this.ws.isConnected)this.ws.send({type:"assistant_message",message:e.text,conversation_id:this.conversationId});else try{const t=await this.app.rest.post("/api/assistant",{message:e.text,conversation_id:this.conversationId}),s=t?.data?.data||t?.data||t;s.conversation_id&&(this.conversationId=s.conversation_id),s.response&&this.chatView.addMessage(this._transformMessage(s.response)),this._setInputEnabled(!0)}catch(s){this._handleAPIError(s)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),tool_call:e=>this._onToolCall(e),response:e=>this._onResponse(e),error:e=>this._onError(e),plan:e=>this._onPlan(e),plan_update:e=>this._onPlanUpdate(e),message:e=>this._dispatchWSMessage(e),connected:()=>this._updateConnectionStatus(),disconnected:()=>this._updateConnectionStatus(),reconnecting:()=>this._updateConnectionStatus()},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.on("message:assistant_response",this._wsHandlers.response),this.ws.on("message:assistant_error",this._wsHandlers.error),this.ws.on("message:assistant_plan",this._wsHandlers.plan),this.ws.on("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.on("message:message",this._wsHandlers.message),this.ws.on("connected",this._wsHandlers.connected),this.ws.on("disconnected",this._wsHandlers.disconnected),this.ws.on("reconnecting",this._wsHandlers.reconnecting))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.off("message:assistant_response",this._wsHandlers.response),this.ws.off("message:assistant_error",this._wsHandlers.error),this.ws.off("message:assistant_plan",this._wsHandlers.plan),this.ws.off("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.off("message:message",this._wsHandlers.message),this.ws.off("connected",this._wsHandlers.connected),this.ws.off("disconnected",this._wsHandlers.disconnected),this.ws.off("reconnecting",this._wsHandlers.reconnecting),this._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_tool_call":this._onToolCall(t);break;case"assistant_response":this._onResponse(t);break;case"assistant_error":this._onError(t);break;case"assistant_plan":this._onPlan(t);break;case"assistant_plan_update":this._onPlanUpdate(t)}}_isMyConversation(e){return!e.conversation_id||!this.conversationId||String(e.conversation_id)===String(this.conversationId)}_adoptConversationId(e){e.conversation_id&&!this.conversationId&&(this.conversationId=e.conversation_id,this.conversationListView.refresh())}_onThinking(e){this._isMyConversation(e)&&(this._adoptConversationId(e),this._showChatArea(),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1))}_onToolCall(e){this._isMyConversation(e)&&this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`)}_onResponse(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0),this._adoptConversationId(e);const t=this.element?.querySelector('[data-ref="input"]');t&&t.focus();const s=this._transformMessage({id:e.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:e.response||e.content||e.message||"",blocks:e.blocks||[],tool_calls:e.tool_calls_made||e.tool_calls||[],created:e.timestamp||/* @__PURE__ */(new Date).toISOString()});this.chatView.addMessage(s)}_onError(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0),this._adoptConversationId(e);const t=e.error||e.message||"An error occurred";this._showSystemMessage(t)}_onPlan(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._showChatArea();const t=e.plan;t&&(this._activePlans[t.plan_id]=t,this.chatView.addMessage({id:`plan-${t.plan_id}`,role:"assistant",author:{name:"Assistant"},content:"",timestamp:/* @__PURE__ */(new Date).toISOString(),blocks:[{type:"progress",...t}],tool_calls:[]}))}_onPlanUpdate(e){if(!this._isMyConversation(e))return;const t=this._activePlans[e.plan_id];if(t){const s=t.steps.find(t=>t.id===e.step_id);s&&(s.status=e.status,s.summary=e.summary)}const s=this.chatView.messageViews.get(`plan-${e.plan_id}`);s?.updateProgressStep&&s.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary)}async _onConversationSelect(e){this.conversationId=e.id,this.conversationListView.setActive(e.id),this._showChatArea(),await this.chatView.refresh()}_onNewConversation(){this.conversationId=null,this.conversationListView.setActive(null),this.chatView.clearMessages(),this._setInputEnabled(!0),this._showWelcome();const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_onConversationDeleted(e){String(e.id)===String(this.conversationId)&&this._onNewConversation()}_transformMessage(e){if("tool_result"===e.role)return null;let t=e.content||e.text||"",s=e.blocks||[],i=e.tool_calls||[];if(i.length>0){const e=i.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),i=i.filter(e=>"tool_use"===e.type)}if(0===s.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,s=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Assistant"}:e.author||{name:e.user?.display_name||this.app?.activeUser?.get("display_name")||"You",id:e.user?.id||a},content:t,timestamp:e.created||e.timestamp,blocks:s,tool_calls:i,_conversationId:this.conversationId}}static _parseBlocks(e){const t=/```assistant_block\s*\n([\s\S]*?)```/g,s=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress"]),i=[];let a;for(;null!==(a=t.exec(e));)try{const e=JSON.parse(a[1].trim());e&&s.has(e.type)&&i.push(e)}catch(n){}return{content:e.replace(t,"").replace(/\n{3,}/g,"\n\n").trim(),blocks:i}}_showSystemMessage(e){this._showChatArea(),this.chatView.addMessage({id:"sys-"+ ++this._messageIdCounter,type:"system_event",content:e,timestamp:/* @__PURE__ */(new Date).toISOString()})}_handleAPIError(e){404===e.status?this._showSystemMessage("Assistant is not enabled on this server."):503===e.status?this._showSystemMessage("LLM API key not configured. Contact your administrator."):this._showSystemMessage("Failed to send message. Please try again."),this._setInputEnabled(!0)}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");if(e)if(this.ws?.isConnected)e.className="status-dot connected",e.title="Connected";else if(this.ws?.isReconnecting)e.className="status-dot reconnecting",e.title="Reconnecting...";else{e.className="status-dot disconnected",e.title="Disconnected";const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]');t&&(t.disabled=!0),s&&s.classList.add("d-none")}}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}const G=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,default:AssistantView},Symbol.toStringTag,{value:"Module"}));function J(e,t=!0){if(e.registerPage("system/dashboard",AdminDashboardPage,{permissions:["security"]}),e.registerPage("system/jobs/dashboard",JobDashboardPage,{permissions:["view_jobs","manage_jobs"]}),e.registerPage("system/jobs/runners",JobRunnersPage,{permissions:["view_jobs"]}),e.registerPage("system/jobs/list",JobsTablePage,{permissions:["view_jobs"]}),e.registerPage("system/jobs/scheduled-tasks",ScheduledTaskTablePage,{permissions:["view_scheduled_tasks","manage_scheduled_tasks"]}),e.registerPage("system/users",UserTablePage,{permissions:["view_users","manage_users"]}),e.registerPage("system/groups",GroupTablePage,{permissions:["view_groups","manage_groups"]}),e.registerPage("system/members",MemberTablePage,{permissions:["view_members","manage_groups"]}),e.registerPage("system/s3buckets",S3BucketTablePage,{permissions:["manage_aws"]}),e.registerPage("system/filemanagers",FileManagerTablePage,{permissions:["view_fileman","manage_files"]}),e.registerPage("system/files",FileTablePage,{permissions:["manage_files"]}),e.registerPage("system/incidents",IncidentTablePage,{permissions:["view_security"]}),e.registerPage("system/events",EventTablePage,{permissions:["view_security"]}),e.registerPage("system/logs",LogTablePage,{permissions:["view_logs"]}),e.registerPage("system/user/devices",UserDeviceTablePage,{permissions:["manage_users"]}),e.registerPage("system/user/device-locations",UserDeviceLocationTablePage,{permissions:["manage_users"]}),e.registerPage("system/system/geoip",GeoLocatedIPTablePage,{permissions:["view_security","manage_users"]}),e.registerPage("system/email/mailboxes",EmailMailboxTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/domains",EmailDomainTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/sent",SentMessageTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/templates",EmailTemplateTablePage,{permissions:["manage_aws"]}),e.registerPage("system/incident-dashboard",IncidentDashboardPage,{permissions:["view_security"]}),e.registerPage("system/rulesets",RuleSetTablePage,{permissions:["manage_security"]}),e.registerPage("system/tickets",TicketTablePage,{permissions:["manage_security"]}),e.registerPage("system/metrics/permissions",MetricsPermissionsTablePage,{permissions:["manage_metrics"]}),e.registerPage("system/push/dashboard",PushDashboardPage,{permissions:["manage_notifications"]}),e.registerPage("system/push/configs",PushConfigTablePage,{permissions:["manage_push_config"]}),e.registerPage("system/push/templates",PushTemplateTablePage,{permissions:["manage_notifications"]}),e.registerPage("system/push/deliveries",PushDeliveryTablePage,{permissions:["view_notifications","manage_notifications"]}),e.registerPage("system/push/devices",PushDeviceTablePage,{permissions:["view_devices","manage_devices"]}),e.registerPage("system/phonehub/numbers",PhoneNumberTablePage,{permissions:["view_phone_numbers","manage_phone_numbers"]}),e.registerPage("system/phonehub/sms",SMSTablePage,{permissions:["view_sms","manage_sms"]}),e.registerPage("system/api-keys",ApiKeyTablePage,{permissions:["manage_groups","manage_group"]}),e.registerPage("system/settings",SettingTablePage,{permissions:["manage_settings"]}),e.registerPage("system/cloudwatch",CloudWatchDashboardPage,{permissions:["manage_aws"]}),e.registerPage("system/security/blocked-ips",BlockedIPsTablePage,{permissions:["view_security"]}),e.registerPage("system/security/firewall-log",FirewallLogTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bouncer-signals",BouncerSignalTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bouncer-devices",BouncerDeviceTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bot-signatures",BotSignatureTablePage,{permissions:["manage_security"]}),e.registerPage("system/security/ipsets",IPSetTablePage,{permissions:["view_security"]}),t&&e.sidebar&&e.sidebar.getMenuConfig){const t=e.sidebar.getMenuConfig("system");if(t&&t.items){const e=[{text:"Dashboard",route:"?page=system/dashboard",icon:"bi-speedometer2",permissions:["security"]},{text:"Users",route:"?page=system/users",icon:"bi-people",permissions:["view_users","manage_users"]},{text:"Groups",route:"?page=system/groups",icon:"bi-diagram-3",permissions:["view_groups","manage_groups"]},{text:"Job Engine",route:null,icon:"bi-gear-wide-connected",permissions:["view_jobs","manage_jobs"],children:[{text:"Dashboard",route:"?page=system/jobs/dashboard",icon:"bi-bar-chart-line",permissions:["view_jobs"]},{text:"Runners",route:"?page=system/jobs/runners",icon:"bi-cpu",permissions:["view_jobs"]},{text:"Jobs",route:"?page=system/jobs/list",icon:"bi-list-task",permissions:["view_jobs"]},{text:"Scheduled Tasks",route:"?page=system/jobs/scheduled-tasks",icon:"bi-clock-history",permissions:["view_scheduled_tasks","manage_scheduled_tasks"]}]},{text:"Security",route:null,icon:"bi-shield-lock",permissions:["view_security"],children:[{text:"Dashboard",route:"?page=system/incident-dashboard",icon:"bi-bar-chart-line",permissions:["view_security"]},{text:"Incidents",route:"?page=system/incidents",icon:"bi-exclamation-triangle",permissions:["view_security"]},{text:"Tickets",route:"?page=system/tickets",icon:"bi-ticket-detailed",permissions:["manage_security"]},{text:"Events",route:"?page=system/events",icon:"bi-bell",permissions:["view_security"]},{text:"Rule Engine",route:"?page=system/rulesets",icon:"bi-funnel",permissions:["manage_security"]},{text:"Blocked IPs",route:"?page=system/security/blocked-ips",icon:"bi-slash-circle",permissions:["view_security"]},{text:"IP Sets",route:"?page=system/security/ipsets",icon:"bi-shield-shaded",permissions:["view_security"]},{text:"Firewall Log",route:"?page=system/security/firewall-log",icon:"bi-journal-code",permissions:["view_security"]},{text:"GeoIP",route:"?page=system/system/geoip",icon:"bi-globe",permissions:["view_security"]},{text:"Bouncer Signals",route:"?page=system/security/bouncer-signals",icon:"bi-activity",permissions:["view_security"]},{text:"Bouncer Devices",route:"?page=system/security/bouncer-devices",icon:"bi-fingerprint",permissions:["view_security"]},{text:"Bot Signatures",route:"?page=system/security/bot-signatures",icon:"bi-robot",permissions:["manage_security"]}]},{text:"Email",route:null,icon:"bi-envelope",permissions:["manage_aws"],children:[{text:"Domains",route:"?page=system/email/domains",icon:"bi-globe",permissions:["manage_aws"]},{text:"Mailboxes",route:"?page=system/email/mailboxes",icon:"bi-inbox",permissions:["manage_aws"]},{text:"Sent",route:"?page=system/email/sent",icon:"bi-send-check",permissions:["manage_aws"]},{text:"Templates",route:"?page=system/email/templates",icon:"bi-file-text",permissions:["manage_aws"]}]},{text:"Push Notifications",route:null,icon:"bi-broadcast",permissions:["manage_notifications","manage_push_config"],children:[{text:"Dashboard",route:"?page=system/push/dashboard",icon:"bi-bar-chart-line",permissions:["manage_notifications"]},{text:"Configurations",route:"?page=system/push/configs",icon:"bi-gear",permissions:["manage_push_config"]},{text:"Templates",route:"?page=system/push/templates",icon:"bi-file-earmark-text",permissions:["manage_notifications"]},{text:"Deliveries",route:"?page=system/push/deliveries",icon:"bi-send",permissions:["view_notifications","manage_notifications"]},{text:"Devices",route:"?page=system/push/devices",icon:"bi-phone",permissions:["view_devices","manage_devices"]}]},{text:"Phone Hub",route:null,icon:"bi-telephone",permissions:["view_phone_numbers","manage_phone_numbers"],children:[{text:"Numbers",route:"?page=system/phonehub/numbers",icon:"bi-collection",permissions:["view_phone_numbers","manage_phone_numbers"]},{text:"SMS",route:"?page=system/phonehub/sms",icon:"bi-chat-dots",permissions:["view_sms","manage_sms"]}]},{text:"Storage",route:null,icon:"bi-folder",permissions:["manage_files","manage_aws"],children:[{text:"S3 Buckets",route:"?page=system/s3buckets",icon:"bi-bucket",permissions:["manage_aws"]},{text:"Storage Backends",route:"?page=system/filemanagers",icon:"bi-hdd-stack",permissions:["view_fileman","manage_files"]},{text:"Files",route:"?page=system/files",icon:"bi-file-earmark",permissions:["manage_files"]}]},{text:"System",route:null,icon:"bi-wrench-adjustable",permissions:["view_logs","manage_settings","manage_groups"],children:[{text:"Logs",route:"?page=system/logs",icon:"bi-journal-text",permissions:["view_logs"]},{text:"API Keys",route:"?page=system/api-keys",icon:"bi-key",permissions:["manage_groups","manage_group"]},{text:"User Devices",route:"?page=system/user/devices",icon:"bi-phone",permissions:["manage_users"]},{text:"Device Locations",route:"?page=system/user/device-locations",icon:"bi-geo-alt",permissions:["manage_users"]},{text:"Metrics Permissions",route:"?page=system/metrics/permissions",icon:"bi-bar-chart-line",permissions:["manage_metrics"]},{text:"Settings",route:"?page=system/settings",icon:"bi-gear",permissions:["manage_settings"]},{text:"CloudWatch",route:"?page=system/cloudwatch",icon:"bi-cloud",permissions:["manage_aws"]}]}];t.items.unshift(...e)}}}exports.WebApp=m.WebApp,exports.BUILD_TIME=u.BUILD_TIME,exports.VERSION=u.VERSION,exports.VERSION_INFO=u.VERSION_INFO,exports.VERSION_MAJOR=u.VERSION_MAJOR,exports.VERSION_MINOR=u.VERSION_MINOR,exports.VERSION_REVISION=u.VERSION_REVISION,exports.AdminDashboardPage=AdminDashboardPage,exports.ApiKeyTablePage=ApiKeyTablePage,exports.ApiKeyView=ApiKeyView,exports.AssistantView=AssistantView,exports.BlockedIPsTablePage=BlockedIPsTablePage,exports.BotSignatureTablePage=BotSignatureTablePage,exports.BouncerDeviceTablePage=BouncerDeviceTablePage,exports.BouncerDeviceView=BouncerDeviceView,exports.BouncerSignalTablePage=BouncerSignalTablePage,exports.BouncerSignalView=BouncerSignalView,exports.CloudWatchChart=CloudWatchChart,exports.CloudWatchDashboardPage=CloudWatchDashboardPage,exports.CloudWatchResourceView=CloudWatchResourceView,exports.DeviceView=DeviceView,exports.EmailDomainTablePage=EmailDomainTablePage,exports.EmailMailboxTablePage=EmailMailboxTablePage,exports.EmailTemplateTablePage=EmailTemplateTablePage,exports.EmailTemplateView=EmailTemplateView,exports.EmailView=EmailView,exports.EventTablePage=EventTablePage,exports.EventView=EventView,exports.FileManagerTablePage=FileManagerTablePage,exports.FileTablePage=FileTablePage,exports.FileView=FileView,exports.FirewallLogTablePage=FirewallLogTablePage,exports.GeoIPView=GeoIPView,exports.GeoLocatedIPTablePage=GeoLocatedIPTablePage,exports.GroupTablePage=GroupTablePage,exports.GroupView=GroupView,exports.HandlerBuilderView=HandlerBuilderView,exports.IPSetTablePage=IPSetTablePage,exports.IPSetView=IPSetView,exports.IncidentDashboardPage=IncidentDashboardPage,exports.IncidentTablePage=IncidentTablePage,exports.IncidentView=IncidentView,exports.JobDashboardPage=JobDashboardPage,exports.JobDetailsView=JobDetailsView,exports.JobHealthView=JobHealthView,exports.JobRunnersPage=JobRunnersPage,exports.JobStatsView=JobStatsView,exports.JobsTablePage=JobsTablePage,exports.LogTablePage=LogTablePage,exports.LogView=LogView,exports.MemberTablePage=MemberTablePage,exports.MemberView=MemberView,exports.MetricsPermissionsTablePage=MetricsPermissionsTablePage,exports.MetricsPermissionsView=MetricsPermissionsView,exports.PhoneNumberTablePage=PhoneNumberTablePage,exports.PhoneNumberView=PhoneNumberView,exports.PushConfigTablePage=PushConfigTablePage,exports.PushDashboardPage=PushDashboardPage,exports.PushDeliveryTablePage=PushDeliveryTablePage,exports.PushDeliveryView=PushDeliveryView,exports.PushDeviceTablePage=PushDeviceTablePage,exports.PushDeviceView=PushDeviceView,exports.PushTemplateTablePage=PushTemplateTablePage,exports.RuleSetTablePage=RuleSetTablePage,exports.RuleSetView=RuleSetView,exports.RunnerDetailsView=RunnerDetailsView,exports.S3BucketTablePage=S3BucketTablePage,exports.SMSTablePage=SMSTablePage,exports.ScheduledTaskTablePage=ScheduledTaskTablePage,exports.ScheduledTaskView=ScheduledTaskView,exports.SentMessageTablePage=SentMessageTablePage,exports.SettingTablePage=SettingTablePage,exports.SettingView=SettingView,exports.TicketTablePage=TicketTablePage,exports.TicketView=TicketView,exports.UserDeviceLocationTablePage=UserDeviceLocationTablePage,exports.UserDeviceLocationView=UserDeviceLocationView,exports.UserDeviceTablePage=UserDeviceTablePage,exports.UserTablePage=UserTablePage,exports.UserView=UserView,exports.registerAdminPages=J,exports.registerAssistant=function(e){const t={id:"assistant",icon:"bi-robot",action:"open-assistant",isButton:!0,buttonClass:"btn btn-link nav-link",tooltip:"Admin Assistant",permissions:["view_admin"],handler:async()=>{const{default:t}=await Promise.resolve().then(()=>G),{default:s}=await Promise.resolve().then(()=>require("./chunks/Modal-Y1PW_Fmf.js")),i=new t({app:e});s.show(i,{size:"fullscreen",noBodyPadding:!0,title:" ",buttons:[]})}};e.topbar&&e.topbar.config?(e.topbar.config.rightItems.unshift(t),e.topbar.isMounted()&&e.topbar.render()):e.topbarConfig&&(e.topbarConfig.rightItems||(e.topbarConfig.rightItems=[]),e.topbarConfig.rightItems.unshift(t))},exports.registerSystemPages=J;
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./chunks/ContextMenu-DBeueYpI.js"),t=require("./chunks/Collection-Bgp386gn.js");require("./chunks/WebSocketClient-C9VS1m8v.js");const s=require("./chunks/Dialog-2gXM2UcO.js"),i=require("./chunks/MetricsMiniChartWidget-y-KklF3c.js"),a=require("./chunks/ChatView-DZBmWPu-.js"),n=require("./chunks/Passkeys-DNpner4L.js"),o=require("./chunks/FormView-CRPeN8tp.js"),l=require("./chunks/Modal-Y1PW_Fmf.js"),r=require("./chunks/DataView-Dpr4AjSq.js"),d=require("./chunks/MetricsCountryMapView-ww-c8cxk.js"),c=require("./chunks/PDFViewer-Cg_tbqb7.js"),m=require("./chunks/WebApp-C98dm94j.js"),u=require("./chunks/version-Di5CfBml.js");class AdminHeaderView extends t.View{constructor(e={}){super({title:"Dashboard",...e,headerActions:[{label:"Export",icon:"bi-download",action:"export",buttonClass:"btn-primary"}],className:"admin-header-section"}),this.stats={user_activity_day:0,total_users:0,group_activity_day:0,total_groups:0,api_calls:0,apiChange:"",incidents:0,incidentsChange:""},this.prepareStatsForTemplate()}async getTemplate(){return'\n <div class="admin-stats-header mb-4">\n <div class="row">\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="user_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="group_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="api_activity_day"></div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-12 mb-3">\n <div data-container="incident_activity_day"></div>\n </div>\n </div>\n </div>\n '}async onInit(){this.userActivity=new i.MetricsMiniChartWidget({icon:"bi bi-people fs-2",title:"User Activity",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total_users}} <span class="subtitle-label">Total</span>',background:"#5388D6",textColor:"#FFFFFF",granularity:"days",trendRange:4,trendOffset:0,slugs:["user_activity_day"],account:"global",chartType:"bar",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,showSettings:!0,showDateRange:!0,containerId:"user_activity_day"}),this.addChild(this.userActivity),this.groupActivity=new i.MetricsMiniChartWidget({icon:"bi bi-collection fs-2",title:"Group Activity",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total_groups}} <span class="subtitle-label">Total</span>',background:"#1f6a7a",textColor:"#FFFFFF",granularity:"days",trendRange:4,trendOffset:0,slugs:["group_activity_day"],account:"global",chartType:"bar",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"group_activity_day"}),this.addChild(this.groupActivity),this.apiActivity=new i.MetricsMiniChartWidget({icon:"bi bi-graph-up fs-2",title:"API Requests",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total}} <span class="subtitle-label">Total</span>',background:"#50A079",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",trendRange:4,trendOffset:0,granularity:"days",slugs:["api_calls"],account:"global",chartType:"line",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"api_activity_day"}),this.addChild(this.apiActivity),this.incidentActivity=new i.MetricsMiniChartWidget({icon:"bi bi-exclamation-triangle fs-2",title:"Incidents",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span> {{total}} <span class="subtitle-label">Total</span>',background:"#B14545",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",trendRange:4,trendOffset:0,granularity:"days",slugs:["incidents"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!0,height:50,chartWidth:"100%",color:"rgba(245, 245, 255, 0.8)",fill:!0,fillColor:"rgba(245, 245, 255, 0.6)",smoothing:.3,showTrending:!0,containerId:"incident_activity_day"}),this.addChild(this.incidentActivity)}async onBeforeRender(){}prepareStatsForTemplate(){}async loadValues(){try{const e=await this.getApp().rest.GET("/api/metrics/value/get",{slugs:["total_users","total_groups"],account:"global"});e.success&&e.data.status&&Object.assign(this.stats,e.data.data),this.groupActivity&&(this.groupActivity.header.total_groups=this.stats.total_groups||0,this.groupActivity.header.render()),this.userActivity&&(this.userActivity.header.total_users=this.stats.total_users||0,this.userActivity.header.render())}catch(e){console.error("Failed to load admin stats:",e)}}async loadStats(){try{const e=await this.getApp().rest.GET("/api/metrics/series",{slugs:["user_created","user_activity_day","incidents","api_calls","api_errors","group_activity_day"],account:"global",granularity:"days"});e.success&&e.data.status&&(Object.assign(this.stats,e.data.data),this.prepareStatsForTemplate())}catch(e){console.error("Failed to load admin stats:",e)}}}class AdminDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Admin Dashboard",className:"admin-dashboard-page"}),this.pageTitle="Admin Dashboard",this.pageSubtitle="System monitoring and metrics overview"}async getTemplate(){return'\n <div class="admin-dashboard-container container-lg">\n \x3c!-- Page Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-2">\n <div>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n <small class="text-info">\n <i class="bi bi-shield-check me-1"></i>\n Real-time system metrics and performance monitoring\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all" title="Refresh All Charts">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <button type="button" class="btn btn-outline-primary btn-sm"\n data-action="export-metrics" title="Export Metrics Data">\n <i class="bi bi-download"></i> Export\n </button>\n <button type="button" class="btn btn-outline-warning btn-sm"\n data-action="view-alerts" title="View System Alerts">\n <i class="bi bi-bell"></i> Alerts\n </button>\n </div>\n </div>\n\n \x3c!-- Stats Header --\x3e\n <div data-container="admin-header"></div>\n <div data-container="example-chart"></div>\n \x3c!-- Charts Section --\x3e\n <div class="row">\n \x3c!-- Full Width API Metrics Chart --\x3e\n <div class="col-12 mb-4">\n <div data-container="api-metrics-chart"></div>\n </div>\n </div>\n\n \x3c!-- System Status Footer --\x3e\n <div class="row">\n <div class="col-12">\n <div class="alert alert-success border-0" role="alert">\n <div class="d-flex align-items-center">\n <i class="bi bi-check-circle-fill me-2"></i>\n <div>\n <strong>System Status:</strong> All systems operational.\n Last updated: <span class="text-muted">{{lastUpdated}}</span>\n </div>\n <div class="ms-auto">\n <button class="btn btn-sm btn-outline-success" data-action="view-system-status">\n <i class="bi bi-info-circle"></i> Details\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}async onInit(){this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.headerView=new AdminHeaderView({containerId:"admin-header"}),this.addChild(this.headerView),this.apiMetricsChart=new i.MetricsChart({title:'<i class="bi bi-graph-up me-2"></i> API Metrics',endpoint:"/api/metrics/fetch",height:250,granularity:"hours",slugs:["api_calls","api_errors"],account:"global",chartType:"line",showDateRange:!1,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number"},containerId:"api-metrics-chart"}),this.addChild(this.apiMetricsChart)}async onActionRefreshAll(e,t){try{const s=t||e?.currentTarget||null,i=s?.querySelector?.("i");i?.classList.add("bi-spin"),s&&(s.disabled=!0);const a=[this.headerView?.loadValues(),this.apiMetricsChart?.refresh()].filter(Boolean);await Promise.allSettled(a),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString();const n=this.getApp()?.events;n&&n.emit("admin:dashboard-refreshed",{page:this,timestamp:this.lastUpdated})}catch(s){console.error("Failed to refresh dashboard:",s);const e=this.element.querySelector(".alert-success");e&&(e.className="alert alert-danger border-0",e.innerHTML='\n <div class="d-flex align-items-center">\n <i class="bi bi-exclamation-triangle-fill me-2"></i>\n <div>\n <strong>Error:</strong> Failed to refresh dashboard data.\n </div>\n </div>\n ',setTimeout(()=>{e.className="alert alert-success border-0",e.innerHTML=`\n <div class="d-flex align-items-center">\n <i class="bi bi-check-circle-fill me-2"></i>\n <div>\n <strong>System Status:</strong> All systems operational.\n Last updated: <span class="text-muted">${this.lastUpdated}</span>\n </div>\n </div>\n `},5e3))}finally{const e=t.querySelector("i");e?.classList.remove("bi-spin"),button&&(button.disabled=!1)}}async onActionExportMetrics(e,t){try{await(this.apiMetricsChart?.export("png"));const e=this.getApp()?.events;e&&e.emit("admin:metrics-exported",{page:this,charts:["api-metrics"]})}catch(s){console.error("Failed to export metrics:",s)}}async onActionViewAlerts(e,t){const s=this.getApp()?.router;s&&s.navigateTo("/admin/alerts")}async onActionViewSystemStatus(e,t){const s=this.getApp()?.router;s&&s.navigateTo("/admin/system-status")}async refreshDashboard(){return this.onActionRefreshAll(null,null,{disabled:!1,querySelector:()=>null})}getCharts(){return{apiMetrics:this.apiMetricsChart}}getStats(){return this.headerView?.stats||{}}async onAfterRender(){this.headerView?.loadValues()}}class SideNavView extends t.View{constructor(e={}){const{sections:t=[],activeSection:s,navWidth:i,contentPadding:a,enableResponsive:n,minWidth:o,...l}=e;super({tagName:"div",className:"side-nav-view",...l}),this.navWidth=i||200,this.contentPadding=a||"1.5rem 2.5rem",this.enableResponsive=!1!==n,this.minWidth=o||500,this.sectionConfigs=[],this.sectionViews={},this.sectionKeys=[],this.activeSection=null,this.currentMode="sidebar",this.resizeObserver=null,this.lastContainerWidth=0;for(const r of t)this._addSectionConfig(r);this.activeSection=s||this.sectionKeys[0]||null,this.handleResize=this.handleResize.bind(this)}_addSectionConfig(e){"divider"!==e.type?e.permissions&&!this._hasPermission(e.permissions)||(this.sectionConfigs.push(e),this.sectionKeys.push(e.key),e.view&&(this.sectionViews[e.key]=e.view,e.view.parent=this)):this.sectionConfigs.push({type:"divider",label:e.label})}_hasPermission(e){try{return this.getApp().activeUser.hasPerm(e)}catch{return!0}}async renderTemplate(){const e="dropdown"===this.currentMode?this._buildDropdownNav():this._buildSidebarNav();return`\n <style>\n .snv-layout { display: flex; height: 100%; min-height: 0; }\n .snv-nav {\n width: ${this.navWidth}px;\n background: #f8f9fc;\n border-right: 1px solid #e9ecef;\n padding: 0.75rem 0;\n flex-shrink: 0;\n overflow-y: auto;\n }\n .snv-nav a {\n color: #495057;\n padding: 0.45rem 1.25rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n text-decoration: none;\n cursor: pointer;\n }\n .snv-nav a:hover { background: #e9ecef; }\n .snv-nav a.active {\n background: #e7f1ff;\n color: #0d6efd;\n font-weight: 600;\n border-right: 2px solid #0d6efd;\n }\n .snv-nav a i { width: 18px; text-align: center; font-size: 0.9rem; }\n .snv-nav-label {\n font-size: 0.65rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: #adb5bd;\n padding: 0.75rem 1.25rem 0.25rem;\n }\n .snv-content {\n flex: 1;\n overflow-y: auto;\n padding: ${this.contentPadding};\n min-width: 0;\n }\n .snv-content > .snv-section { display: none; }\n .snv-content > .snv-section.snv-active { display: block; }\n .snv-dropdown { margin-bottom: 0.75rem; }\n .snv-select-btn {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n width: 100%;\n padding: 0.5rem 1rem;\n background: #f8f9fc;\n border: 1px solid #dee2e6;\n border-radius: 0.375rem;\n font-size: 0.85rem;\n color: #495057;\n cursor: pointer;\n }\n .snv-select-btn:hover { background: #e9ecef; }\n .snv-select-btn::after {\n content: '';\n display: inline-block;\n margin-left: auto;\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-left: 0.3em solid transparent;\n }\n @media (max-width: 576px) {\n .snv-nav { display: none; }\n .snv-content { padding: 1.25rem; }\n }\n </style>\n ${"dropdown"===this.currentMode?`\n <div class="snv-dropdown">${e}</div>\n <div class="snv-content" data-container="snv-content"></div>\n `:`\n <div class="snv-layout">\n <nav class="snv-nav">${e}</nav>\n <div class="snv-content" data-container="snv-content"></div>\n </div>\n `}\n `}_buildSidebarNav(){return this.sectionConfigs.map(e=>{if("divider"===e.type)return`<div class="snv-nav-label">${this.escapeHtml(e.label)}</div>`;const t=e.key===this.activeSection,s=e.icon?`<i class="bi ${e.icon}"></i>`:"";return`<a role="button" class="${t?"active":""}" data-action="navigate" data-section="${e.key}">${s} ${this.escapeHtml(e.label)}</a>`}).join("")}_buildDropdownNav(){const e=this.sectionConfigs.find(e=>e.key===this.activeSection),t=e?e.label:this.sectionKeys[0],s=this.sectionConfigs.filter(e=>"divider"!==e.type).map(e=>{const t=e.key===this.activeSection;return`\n <li>\n <button class="dropdown-item ${t?"active":""}"\n data-action="navigate"\n data-section="${e.key}"\n type="button">\n ${e.icon?`<i class="bi ${e.icon} me-2"></i>`:""}\n ${this.escapeHtml(e.label)}\n ${t?'<i class="bi bi-check-lg ms-2"></i>':""}\n </button>\n </li>\n `}).join("");return`\n <div class="dropdown">\n <button class="snv-select-btn" type="button"\n data-bs-toggle="dropdown" aria-expanded="false">\n ${e?.icon?`<i class="bi ${e.icon}"></i>`:""}\n <span>${this.escapeHtml(t)}</span>\n </button>\n <ul class="dropdown-menu w-100">${s}</ul>\n </div>\n `}async onAfterRender(){await super.onAfterRender(),this.activeSection&&await this._mountSection(this.activeSection),this.enableResponsive&&this._setupResponsive()}async onBeforeDestroy(){await super.onBeforeDestroy(),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),"undefined"!=typeof window&&window.removeEventListener("resize",this.handleResize);for(const e of Object.values(this.sectionViews))e&&"function"==typeof e.destroy&&await e.destroy()}async showSection(e){if(!this.sectionViews[e])return console.warn(`SideNavView: Section "${e}" does not exist`),!1;if(e===this.activeSection){const t=this.sectionViews[e];if(t&&t.isMounted()&&this.element?.contains(t.element))return!0}const t=this.activeSection;this.activeSection=e,t&&t!==e&&await this._unmountSection(t),await this._mountSection(e);const s=this.sectionViews[e];return s?.onSectionActivated&&await s.onSectionActivated(),this._updateNavState(e),this.emit("section:changed",{activeSection:e,previousSection:t}),!0}async _mountSection(e){const t=this.sectionViews[e];if(!t)return;const s=this.element?.querySelector('[data-container="snv-content"]');if(s&&!t.isMounted()){this._showContentLoading(s);try{await t.render(!0,s)}finally{this._hideContentLoading(s)}}}_showContentLoading(e){if(!e)return;let t=e.querySelector(".snv-loading");t||(t=document.createElement("div"),t.className="snv-loading",t.innerHTML='<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>',t.style.cssText="display:flex;align-items:center;justify-content:center;padding:3rem;",e.prepend(t))}_hideContentLoading(e){if(!e)return;const t=e.querySelector(".snv-loading");t&&t.remove()}async _unmountSection(e){const t=this.sectionViews[e];t&&t.isMounted()&&await t.unmount()}_updateNavState(e){if(!this.element)return;this.element.querySelectorAll(".snv-nav a, .dropdown-item").forEach(t=>{const s=t.dataset.section;s&&t.classList.toggle("active",s===e)});const t=this.element.querySelector(".snv-select-btn span");if(t){const s=this.sectionConfigs.find(t=>t.key===e);s&&(t.textContent=s.label)}}async onActionNavigate(e,t){e.preventDefault();const s=t.dataset.section;return s&&await this.showSection(s),!0}_setupResponsive(){if(this.element&&this.enableResponsive)if(this._updateMode(),"undefined"!=typeof ResizeObserver){this.resizeObserver=new ResizeObserver(()=>{this.handleResize()});const e=this.element.parentElement||this.element;this.resizeObserver.observe(e)}else window.addEventListener("resize",this.handleResize)}async handleResize(){const e=this._getContainerWidth();Math.abs(e-this.lastContainerWidth)>50&&(this.lastContainerWidth=e,await this._updateMode())}_getContainerWidth(){return this.element&&(this.element.parentElement||this.element).offsetWidth||this.minWidth}async _updateMode(){const e=this._getContainerWidth(),t=e<this.minWidth?"dropdown":"sidebar";t!==this.currentMode&&(this.currentMode=t,this.isMounted()&&await this.render(),this.emit("navigation:modeChanged",{mode:this.currentMode,containerWidth:e}))}getActiveSection(){return this.activeSection}getSectionKeys(){return[...this.sectionKeys]}getSection(e){return this.sectionViews[e]||null}async addSection(e,t=!1){return e.key&&this.sectionViews[e.key]?(console.warn(`SideNavView: Section "${e.key}" already exists`),!1):(this._addSectionConfig(e),this.isMounted()&&(await this.render(),t&&e.key&&await this.showSection(e.key)),this.emit("section:added",{config:e}),!0)}async removeSection(e){const t=this.sectionViews[e];return t?("function"==typeof t.destroy&&await t.destroy(),delete this.sectionViews[e],this.sectionKeys=this.sectionKeys.filter(t=>t!==e),this.sectionConfigs=this.sectionConfigs.filter(t=>t.key!==e),this.activeSection===e&&(this.activeSection=this.sectionKeys[0]||null),this.isMounted()&&await this.render(),this.emit("section:removed",{key:e}),!0):(console.warn(`SideNavView: Section "${e}" does not exist`),!1)}_onModelChange(){}static create(e={}){return new SideNavView(e)}}class LoginEvent extends t.Model{constructor(e={}){super(e,{endpoint:"/api/account/logins"})}}class LoginEventList extends t.Collection{constructor(e={}){super({ModelClass:LoginEvent,endpoint:"/api/account/logins",...e})}}class LoginLocationMapView extends t.View{constructor(e={}){super({className:"login-location-map-view",...e}),this.userId=e.userId||null,this.height=e.height||360,this.mapStyle=e.mapStyle||"dark",this.drStart=e.drStart||null,this.drEnd=e.drEnd||null,this._drillCountry=null,this._refreshing=!1,this.mapView=null,this._mapAvailable=!1}async getTemplate(){return`\n <div class="login-location-map">\n <div class="d-none align-items-center gap-2 mb-2" data-region="drill-bar">\n <button class="btn btn-sm btn-outline-secondary" data-action="reset-drill-down">\n <i class="bi bi-arrow-left me-1"></i>All Countries\n </button>\n <span class="text-muted small" data-region="drill-label"></span>\n </div>\n <div data-container="map" style="height:${this.height}px;"></div>\n <div class="text-muted small px-1 pt-2" data-region="status"></div>\n </div>\n `}async onInit(){try{const e=(await Promise.resolve().then(()=>require("./chunks/MetricsCountryMapView-ww-c8cxk.js")).then(e=>e.MapLibreView$1)).default;this.mapView=new e({containerId:"map",height:this.height,style:this.mapStyle,zoom:1.3,center:[10,20],pitch:15,bearing:0,showNavigationControl:!0,autoFitBounds:!1}),this.addChild(this.mapView),this._mapAvailable=!0,await this.refresh()}catch(e){this._mapAvailable=!1,this._setStatus("Map extension not available.")}}async refresh(){if(!this._refreshing&&this._mapAvailable){this._refreshing=!0,this._setStatus("Loading locations…");try{const e=await this._fetchSummary();this._applyMarkers(e),this._setStatus("")}catch(e){console.error("LoginLocationMapView refresh error",e),this._setStatus("Unable to load login locations.")}finally{this._refreshing=!1}}}async _fetchSummary(e=null){const t=this.getApp()?.rest;if(!t)throw new Error("REST client unavailable");const s={};let i;this.drStart&&(s.dr_start=this.drStart),this.drEnd&&(s.dr_end=this.drEnd),this.userId?(i="/api/account/logins/user",s.user_id=this.userId):i="/api/account/logins/summary",e&&(s.country_code=e,s.region=!0);const a=await t.GET(i,s);if(!a.success||!a.data?.status)throw new Error(a.data?.error||"Login summary API error");return a.data.data||[]}_applyMarkers(e){if(!e.length)return this.mapView.updateMarkers([]),void this._setStatus("No login locations found.");const t=Math.max(...e.map(e=>e.count)),s=e.filter(e=>e.latitude&&e.longitude).map(e=>{const s=e.count/(t||1),i=Math.round(18+26*s),a=!!e.region,n=a?e.region:e.country_code,o=a?e.new_region_count||0:e.new_country_count||0,l=a?"new region":"new country",r=`\n <div class="text-center" style="min-width:120px;">\n <div class="fw-semibold">${n}</div>\n <div class="text-muted">${e.count.toLocaleString()} login${1!==e.count?"s":""}</div>\n ${o>0?`<div><span class="badge bg-warning text-dark" style="font-size:0.65rem;">${o} ${l}</span></div>`:""}\n </div>\n `;return{lng:e.longitude,lat:e.latitude,size:i,color:this._getMarkerColor(s),popup:r,_countryCode:e.country_code,_isRegion:a}});this.mapView.updateMarkers(s),this._drillCountry||this._attachMarkerClicks(s)}_attachMarkerClicks(e){this.mapView?.mapMarkers&&this.mapView.mapMarkers.forEach((t,s)=>{const i=e[s];if(!i||i._isRegion)return;const a=t.getElement();a&&a.addEventListener("dblclick",e=>{e.stopPropagation(),this.drillDown(i._countryCode)})})}_getMarkerColor(e){const t=[255,193,7],s=[32,201,151].map((s,i)=>Math.round(s+(t[i]-s)*e));return`rgba(${s[0]}, ${s[1]}, ${s[2]}, 0.9)`}async drillDown(e){if(!this._refreshing){this._drillCountry=e,this._showDrillBar(e),this._refreshing=!0,this._setStatus("Loading regions…");try{const t=await this._fetchSummary(e);this._applyMarkers(t),this._setStatus(""),this.mapView?.markers?.length>1&&this.mapView.fitBounds()}catch(t){console.error("LoginLocationMapView drillDown error",t),this._setStatus("Unable to load region data.")}finally{this._refreshing=!1}}}async onActionResetDrillDown(){this._drillCountry=null,this._hideDrillBar(),await this.refresh()}_showDrillBar(e){const t=this.element?.querySelector('[data-region="drill-bar"]'),s=this.element?.querySelector('[data-region="drill-label"]');t&&t.classList.replace("d-none","d-flex"),s&&(s.textContent=`Regions in ${e}`)}_hideDrillBar(){const e=this.element?.querySelector('[data-region="drill-bar"]');e&&e.classList.replace("d-flex","d-none")}async onTabActivated(){this.mapView?.map&&this.mapView.map.resize(),await this.refresh()}_setStatus(e){const t=this.element?.querySelector('[data-region="status"]');t&&(t.textContent=e||"",t.style.display=e?"block":"none")}}class AdminProfileSection extends t.View{constructor(e={}){super({className:"admin-profile-section",template:'\n <style>\n .ap-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .ap-section-label:first-child { margin-top: 0; }\n .ap-field-row { display: flex; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid #f0f0f0; }\n .ap-field-row:last-child { border-bottom: none; }\n .ap-field-label { width: 140px; font-size: 0.8rem; color: #6c757d; flex-shrink: 0; }\n .ap-field-value { flex: 1; font-size: 0.88rem; color: #212529; display: flex; align-items: center; gap: 0.4rem; }\n .ap-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .ap-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n .ap-badge-ok { font-size: 0.65rem; padding: 0.15em 0.45em; background: #d1e7dd; color: #0f5132; border-radius: 3px; }\n .ap-badge-warn { font-size: 0.65rem; padding: 0.15em 0.45em; background: #fff3cd; color: #856404; border-radius: 3px; }\n .ap-badge-muted { font-size: 0.65rem; padding: 0.15em 0.45em; background: #f0f0f0; color: #6c757d; border-radius: 3px; }\n .ap-not-set { color: #adb5bd; font-style: italic; font-size: 0.85rem; }\n </style>\n\n \x3c!-- Contact & Verification --\x3e\n <div class="ap-section-label">Contact & Verification</div>\n <div class="ap-field-row">\n <div class="ap-field-label">Email</div>\n <div class="ap-field-value">\n {{model.email}}\n {{#model.is_email_verified|bool}}\n <span class="ap-badge-ok">Verified</span>\n {{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}\n <span class="ap-badge-warn">Unverified</span>\n {{/model.is_email_verified|bool}}\n </div>\n {{#model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="unverify-email" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="force-verify-email" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_email_verified|bool}}\n <button type="button" class="ap-field-action" data-action="change-email" title="Change email"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Phone</div>\n <div class="ap-field-value">\n {{#hasPhone|bool}}\n {{model.phone_number}}\n {{#model.is_phone_verified|bool}}\n <span class="ap-badge-ok">Verified</span>\n {{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}\n <span class="ap-badge-warn">Unverified</span>\n {{/model.is_phone_verified|bool}}\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <span class="ap-not-set">Not set</span>\n {{/hasPhone|bool}}\n </div>\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="unverify-phone" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="force-verify-phone" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_phone_verified|bool}}\n <button type="button" class="ap-field-action" data-action="change-phone" title="Change phone"><i class="bi bi-pencil"></i></button>\n <button type="button" class="ap-field-action" data-action="remove-phone" title="Remove phone"><i class="bi bi-x-lg"></i></button>\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <button type="button" class="ap-field-action" data-action="set-phone" title="Set phone number"><i class="bi bi-plus"></i></button>\n {{/hasPhone|bool}}\n </div>\n\n \x3c!-- Account --\x3e\n <div class="ap-section-label">Account</div>\n <div class="ap-field-row">\n <div class="ap-field-label">Username</div>\n <div class="ap-field-value">{{model.username}}</div>\n <button type="button" class="ap-field-action" data-action="edit-username" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Status</div>\n <div class="ap-field-value">\n {{#model.is_active|bool}}<span class="ap-badge-ok">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="ap-badge-warn">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Role</div>\n <div class="ap-field-value">\n {{roleLabel}}\n {{#model.is_staff|bool}}<span class="ap-badge-muted">Staff</span>{{/model.is_staff|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">MFA</div>\n <div class="ap-field-value">\n {{#model.requires_mfa|bool}}<span class="ap-badge-ok">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="ap-badge-muted">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Member Since</div>\n <div class="ap-field-value">{{model.date_joined|date}}</div>\n </div>\n <div class="ap-field-row">\n <div class="ap-field-label">Last Login</div>\n <div class="ap-field-value">{{model.last_login|relative}}</div>\n </div>\n ',...e})}get hasPhone(){return!(!this.model||!this.model.get("phone_number"))}get roleLabel(){return this.model&&this.model.get("is_superuser")?"Superuser":"User"}async onActionForceVerifyEmail(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("email")}</strong> as verified? This bypasses the normal verification flow.`,"Force Verify Email"))||(await this._saveField({is_email_verified:!0},"Email marked as verified"),!0)}async onActionUnverifyEmail(){return!(await s.Dialog.confirm("Mark email as unverified? The user will need to re-verify their email.","Unverify Email"))||(await this._saveField({is_email_verified:!1},"Email marked as unverified"),!0)}async onActionForceVerifyPhone(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("phone_number")}</strong> as verified? This bypasses the normal verification flow.`,"Force Verify Phone"))||(await this._saveField({is_phone_verified:!0},"Phone marked as verified"),!0)}async onActionUnverifyPhone(){return!(await s.Dialog.confirm("Mark phone as unverified? The user will need to re-verify their phone number.","Unverify Phone"))||(await this._saveField({is_phone_verified:!1},"Phone marked as unverified"),!0)}async onActionChangeEmail(){const e=await s.Dialog.prompt("Enter the new email address for this user:","Change Email",{defaultValue:this.model.get("email")||""});return null===e||!e.trim()||(await this._saveField({email:e.trim()},"Email updated"),!0)}async onActionChangePhone(){const e=await s.Dialog.prompt("Enter the new phone number for this user:","Change Phone",{defaultValue:this.model.get("phone_number")||""});return null===e||!e.trim()||(await this._saveField({phone_number:e.trim()},"Phone number updated"),!0)}async onActionSetPhone(){const e=await s.Dialog.prompt("Enter a phone number for this user:","Set Phone Number",{placeholder:"(415) 555-0123"});return!e||!e.trim()||(await this._saveField({phone_number:e.trim()},"Phone number added"),!0)}async onActionRemovePhone(){if(!(await s.Dialog.confirm("Remove this user's phone number?","Remove Phone")))return!0;const e=await this.model.save({phone_number:null});return 200===e.status?(this.model.set("is_phone_verified",!1),this.getApp()?.toast?.success("Phone number removed"),await this.render()):this.getApp()?.toast?.error(e.message||"Failed to remove phone number"),!0}async onActionEditUsername(){const e=await s.Dialog.prompt("Username:","Edit Username",{defaultValue:this.model.get("username")||""});return null!==e&&e.trim()&&await this._saveField({username:e.trim()},"Username updated"),!0}async _saveField(e,t){const s=await this.model.save(e);200===s.status?(this.getApp()?.toast?.success(t),await this.render()):this.getApp()?.toast?.error(s.message||"Failed to save")}}class AdminPersonalSection extends t.View{constructor(e={}){super({className:"admin-personal-section",template:'\n <style>\n .aps-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .aps-section-label:first-child { margin-top: 0; }\n .aps-field-row { display: flex; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid #f0f0f0; }\n .aps-field-row:last-child { border-bottom: none; }\n .aps-field-label { width: 140px; font-size: 0.8rem; color: #6c757d; flex-shrink: 0; }\n .aps-field-value { flex: 1; font-size: 0.88rem; color: #212529; display: flex; align-items: center; gap: 0.4rem; }\n .aps-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .aps-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n .aps-badge-ok { font-size: 0.65rem; padding: 0.15em 0.45em; background: #d1e7dd; color: #0f5132; border-radius: 3px; }\n .aps-badge-warn { font-size: 0.65rem; padding: 0.15em 0.45em; background: #fff3cd; color: #856404; border-radius: 3px; }\n .aps-not-set { color: #adb5bd; font-style: italic; font-size: 0.85rem; }\n </style>\n\n \x3c!-- Name --\x3e\n <div class="aps-section-label">Name</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Display Name</div>\n <div class="aps-field-value">\n {{#model.display_name}}{{model.display_name}}{{/model.display_name}}\n {{^model.display_name}}<span class="aps-not-set">Not set</span>{{/model.display_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">First Name</div>\n <div class="aps-field-value">\n {{#model.first_name}}{{model.first_name}}{{/model.first_name}}\n {{^model.first_name}}<span class="aps-not-set">Not set</span>{{/model.first_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-first-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">Last Name</div>\n <div class="aps-field-value">\n {{#model.last_name}}{{model.last_name}}{{/model.last_name}}\n {{^model.last_name}}<span class="aps-not-set">Not set</span>{{/model.last_name}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-last-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="aps-section-label">Details</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Date of Birth</div>\n <div class="aps-field-value">\n {{#hasDob|bool}}\n {{dobFormatted}}\n {{#model.is_dob_verified|bool}}<span class="aps-badge-ok">Verified</span>{{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}<span class="aps-badge-warn">Unverified</span>{{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n {{^hasDob|bool}}<span class="aps-not-set">Not set</span>{{/hasDob|bool}}\n </div>\n {{#hasDob|bool}}\n {{#model.is_dob_verified|bool}}\n <button type="button" class="aps-field-action" data-action="unverify-dob" title="Mark as unverified"><i class="bi bi-x-circle"></i></button>\n {{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}\n <button type="button" class="aps-field-action" data-action="force-verify-dob" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n <button type="button" class="aps-field-action" data-action="edit-dob" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="aps-field-row">\n <div class="aps-field-label">Timezone</div>\n <div class="aps-field-value">{{timezoneDisplay}}</div>\n <button type="button" class="aps-field-action" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n\n \x3c!-- Address --\x3e\n <div class="aps-section-label">Address</div>\n <div class="aps-field-row">\n <div class="aps-field-label">Address</div>\n <div class="aps-field-value">\n {{#hasAddress|bool}}{{addressSummary}}{{/hasAddress|bool}}\n {{^hasAddress|bool}}<span class="aps-not-set">Not set</span>{{/hasAddress|bool}}\n </div>\n <button type="button" class="aps-field-action" data-action="edit-address" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n ',...e})}get hasDob(){return!!this.model?.get("dob")}get dobFormatted(){const e=this.model?.get("dob");if(!e)return"";try{const[t,s,i]=e.split("-");return`${s}/${i}/${t}`}catch{return e}}get timezoneDisplay(){return(this.model?.get("metadata")||{}).timezone||"Not set"}get hasAddress(){const e=this.model?.get("metadata")||{};return!!(e.street||e.city||e.state||e.zip||e.country)}get addressSummary(){const e=this.model?.get("metadata")||{};return[e.street,e.city,e.state,e.zip,e.country].filter(Boolean).join(", ")}async onActionForceVerifyDob(){return!(await s.Dialog.confirm("Mark date of birth as verified?","Force Verify DOB"))||(await this._saveField({is_dob_verified:!0},"DOB marked as verified"),!0)}async onActionUnverifyDob(){return!(await s.Dialog.confirm("Mark date of birth as unverified?","Unverify DOB"))||(await this._saveField({is_dob_verified:!1},"DOB marked as unverified"),!0)}async onActionEditDisplayName(){const e=await s.Dialog.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return null!==e&&e.trim()&&await this._saveField({display_name:e.trim()},"Display name"),!0}async onActionEditFirstName(){const e=await s.Dialog.prompt("First name:","Edit First Name",{defaultValue:this.model.get("first_name")||""});return null!==e&&await this._saveField({first_name:e.trim()},"First name"),!0}async onActionEditLastName(){const e=await s.Dialog.prompt("Last name:","Edit Last Name",{defaultValue:this.model.get("last_name")||""});return null!==e&&await this._saveField({last_name:e.trim()},"Last name"),!0}async onActionEditDob(){const e=await s.Dialog.showForm({title:"Date of Birth",size:"sm",fields:[{name:"dob",type:"date",label:"Date of Birth",cols:12}],data:{dob:this.model.get("dob")||""}});return!e||(await this._saveField({dob:e.dob||null},"Date of birth"),!0)}async onActionEditTimezone(){const e=this.model.get("metadata")||{},t=await s.Dialog.showForm({title:"Change Timezone",size:"sm",fields:[{name:"timezone",type:"select",label:"Timezone",cols:12,options:[{value:"America/New_York",text:"Eastern Time (ET)"},{value:"America/Chicago",text:"Central Time (CT)"},{value:"America/Denver",text:"Mountain Time (MT)"},{value:"America/Los_Angeles",text:"Pacific Time (PT)"},{value:"America/Anchorage",text:"Alaska Time (AKT)"},{value:"Pacific/Honolulu",text:"Hawaii Time (HT)"},{value:"UTC",text:"UTC"},{value:"Europe/London",text:"London (GMT/BST)"},{value:"Europe/Paris",text:"Paris (CET/CEST)"},{value:"Europe/Berlin",text:"Berlin (CET/CEST)"},{value:"Asia/Tokyo",text:"Tokyo (JST)"},{value:"Asia/Shanghai",text:"Shanghai (CST)"},{value:"Australia/Sydney",text:"Sydney (AEST)"}]}],data:{timezone:e.timezone||""}});return!t||(await this._saveField({metadata:{...e,timezone:t.timezone}},"Timezone"),!0)}async onActionEditAddress(){const e=this.model.get("metadata")||{},t=await s.Dialog.showForm({title:"Edit Address",size:"md",fields:[{name:"street",type:"text",label:"Street",placeholder:"123 Main St",cols:12},{name:"city",type:"text",label:"City",cols:6},{name:"state",type:"text",label:"State / Province",cols:6},{name:"zip",type:"text",label:"Zip / Postal Code",cols:6},{name:"country",type:"text",label:"Country",cols:6}],data:{street:e.street||"",city:e.city||"",state:e.state||"",zip:e.zip||"",country:e.country||""}});if(!t)return!0;const i={...e,street:t.street||"",city:t.city||"",state:t.state||"",zip:t.zip||"",country:t.country||""};return await this._saveField({metadata:i},"Address"),!0}async _saveField(e,t){const s=await this.model.save(e);200===s.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(s.message||`Failed to update ${t.toLowerCase()}`)}}class AdminSecuritySection extends t.View{constructor(e={}){super({className:"admin-security-section",template:'\n <style>\n .as-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.75rem; }\n .as-section-label:first-child { margin-top: 0; }\n .as-item { display: flex; align-items: center; gap: 0.85rem; padding: 0.85rem 1rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.5rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }\n .as-item:hover { border-color: #dee2e6; background: #fafbfd; }\n .as-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }\n .as-info { flex: 1; min-width: 0; }\n .as-title { font-weight: 600; font-size: 0.88rem; }\n .as-desc { font-size: 0.78rem; color: #6c757d; }\n .as-badge { font-size: 0.72rem; padding: 0.15em 0.5em; border-radius: 3px; flex-shrink: 0; }\n .as-chevron { color: #ced4da; font-size: 0.8rem; flex-shrink: 0; }\n </style>\n\n <div class="as-section-label">Authentication</div>\n\n <div class="as-item" data-action="send-password-reset">\n <div class="as-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="as-info">\n <div class="as-title">Send Password Reset</div>\n <div class="as-desc">Send a password reset email to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n\n <div class="as-item" data-action="send-magic-link">\n <div class="as-icon" style="background: rgba(13,110,253,0.1); color: #0d6efd;"><i class="bi bi-link-45deg"></i></div>\n <div class="as-info">\n <div class="as-title">Send Magic Login Link</div>\n <div class="as-desc">Send a one-click login link to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n\n {{^model.is_email_verified|bool}}\n <div class="as-item" data-action="send-email-verification">\n <div class="as-icon" style="background: rgba(25,135,84,0.1); color: #198754;"><i class="bi bi-envelope-check"></i></div>\n <div class="as-info">\n <div class="as-title">Send Email Verification</div>\n <div class="as-desc">Send a verification email to {{model.email}}</div>\n </div>\n <span class="as-badge bg-light text-muted border">Send</span>\n </div>\n {{/model.is_email_verified|bool}}\n\n <div class="as-item" data-action="set-password">\n <div class="as-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="as-info">\n <div class="as-title">Set Password</div>\n <div class="as-desc">Set a new password directly for this user</div>\n </div>\n <span class="as-badge bg-light text-muted border">Set</span>\n </div>\n\n <div class="as-section-label">Multi-Factor Authentication</div>\n\n <div class="as-item" data-action="toggle-mfa">\n <div class="as-icon" style="background: rgba(111,66,193,0.1); color: #6f42c1;"><i class="bi bi-shield-lock"></i></div>\n <div class="as-info">\n <div class="as-title">MFA Requirement</div>\n <div class="as-desc">\n {{#model.requires_mfa|bool}}User is required to use MFA{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}MFA is not required for this user{{/model.requires_mfa|bool}}\n </div>\n </div>\n {{#model.requires_mfa|bool}}\n <span class="as-badge bg-success bg-opacity-10 text-success border">Enabled</span>\n {{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}\n <span class="as-badge bg-light text-muted border">Disabled</span>\n {{/model.requires_mfa|bool}}\n </div>\n\n <div class="as-item" data-action="manage-passkeys">\n <div class="as-icon bg-success bg-opacity-10 text-success"><i class="bi bi-fingerprint"></i></div>\n <div class="as-info">\n <div class="as-title">Passkeys</div>\n <div class="as-desc">View and manage registered passkeys</div>\n </div>\n <i class="bi bi-chevron-right as-chevron"></i>\n </div>\n\n {{#model.requires_mfa|bool}}\n <div class="as-item" data-action="view-recovery-codes">\n <div class="as-icon" style="background: rgba(111,66,193,0.1); color: #6f42c1;"><i class="bi bi-file-earmark-lock"></i></div>\n <div class="as-info">\n <div class="as-title">Recovery Codes</div>\n <div class="as-desc">View remaining recovery codes</div>\n </div>\n <i class="bi bi-chevron-right as-chevron"></i>\n </div>\n\n <div class="as-item" data-action="disable-totp">\n <div class="as-icon" style="background: rgba(220,53,69,0.1); color: #dc3545;"><i class="bi bi-shield-x"></i></div>\n <div class="as-info">\n <div class="as-title">Disable Authenticator</div>\n <div class="as-desc">Remove TOTP requirement for this user</div>\n </div>\n </div>\n {{/model.requires_mfa|bool}}\n\n <div class="as-section-label">Sessions</div>\n\n <div class="as-item" data-action="revoke-all-sessions">\n <div class="as-icon" style="background: rgba(220,53,69,0.1); color: #dc3545;"><i class="bi bi-box-arrow-right"></i></div>\n <div class="as-info">\n <div class="as-title">Revoke All Sessions</div>\n <div class="as-desc">Force sign-out from all devices</div>\n </div>\n </div>\n ',...e})}async onActionSendPasswordReset(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a password reset email to <strong>${i}</strong>?`,"Send Password Reset")))return!0;const a=await t.rest.POST("/api/auth/password/reset",{email:i});return a.success?e?.toast?.success("Password reset email sent"):e?.toast?.error(a.message||"Failed to send password reset"),!0}async onActionSendEmailVerification(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a verification email to <strong>${i}</strong>?`,"Send Email Verification")))return!0;const a=await t.rest.POST("/api/auth/email/verify",{email:i});return a.success?e?.toast?.success("Verification email sent"):e?.toast?.error(a.message||"Failed to send verification email"),!0}async onActionSendMagicLink(){const e=this.getApp(),i=this.model.get("email");if(!(await s.Dialog.confirm(`Send a magic login link to <strong>${i}</strong>? They will be able to sign in with one click.`,"Send Magic Login Link")))return!0;const a=await t.rest.POST("/api/auth/magic-link",{email:i});return a.success?e?.toast?.success("Magic login link sent"):e?.toast?.error(a.message||"Failed to send magic link"),!0}async onActionSetPassword(){const e=this.getApp(),t=await s.Dialog.showForm({title:"Set Password",size:"sm",fields:[{name:"password",type:"password",label:"New Password",required:!0,cols:12,help:"Set a new password for this user."},{name:"confirm",type:"password",label:"Confirm Password",required:!0,cols:12}]});if(!t)return!0;if(t.password!==t.confirm)return e?.toast?.error("Passwords do not match"),!0;const i=await this.model.save({password:t.password});return 200===i.status?e?.toast?.success("Password updated"):e?.toast?.error(i.message||"Failed to set password"),!0}async onActionToggleMfa(){const e=this.getApp(),t=this.model.get("requires_mfa"),i=t?"disable":"enable";if(!(await s.Dialog.confirm((t?"Disable":"Enable")+" MFA requirement for this user?",(t?"Disable":"Enable")+" MFA")))return!0;const a=await this.model.save({requires_mfa:!t});return 200===a.status?(e?.toast?.success(`MFA ${i}d`),await this.render()):e?.toast?.error(a.message||`Failed to ${i} MFA`),!0}async onActionDisableTotp(){const e=this.getApp();if(!(await s.Dialog.confirm("Disable the authenticator app for this user? They will no longer need a TOTP code to sign in.","Disable Authenticator")))return!0;const i=await t.rest.DELETE(`/api/user/${this.model.id}/totp`);return i.success?(this.model.set("requires_mfa",!1),e?.toast?.success("Authenticator disabled"),await this.render()):e?.toast?.error(i.message||"Failed to disable authenticator"),!0}async onActionManagePasskeys(){const e=new n.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch(o){}const i=e.models||[],a=new t.View({template:'\n <style>\n .pk-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.65rem 0.75rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.4rem; }\n .pk-icon { width: 32px; height: 32px; background: #e7f1ff; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #0d6efd; font-size: 0.9rem; flex-shrink: 0; }\n .pk-info { flex: 1; min-width: 0; }\n .pk-name { font-weight: 600; font-size: 0.85rem; }\n .pk-meta { font-size: 0.73rem; color: #6c757d; }\n .pk-actions .btn { padding: 0.2rem 0.4rem; font-size: 0.75rem; }\n .pk-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .pk-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n {{#passkeys}}\n <div class="pk-row">\n <div class="pk-icon"><i class="bi bi-fingerprint"></i></div>\n <div class="pk-info">\n <div class="pk-name">{{.friendly_name|default:\'Unnamed Passkey\'}}</div>\n <div class="pk-meta">Created {{.created|date}} · Last used {{.last_used|relative|default:\'never\'}} · {{.sign_count}} uses</div>\n </div>\n <div class="pk-actions">\n <button type="button" class="btn btn-outline-secondary" data-action="edit-passkey" data-id="{{.id}}" title="Edit"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-outline-danger" data-action="delete-passkey" data-id="{{.id}}" title="Delete"><i class="bi bi-trash"></i></button>\n </div>\n </div>\n {{/passkeys}}\n {{^passkeys|bool}}\n <div class="pk-empty">\n <i class="bi bi-fingerprint"></i>\n No passkeys registered\n </div>\n {{/passkeys|bool}}\n '});return a.passkeys=i.map(e=>e.toJSON?e.toJSON():e),a.onActionEditPasskey=async(e,t)=>{const a=t.dataset.id,o=i.find(e=>String(e.id)===String(a));return o&&await s.Dialog.showModelForm({title:"Edit Passkey",model:o,fields:n.PasskeyForms.edit.fields,size:"sm"}),!0},a.onActionDeletePasskey=async(e,t)=>{const a=t.dataset.id;if(await s.Dialog.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(a));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await s.Dialog.showDialog({title:"Passkeys",body:a,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionViewRecoveryCodes(){const e=this.getApp(),i=await t.rest.GET(`/api/user/${this.model.id}/totp/recovery-codes`,{},{dataOnly:!0});if(!i.success||!i.data)return e?.toast?.error(i.message||"Failed to load recovery codes"),!0;const{remaining:a,codes:n}=i.data,o=new t.View({template:'\n <style>\n .rc-info { font-size: 0.82rem; color: #6c757d; margin-bottom: 1rem; }\n .rc-remaining { font-weight: 600; color: #495057; }\n .rc-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem; }\n .rc-code { font-family: monospace; font-size: 0.85rem; padding: 0.35rem 0.6rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; text-align: center; }\n </style>\n <div class="rc-info"><span class="rc-remaining">{{remaining}}</span> recovery codes remaining</div>\n <div class="rc-list">\n {{#codes}}<div class="rc-code">{{.}}</div>{{/codes}}\n </div>\n '});return o.remaining=a,o.codes=n||[],await s.Dialog.showDialog({title:"Recovery Codes",body:o,size:"sm",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionRevokeAllSessions(){const e=this.getApp();if(!(await s.Dialog.confirm("Revoke all sessions for this user? They will be signed out of all devices immediately.","Revoke All Sessions")))return!0;const i=await t.rest.POST(`/api/user/${this.model.id}/sessions/revoke`);return i.success?e?.toast?.success("All sessions revoked"):e?.toast?.error(i.message||"Failed to revoke sessions"),!0}}const b={google:"bi-google",github:"bi-github",microsoft:"bi-microsoft",apple:"bi-apple",facebook:"bi-facebook",twitter:"bi-twitter-x",linkedin:"bi-linkedin"};class AdminConnectedSection extends t.View{constructor(e={}){super({className:"admin-connected-section",template:'\n <style>\n .ac-row { display: flex; align-items: center; gap: 0.85rem; padding: 0.85rem 1rem; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 0.5rem; }\n .ac-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; background: #f0f0f0; color: #495057; }\n .ac-info { flex: 1; min-width: 0; }\n .ac-provider { font-weight: 600; font-size: 0.88rem; text-transform: capitalize; }\n .ac-meta { font-size: 0.78rem; color: #6c757d; }\n .ac-actions .btn { font-size: 0.75rem; padding: 0.25rem 0.5rem; }\n .ac-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .ac-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n\n {{#connections}}\n <div class="ac-row">\n <div class="ac-icon"><i class="bi {{.icon}}"></i></div>\n <div class="ac-info">\n <div class="ac-provider">{{.provider}}</div>\n <div class="ac-meta">{{.email}} · Connected {{.created|relative}}</div>\n </div>\n <div class="ac-actions">\n <button type="button" class="btn btn-outline-danger" data-action="unlink" data-id="{{.id}}" title="Unlink"><i class="bi bi-x-lg me-1"></i>Unlink</button>\n </div>\n </div>\n {{/connections}}\n {{^connections|bool}}\n <div class="ac-empty">\n <i class="bi bi-plug"></i>\n No connected accounts\n </div>\n {{/connections|bool}}\n ',...e}),this.connections=[]}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id}),s=e?.data?.results||e?.data||[];this.connections=s.map(e=>({...e,icon:b[e.provider]||"bi-link-45deg"}))}catch(e){this.connections=[]}}async onActionUnlink(e,i){const a=i.dataset.id,n=this.connections.find(e=>String(e.id)===String(a)),o=n?.provider||"this account";if(!(await s.Dialog.confirm(`Unlink ${o} for this user?`,"Unlink Account")))return!0;const l=await t.rest.DELETE(`/api/account/oauth_connection/${a}`);return l.success?(this.getApp()?.toast?.success(`${o} account unlinked`),await this.render()):this.getApp()?.toast?.error(l.message||"Failed to unlink account"),!0}}const h={in_app:"In-App",email:"Email",push:"Push"},p=["in_app","email","push"];class AdminNotificationsSection extends t.View{constructor(e={}){super({className:"admin-notifications-section",template:'\n <style>\n .an-table { width: 100%; border-collapse: collapse; }\n .an-table th { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; padding: 0.5rem 0.75rem; border-bottom: 2px solid #e9ecef; }\n .an-table th:first-child { text-align: left; }\n .an-table th:not(:first-child) { text-align: center; width: 80px; }\n .an-table td { padding: 0.65rem 0.75rem; border-bottom: 1px solid #f0f0f0; }\n .an-table td:first-child { font-size: 0.88rem; font-weight: 500; text-transform: capitalize; }\n .an-table td:not(:first-child) { text-align: center; }\n .an-table tr:last-child td { border-bottom: none; }\n .an-empty { text-align: center; padding: 2rem 1rem; color: #6c757d; }\n .an-empty i { font-size: 2rem; color: #ced4da; display: block; margin-bottom: 0.5rem; }\n </style>\n\n {{#hasPreferences|bool}}\n <table class="an-table">\n <thead>\n <tr>\n <th>Type</th>\n {{#channels}}\n <th>{{.label}}</th>\n {{/channels}}\n </tr>\n </thead>\n <tbody>\n {{#preferenceRows}}\n <tr>\n <td>{{.kindLabel}}</td>\n {{#.toggles}}\n <td>\n <input type="checkbox" class="form-check-input"\n data-action="toggle-pref"\n data-kind="{{.kind}}"\n data-channel="{{.channel}}"\n {{#.checked}}checked{{/.checked}}>\n </td>\n {{/.toggles}}\n </tr>\n {{/preferenceRows}}\n </tbody>\n </table>\n {{/hasPreferences|bool}}\n {{^hasPreferences|bool}}\n <div class="an-empty">\n <i class="bi bi-bell"></i>\n No notification preferences configured\n </div>\n {{/hasPreferences|bool}}\n ',...e}),this.preferences={}}get channels(){return p.map(e=>({key:e,label:h[e]||e}))}get hasPreferences(){return Object.keys(this.preferences).length>0}get preferenceRows(){return Object.keys(this.preferences).sort().map(e=>({kind:e,kindLabel:e.replace(/[_-]/g," ").replace(/\b\w/g,e=>e.toUpperCase()),toggles:p.map(t=>({kind:e,channel:t,checked:!1!==this.preferences[e]?.[t]}))}))}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/notification/preferences",{user:this.model.id},{dataOnly:!0});this.preferences=e?.data?.preferences||e?.data||{}}catch(e){this.preferences={}}}async onActionTogglePref(e,s){const i=s.dataset.kind,a=s.dataset.channel,n=s.checked;this.preferences[i]||(this.preferences[i]={}),this.preferences[i][a]=n;try{const e=await t.rest.POST("/api/account/notification/preferences",{user:this.model.id,preferences:{[i]:{[a]:n}}});e.success||(this.getApp()?.toast?.error(e.message||"Failed to update preference"),s.checked=!n)}catch(o){this.getApp()?.toast?.error("Failed to update preference"),s.checked=!n}return!0}}class AdminApiKeysSection extends t.View{constructor(e={}){super({className:"admin-api-keys-section",template:'\n <style>\n .aak-list { border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden; }\n .aak-item { display: flex; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #f0f0f0; gap: 1rem; }\n .aak-item:last-child { border-bottom: none; }\n .aak-item-icon { color: #6c757d; font-size: 1.1rem; flex-shrink: 0; }\n .aak-item-info { flex: 1; min-width: 0; }\n .aak-item-name { font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; }\n .aak-item-meta { font-size: 0.75rem; color: #6c757d; display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.15rem; }\n .aak-item-meta i { margin-right: 0.2rem; }\n .aak-empty { padding: 2rem; text-align: center; color: #6c757d; font-size: 0.85rem; }\n .aak-empty i { font-size: 1.5rem; display: block; margin-bottom: 0.5rem; }\n .aak-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }\n .aak-header h6 { margin: 0; font-weight: 600; }\n .aak-result { padding: 1rem; background: #d1e7dd; border: 1px solid #badbcc; border-radius: 8px; margin-bottom: 1rem; }\n .aak-result-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #0f5132; margin-bottom: 0.5rem; }\n .aak-token-wrap { display: flex; gap: 0.5rem; align-items: center; }\n .aak-token { flex: 1; font-family: monospace; font-size: 0.78rem; padding: 0.5rem 0.75rem; background: #fff; border: 1px solid #dee2e6; border-radius: 4px; word-break: break-all; max-height: 80px; overflow-y: auto; }\n .aak-token-warning { font-size: 0.75rem; color: #dc3545; margin-top: 0.5rem; font-weight: 600; }\n </style>\n\n <div id="aak-new-token" style="display: none;">\n <div class="aak-result">\n <div class="aak-result-label">Generated API Key</div>\n <div class="aak-token-wrap">\n <div class="aak-token" id="aak-token-display"></div>\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="copy-token" title="Copy">\n <i class="bi bi-clipboard"></i>\n </button>\n </div>\n <div class="aak-token-warning">\n <i class="bi bi-exclamation-circle me-1"></i>This token will not be shown again. Copy it now.\n </div>\n </div>\n </div>\n\n <div class="aak-header">\n <h6>API Keys</h6>\n <button type="button" class="btn btn-primary btn-sm" data-action="generate-key">\n <i class="bi bi-plus-lg me-1"></i>Generate Key\n </button>\n </div>\n\n <div id="aak-keys-list"></div>\n ',...e}),this.apiKeys=[],this.generatedToken=null}async onBeforeRender(){await this._loadKeys()}async _loadKeys(){try{const e=await t.rest.GET("/api/account/api_keys",{user:this.model.id},{},{dataOnly:!0});this.apiKeys=e.success&&Array.isArray(e.data)?e.data:[]}catch(e){this.apiKeys=[]}}onAfterRender(){this._renderKeysList()}_renderKeysList(){const e=this.element?.querySelector("#aak-keys-list");if(!e)return;if(!this.apiKeys.length)return void(e.innerHTML='\n <div class="aak-list">\n <div class="aak-empty">\n <i class="bi bi-key"></i>\n No API keys for this user\n </div>\n </div>');const t=this.apiKeys.map(e=>{const t=e.name||"API Key",s=e.created?new Date(1e3*e.created).toLocaleDateString():"",i=e.expires?new Date(1e3*e.expires).toLocaleDateString():"Never",a=e.last_used?new Date(1e3*e.last_used).toLocaleDateString():"Never",n=e.allowed_ips?.length?e.allowed_ips.join(", "):"Any";return`\n <div class="aak-item">\n <div class="aak-item-icon"><i class="bi bi-key"></i></div>\n <div class="aak-item-info">\n <div class="aak-item-name">${t} ${!1!==e.is_active?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}</div>\n <div class="aak-item-meta">\n <span><i class="bi bi-code-square"></i>${e.token_prefix?`${e.token_prefix}...`:"••••••••"}</span>\n <span><i class="bi bi-calendar"></i>Created ${s}</span>\n <span><i class="bi bi-clock"></i>Expires ${i}</span>\n <span><i class="bi bi-activity"></i>Last used ${a}</span>\n <span><i class="bi bi-globe"></i>IPs: ${n}</span>\n </div>\n </div>\n <div>\n <button type="button" class="btn btn-outline-danger btn-sm" data-action="revoke-key" data-id="${e.id}" title="Revoke">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n </div>`}).join("");e.innerHTML=`<div class="aak-list">${t}</div>`}async onActionGenerateKey(){const e=await s.Dialog.showForm({title:`Generate API Key for ${this.model.get("display_name")||this.model.get("email")}`,icon:"bi-key",fields:[{name:"name",type:"text",label:"Key Name",placeholder:"e.g., CI/CD Pipeline, Mobile App",required:!0,help:"A descriptive name to identify this key."},{name:"allowed_ips",type:"text",label:"Allowed IPs",placeholder:"e.g., 203.0.113.0/24, 10.0.0.1",help:"Optional. Comma-separated IP addresses or CIDR ranges."},{name:"expire_days",type:"select",label:"Expiration",value:"90",options:[{value:"30",label:"30 days"},{value:"60",label:"60 days"},{value:"90",label:"90 days"},{value:"180",label:"180 days"},{value:"360",label:"360 days"}]}]});if(!e)return!0;const i={uid:this.model.id,name:e.name,expire_days:parseInt(e.expire_days||"90",10)},a=(e.allowed_ips||"").trim();a&&(i.allowed_ips=a.split(",").map(e=>e.trim()).filter(Boolean));const n=await t.rest.POST("/api/auth/manage/generate_api_key",i,{},{dataOnly:!0});if(n.success&&n.data?.token){this.generatedToken=n.data.token;const e=this.element.querySelector("#aak-new-token"),t=this.element.querySelector("#aak-token-display");e&&t&&(t.textContent=this.generatedToken,e.style.display="block"),this.getApp()?.toast?.success("API key generated"),await this._loadKeys(),this._renderKeysList()}else this.getApp()?.toast?.error(n.message||"Failed to generate API key");return!0}async onActionRevokeKey(e,i){const a=i.dataset.id;if(!a)return!0;if(!(await s.Dialog.confirm("Revoke this API key? Any applications using it will lose access immediately.","Revoke API Key")))return!0;const n=await t.rest.DELETE(`/api/account/api_keys/${a}`,{},{},{dataOnly:!0});if(n.success){this.getApp()?.toast?.success("API key revoked");const e=this.element?.querySelector("#aak-new-token");e&&(e.style.display="none"),await this._loadKeys(),this._renderKeysList()}else this.getApp()?.toast?.error(n.message||"Failed to revoke API key");return!0}async onActionCopyToken(){if(this.generatedToken)try{await navigator.clipboard.writeText(this.generatedToken),this.getApp()?.toast?.success("Token copied to clipboard")}catch{this.getApp()?.toast?.error("Failed to copy token")}return!0}}class AdminMetadataSection extends t.View{constructor(e={}){super({className:"admin-metadata-section",template:'\n <style>\n .amd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }\n .amd-header h6 { margin: 0; font-weight: 600; }\n .amd-list { border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden; }\n .amd-item { display: flex; align-items: center; padding: 0.6rem 1rem; border-bottom: 1px solid #f0f0f0; gap: 0.75rem; }\n .amd-item:last-child { border-bottom: none; }\n .amd-key { font-family: ui-monospace, monospace; font-size: 0.82rem; font-weight: 600; color: #495057; min-width: 120px; flex-shrink: 0; }\n .amd-value { flex: 1; font-size: 0.85rem; color: #212529; word-break: break-word; min-width: 0; }\n .amd-actions { flex-shrink: 0; display: flex; gap: 0.25rem; }\n .amd-actions .btn { font-size: 0.7rem; padding: 0.15rem 0.35rem; }\n .amd-empty { padding: 2rem; text-align: center; color: #6c757d; font-size: 0.85rem; }\n .amd-empty i { font-size: 1.5rem; display: block; margin-bottom: 0.5rem; }\n </style>\n\n <div class="amd-header">\n <h6>Metadata</h6>\n <button type="button" class="btn btn-primary btn-sm" data-action="add-entry">\n <i class="bi bi-plus-lg me-1"></i>Add\n </button>\n </div>\n\n <div id="amd-entries"></div>\n ',...e})}onAfterRender(){this._renderEntries()}_getMetadata(){return this.model?.get("metadata")||{}}_renderEntries(){const e=this.element?.querySelector("#amd-entries");if(!e)return;const t=this._getMetadata(),s=Object.keys(t).sort();if(!s.length)return void(e.innerHTML='\n <div class="amd-list">\n <div class="amd-empty">\n <i class="bi bi-braces"></i>\n No metadata entries\n </div>\n </div>');const i=s.map(e=>{const s=t[e],i="object"==typeof s?JSON.stringify(s):String(s);return`\n <div class="amd-item">\n <div class="amd-key">${this._escapeHtml(e)}</div>\n <div class="amd-value">${this._escapeHtml(i)}</div>\n <div class="amd-actions">\n <button type="button" class="btn btn-outline-secondary" data-action="edit-entry" data-key="${this._escapeHtml(e)}" title="Edit"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-outline-danger" data-action="remove-entry" data-key="${this._escapeHtml(e)}" title="Remove"><i class="bi bi-trash"></i></button>\n </div>\n </div>`}).join("");e.innerHTML=`<div class="amd-list">${i}</div>`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionAddEntry(){const e=await s.Dialog.showForm({title:"Add Metadata Entry",icon:"bi-braces",size:"sm",fields:[{name:"key",type:"text",label:"Key",required:!0,placeholder:"e.g., timezone"},{name:"value",type:"text",label:"Value",required:!0,placeholder:"e.g., America/New_York"}]});if(!e)return!0;const t={...this._getMetadata()};try{t[e.key]=JSON.parse(e.value)}catch{t[e.key]=e.value}return 200===(await this.model.save({metadata:t})).status?(this.getApp()?.toast?.success("Metadata entry added"),this._renderEntries()):this.getApp()?.toast?.error("Failed to save metadata"),!0}async onActionEditEntry(e,t){const i=t.dataset.key;if(!i)return!0;const a=this._getMetadata(),n=a[i],o="object"==typeof n?JSON.stringify(n):String(n),l=await s.Dialog.showForm({title:`Edit "${i}"`,icon:"bi-braces",size:"sm",fields:[{name:"value",type:"text",label:"Value",required:!0,value:o}]});if(!l)return!0;const r={...a};try{r[i]=JSON.parse(l.value)}catch{r[i]=l.value}return 200===(await this.model.save({metadata:r})).status?(this.getApp()?.toast?.success("Metadata updated"),this._renderEntries()):this.getApp()?.toast?.error("Failed to save metadata"),!0}async onActionRemoveEntry(e,t){const i=t.dataset.key;if(!i)return!0;if(!(await s.Dialog.confirm(`Remove metadata key "<strong>${this._escapeHtml(i)}</strong>"?`,"Remove Entry")))return!0;const a={...this._getMetadata()};return delete a[i],200===(await this.model.save({metadata:a})).status?(this.getApp()?.toast?.success("Metadata entry removed"),this._renderEntries()):this.getApp()?.toast?.error("Failed to remove metadata entry"),!0}}class DeviceRow extends n.TableRow{get deviceIcon(){const e=this.model?.get("device_info")?.device||{},t=this.model?.get("device_info")?.os||{};return["iPhone","Android"].some(s=>(e.family||"").includes(s)||(t.family||"").includes(s))?"bi-phone":"bi-laptop"}get deviceName(){const e=this.model?.get("device_info")?.device||{};return`${e.brand||""} ${e.family||""}`.trim()||"Unknown Device"}get deviceModel(){return this.model?.get("device_info")?.device?.model||""}get browserName(){const e=this.model?.get("device_info")?.user_agent||{};return e.family?`${e.family} ${e.major||""}`.trim():""}get osName(){const e=this.model?.get("device_info")?.os||{};return e.family?`${e.family} ${e.major||""}`.trim():""}get deviceMeta(){return[this.browserName,this.osName].filter(Boolean).join(" · ")||"—"}}class UserView extends t.View{constructor(e={}){super({className:"user-view",...e}),this.model=e.model||new s.User(e.data||{}),this.sideNavView=null,this.template='\n <div class="user-view-container">\n \x3c!-- User Header + Context Menu --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div data-container="user-header" style="flex: 1;"></div>\n <div data-container="user-context-menu" class="ms-3 flex-shrink-0"></div>\n </div>\n \x3c!-- Side Nav Container --\x3e\n <div data-container="user-sidenav" style="min-height: 400px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"user-header",template:'\n <div class="d-flex justify-content-between align-items-start">\n \x3c!-- Left Side: Primary Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{{model.avatar|avatar(\'md\',\'rounded-circle\')}}}\n <div>\n <h3 class="mb-0">{{model.display_name|default(\'Unnamed User\')}}</h3>\n <a href="mailto:{{model.email}}" class="text-decoration-none text-body">{{model.email}}</a>{{{model.email|clipboard(\'icon-only\')}}}\n {{#model.phone_number}}\n <div class="text-muted small mt-1">{{{model.phone_number|phone(false)}}}</div>\n {{/model.phone_number}}\n </div>\n </div>\n\n \x3c!-- Right Side: Status --\x3e\n <div class="text-end">\n <div class="d-flex align-items-center justify-content-end gap-3">\n <span class="d-inline-flex align-items-center gap-1" title="{{model.is_online|boolean(\'Online\',\'Offline\')}}">\n <i class="bi bi-circle-fill {{model.is_online|boolean(\'text-success\',\'text-secondary\')}}" style="font-size: 0.5rem;"></i>\n <span class="small">{{model.is_online|boolean(\'Online\',\'Offline\')}}</span>\n </span>\n <span class="d-inline-flex align-items-center gap-1" style="cursor: pointer;"\n data-action="toggle-active"\n title="{{model.is_active|boolean(\'Click to deactivate\',\'Click to activate\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.last_activity}}\n <div class="text-muted small mt-1">Last active {{model.last_activity|relative}}</div>\n {{/model.last_activity}}\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const i=new AdminProfileSection({model:this.model}),l=new AdminPersonalSection({model:this.model}),r=new AdminSecuritySection({model:this.model}),d=new AdminConnectedSection({model:this.model}),c=new AdminNotificationsSection({model:this.model}),m=new AdminApiKeysSection({model:this.model}),u=new AdminMetadataSection({model:this.model}),b=new o.FormView({fields:s.User.CATEGORY_PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),h=new o.FormView({fields:s.User.GRANULAR_PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),p=new n.MemberList({params:{user:this.model.get("id"),size:5}}),g=new n.TableView({collection:p,hideActivePillNames:["user"],columns:[{key:"created",label:"Date Joined",formatter:"date",sortable:!0},{key:"group.name",label:"Group Name",sortable:!0},{key:"permissions|keys|badge",label:"Permissions"}]}),v=new a.IncidentEventList({params:{size:5,model_name:"account.User",model_id:this.model.get("id")}}),f=new n.TableView({containerId:"events-table",collection:v,hideActivePillNames:["model_name","model_id"],columns:[{key:"id",label:"ID",sortable:!0,width:"40px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Event"}]}),y=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Security & Account Events</h6>\n <p class="text-muted small mb-3">Incidents and account actions associated with this user.</p>\n </div>\n <div data-container="events-table"></div>'});y.addChild(f);const w=new n.TableView({collection:new s.UserDeviceList({params:{size:10,user:this.model.get("id")}}),hideActivePillNames:["user"],clickAction:"view",itemClass:DeviceRow,columns:[{key:"device_info",label:"Device",template:'\n <div style="font-size:0.85rem; font-weight:500;">\n <i class="bi {{deviceIcon}} text-muted me-1" style="font-size:1.1rem; vertical-align:middle;"></i>{{deviceName}}\n {{#deviceModel}} <span class="text-muted fw-normal">({{deviceModel}})</span>{{/deviceModel}}\n </div>\n <div style="font-size:0.73rem; color:#6c757d; margin-top:0.15rem;">\n {{deviceMeta}}\n {{#model.last_ip}} <span class="text-muted mx-1">·</span> {{model.last_ip}}{{/model.last_ip}}\n </div>'},{key:"first_seen",label:"First Seen",formatter:"epoch|relative",width:"120px"},{key:"last_seen",label:"Last Seen",formatter:"epoch|relative",width:"120px"}]}),_=new LoginLocationMapView({userId:this.model.get("id"),height:300,mapStyle:"dark"}),x=new n.TableView({collection:new LoginEventList({params:{user:this.model.get("id"),size:10}}),hideActivePillNames:["user"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"ip_address",label:"IP Address"},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0}]});x.onTabActivated=async()=>{await(x.collection?.fetch())};const k=new a.TabView({tabs:{Map:_,Logins:x},activeTab:"Map"}),A=new a.PushDeviceList({params:{size:5,user:this.model.get("id")}}),S=new n.TableView({collection:A,hideActivePillNames:["user"],columns:[{key:"duid|truncate_middle(16)",label:"Device ID",sortable:!0},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')"},{key:"device_info.os.family",label:"OS",formatter:"default('—')"},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime"},{key:"last_seen",label:"Last Seen",formatter:"epoch|datetime"}],size:5}),C=new n.LogList({params:{size:5,model_name:"account.User",model_id:this.model.get("id")}}),I=new n.TableView({containerId:"logs-table",collection:C,permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Event Type",filter:{type:"text"}},{name:"log",label:"Details"}]}),T=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Object Logs</h6>\n <p class="text-muted small mb-3">System log entries about changes to this user\'s record.</p>\n </div>\n <div data-container="logs-table"></div>'});T.addChild(I);const P=new n.LogList({params:{size:5,uid:this.model.get("id")}}),D=new n.TableView({containerId:"activity-table",collection:P,hideActivePillNames:["uid"],permissions:"view_logs",columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Event Type",filter:{type:"text"}},{name:"path",label:"Request Path"}]}),V=new t.View({template:'\n <div class="mb-2">\n <h6 class="fw-semibold mb-1">Activity Log</h6>\n <p class="text-muted small mb-3">API and request activity performed by this user.</p>\n </div>\n <div data-container="activity-table"></div>'});V.addChild(D),this.sideNavView=new SideNavView({containerId:"user-sidenav",activeSection:"profile",navWidth:200,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:[{key:"profile",label:"Profile",icon:"bi-person",view:i},{key:"personal",label:"Personal",icon:"bi-person-vcard",view:l},{key:"security",label:"Security",icon:"bi-shield-lock",view:r},{key:"connected",label:"OAuth Accounts",icon:"bi-plug",view:d},{type:"divider",label:"Access"},{key:"permissions",label:"Permissions",icon:"bi-shield-check",view:b},{key:"adv_permissions",label:"Adv Permissions",icon:"bi-shield-plus",view:h},{key:"groups",label:"Groups",icon:"bi-people",view:g},{key:"api_keys",label:"API Keys",icon:"bi-key",view:m},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:y},{key:"activity",label:"Activity Log",icon:"bi-clock-history",view:V,permissions:"view_logs"},{key:"logs",label:"Object Logs",icon:"bi-journal-text",view:T,permissions:"view_logs"},{type:"divider",label:"Devices"},{key:"devices",label:"Devices",icon:"bi-laptop",view:w},{key:"locations",label:"Locations",icon:"bi-geo-alt",view:k},{key:"push_devices",label:"Push Devices",icon:"bi-phone",view:S},{type:"divider",label:"Settings"},{key:"notifications",label:"Notifications",icon:"bi-bell",view:c},{key:"metadata",label:"Metadata",icon:"bi-braces",view:u}]}),this.addChild(this.sideNavView);const M=new e.ContextMenu({containerId:"user-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit User",action:"edit-user",icon:"bi-pencil"},...this.model.get("avatar")?[{label:"Clear Avatar",action:"clear-avatar",icon:"bi-person-x"}]:[],{type:"divider"},{label:"Send Password Reset",action:"send-password-reset",icon:"bi-envelope"},{label:"Send Magic Login Link",action:"send-magic-link",icon:"bi-link-45deg"},{label:"Revoke All Sessions",action:"revoke-all-sessions",icon:"bi-box-arrow-right"},{type:"divider"},...this.model.get("is_email_verified")?[]:[{label:"Send Email Verification",action:"send-email-verification",icon:"bi-envelope-check"},{label:"Force Verify Email",action:"force-verify-email",icon:"bi-patch-check"}],...this.model.get("phone_number")&&!this.model.get("is_phone_verified")?[{label:"Force Verify Phone",action:"force-verify-phone",icon:"bi-patch-check"}]:[],{type:"divider"},this.model.get("is_active")?{label:"Deactivate User",action:"deactivate-user",icon:"bi-person-dash"}:{label:"Activate User",action:"activate-user",icon:"bi-person-check"}]}});this.addChild(M)}async onActionEditUser(){await s.Dialog.showModelForm({title:`EDIT - #${this.model.id} ${this.options.modelName}`,model:this.model,formConfig:s.UserForms.edit})}async onActionClearAvatar(){return!(await s.Dialog.confirm("Remove this user's avatar? They will see the default placeholder.","Clear Avatar"))||(200===(await this.model.save({avatar:null})).status?this.getApp().toast.success("Avatar cleared"):this.getApp().toast.error("Failed to clear avatar"),!0)}async onActionSendPasswordReset(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a password reset email to <strong>${e}</strong>?`,"Send Password Reset")))return!0;const i=await t.rest.POST("/api/auth/password/reset",{email:e});return i.success?this.getApp().toast.success("Password reset email sent"):this.getApp().toast.error(i.message||"Failed to send password reset"),!0}async onActionSendMagicLink(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a magic login link to <strong>${e}</strong>?`,"Send Magic Login Link")))return!0;const i=await t.rest.POST("/api/auth/magic-link",{email:e});return i.success?this.getApp().toast.success("Magic login link sent"):this.getApp().toast.error(i.message||"Failed to send magic link"),!0}async onActionRevokeAllSessions(){if(!(await s.Dialog.confirm("Revoke all sessions? The user will be signed out of all devices immediately.","Revoke All Sessions")))return!0;const e=await t.rest.POST(`/api/user/${this.model.id}/sessions/revoke`);return e.success?this.getApp().toast.success("All sessions revoked"):this.getApp().toast.error(e.message||"Failed to revoke sessions"),!0}async onActionSendEmailVerification(){const e=this.model.get("email");if(!(await s.Dialog.confirm(`Send a verification email to <strong>${e}</strong>?`,"Send Email Verification")))return!0;const i=await t.rest.POST("/api/auth/email/verify",{email:e});return i.success?this.getApp().toast.success("Verification email sent"):this.getApp().toast.error(i.message||"Failed to send verification email"),!0}async onActionForceVerifyEmail(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("email")}</strong> as verified?`,"Force Verify Email"))||(200===(await this.model.save({is_email_verified:!0})).status?this.getApp().toast.success("Email marked as verified"):this.getApp().toast.error("Failed to verify email"),!0)}async onActionForceVerifyPhone(){return!(await s.Dialog.confirm(`Mark <strong>${this.model.get("phone_number")}</strong> as verified?`,"Force Verify Phone"))||(200===(await this.model.save({is_phone_verified:!0})).status?this.getApp().toast.success("Phone marked as verified"):this.getApp().toast.error("Failed to verify phone"),!0)}async onActionToggleActive(){return this.model.get("is_active")?this.onActionDeactivateUser():this.onActionActivateUser()}async onActionDeactivateUser(){return!(await s.Dialog.confirm("Are you sure you want to deactivate this user?"))||(200===(await this.model.save({is_active:!1})).status?this.getApp().toast.success("User deactivated"):this.getApp().toast.error("Failed to deactivate user"),!0)}async onActionActivateUser(){return!(await s.Dialog.confirm("Are you sure you want to activate this user?"))||(200===(await this.model.save({is_active:!0})).status?this.getApp().toast.success("User activated"):this.getApp().toast.error("Failed to activate user"),!0)}async showSection(e){this.sideNavView&&await this.sideNavView.showSection(e)}getActiveSection(){return this.sideNavView?this.sideNavView.getActiveSection():null}async showTab(e){return this.showSection(e)}getActiveTab(){return this.getActiveSection()}_onModelChange(){}static create(e={}){return new UserView(e)}}s.User.VIEW_CLASS=UserView;class UserTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_users",pageName:"Manage Users",router:"admin/users",Collection:s.UserList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-last_activity",is_active:!0},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"display_name|tooltip:model.username",label:"Display Name"},{label:"Info",key:"permissions.manage_users",template:"\n {{^model.is_active}}<span class=\"text-danger\">DISABLED</span> {{/model.is_active}}\n {{#model.permissions.manage_users}}{{{model.permissions.manage_users|yesnoicon('bi bi-person-gear text-danger')|tooltip('Manage Users')}}} {{/model.permissions.manage_users}}\n {{#model.permissions.manage_groups}}{{{model.permissions.manage_groups|yesnoicon('bi bi-building-gear text-primary')|tooltip('Manage Groups')}}} {{/model.permissions.manage_groups}}\n {{#model.permissions.view_global}}{{{model.permissions.view_global|yesnoicon('bi bi-globe text-secondary')|tooltip('View Global Menu')}}} {{/model.permissions.view_global}}\n {{#model.permissions.view_admin}}{{{model.permissions.view_admin|yesnoicon('bi bi-wrench text-secondary')|tooltip('View Admin Menu')}}} {{/model.permissions.view_admin}}\n ",sortable:!1},{key:"email",label:"Email",visibility:"xl",className:"text-muted fs-8"},{key:"last_activity",label:"Last Activity",formatter:"relative",className:"text-muted fs-8"}],filters:[{key:"is_active",label:"Active",type:"boolean",defaultValue:!0},{key:"email",label:"Email",type:"text",defaultValue:""},{key:"username",label:"Username",type:"text",defaultValue:""},{key:"locations__ip_address",label:"IP Address",type:"text",defaultValue:""},{key:"last_activity",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No users found. Click "Add" to create a new user.',contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Profile"},{icon:"bi-shield-check",action:"edit-permissions",label:"Edit Permissions"},{icon:"bi-shield",action:"change-password",label:"Change Password"},{separator:!0},{icon:"bi-envelope",action:"send-invite",label:"Send Invite"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditPermissions(e,t){e.preventDefault();const i=this.collection.get(t.dataset.id);await s.Dialog.showModelForm({model:i,size:"lg",title:`Edit Permissions for "${i._.username}"`,fields:s.UserForms.permissions.fields})}async onActionChangePassword(e,i){const a=this.collection.get(i.dataset.id),n=await s.Dialog.showForm({title:`Change Password for "${a._.username}"`,fields:[{type:"text",name:"username",value:a.get("email")||a.get("username"),attributes:{autocomplete:"username",readonly:"readonly",tabindex:"-1",style:"position: absolute; left: -9999px; opacity: 0; height: 0; width: 0;"}},{name:"new_password",label:"New Password",type:"password",passwordUsage:"new",required:!0,showToggle:!0,attributes:{autocomplete:"new-password"}}]});if(n&&n.new_password){if(t.MOJOUtils.checkPasswordStrength(n.new_password).score<5)return this.getApp().toast.error("Password must be at least 6 characters long and contain at least 2 of the following: uppercase letter, lowercase letter, or number"),void(await this.onActionChangePassword(e,i));const s=await a.save({new_password:n.new_password});this.onPasswordChange(s)||await this.onActionChangePassword(e,i)}}onPasswordChange(e){return e.success?(this.getApp().toast.success("Password changed successfully"),!0):(e.data&&e.data.error?this.getApp().toast.error(e.data.error):this.getApp().toast.error("Failed to change password"),!1)}async onActionSendInvite(e,t){const s=this.collection.get(t.dataset.id),i=await s.save({send_invite:!0});return i.success?(this.getApp().toast.success("Invite sent successfully"),!0):(i.data&&i.data.error?this.getApp().toast.error(i.data.error):this.getApp().toast.error("Failed to send invite"),!1)}}class MemberView extends t.View{constructor(e={}){super({className:"member-view",...e}),this.model=e.model||new n.Member(e.data||{}),this.template='\n <div class="member-view-container">\n \x3c!-- Header + Context Menu --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div data-container="member-header" style="flex: 1;"></div>\n <div data-container="member-context-menu" class="ms-3 flex-shrink-0"></div>\n </div>\n \x3c!-- Side Nav --\x3e\n <div data-container="member-sidenav" style="min-height: 300px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"member-header",template:'\n <div class="d-flex justify-content-between align-items-start">\n \x3c!-- Left: Avatar + Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{{model.user.avatar|avatar(\'md\',\'rounded-circle\')}}}\n <div>\n <h4 class="mb-0">\n <a href="#" data-action="view-user" class="text-decoration-none text-body">{{model.user.display_name}}</a>\n </h4>\n <div class="text-muted small mt-1">\n <i class="bi bi-people me-1"></i>\n <a href="#" data-action="view-group" class="text-decoration-none">{{model.group.name}}</a>\n {{#model.group.kind}}\n <span class="badge bg-light text-muted border ms-1" style="font-size: 0.65rem;">{{model.group.kind|capitalize}}</span>\n {{/model.group.kind}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Status --\x3e\n <div class="text-end">\n <div class="d-flex align-items-center gap-2">\n {{#model.metadata.role}}\n <span class="badge bg-primary bg-opacity-10 text-primary" style="font-size: 0.72rem;">{{model.metadata.role}}</span>\n {{/model.metadata.role}}\n <span class="d-inline-flex align-items-center gap-1" style="cursor: pointer;"\n data-action="toggle-active"\n title="{{model.is_active|boolean(\'Click to deactivate\',\'Click to activate\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.created}}\n <div class="text-muted small mt-1">Joined {{model.created|date}}</div>\n {{/model.created}}\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const s=new t.View({model:this.model,template:'\n <style>\n .mv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .mv-section-label:first-child { margin-top: 0; }\n .mv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .mv-field-row:last-child { border-bottom: none; }\n .mv-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .mv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .mv-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .mv-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n </style>\n\n <div class="mv-section-label">User</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Name</div>\n <div class="mv-field-value">\n <a href="#" data-action="view-user" class="text-decoration-none">{{model.user.display_name}}</a>\n </div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Email</div>\n <div class="mv-field-value">{{model.user.email}}</div>\n </div>\n\n <div class="mv-section-label">Group</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Name</div>\n <div class="mv-field-value">\n <a href="#" data-action="view-group" class="text-decoration-none">{{model.group.name}}</a>\n </div>\n </div>\n {{#model.group.kind}}\n <div class="mv-field-row">\n <div class="mv-field-label">Kind</div>\n <div class="mv-field-value"><span class="badge bg-primary bg-opacity-10 text-primary">{{model.group.kind|capitalize}}</span></div>\n </div>\n {{/model.group.kind}}\n\n <div class="mv-section-label">Membership</div>\n <div class="mv-field-row">\n <div class="mv-field-label">Role</div>\n <div class="mv-field-value">{{model.metadata.role|default(\'—\')}}</div>\n <button type="button" class="mv-field-action" data-action="edit-membership" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Status</div>\n <div class="mv-field-value">\n {{#model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#d1e7dd; color:#0f5132; border-radius:3px;">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#fff3cd; color:#856404; border-radius:3px;">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Member ID</div>\n <div class="mv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.id}}</div>\n </div>\n <div class="mv-field-row">\n <div class="mv-field-label">Joined</div>\n <div class="mv-field-value">{{model.created|datetime|default(\'—\')}}</div>\n </div>\n '}),i=new o.FormView({fields:n.Member.PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),a=new n.TableView({collection:new n.LogList({params:{size:10,model_name:"account.Member",model_id:this.model.get("id")}}),permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0},{key:"kind",label:"Kind"},{name:"log",label:"Log"}]});this.sideNavView=new SideNavView({containerId:"member-sidenav",activeSection:"details",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:s},{key:"permissions",label:"Permissions",icon:"bi-shield-check",view:i},{type:"divider",label:"Activity"},{key:"logs",label:"Logs",icon:"bi-journal-text",view:a,permissions:"view_logs"}]}),this.addChild(this.sideNavView);const l=new e.ContextMenu({containerId:"member-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Membership",action:"edit-membership",icon:"bi-pencil"},{type:"divider"},{label:"View User",action:"view-user",icon:"bi-person"},{label:"View Group",action:"view-group",icon:"bi-people"},{type:"divider"},this.model.get("is_active")?{label:"Deactivate Member",action:"deactivate-member",icon:"bi-toggle-off"}:{label:"Activate Member",action:"activate-member",icon:"bi-toggle-on"},{label:"Remove From Group",action:"remove-member",icon:"bi-person-dash",danger:!0}]}});this.addChild(l)}async onActionEditMembership(){await l.default.modelForm({title:"Edit Membership",model:this.model,formConfig:n.MemberForms.edit})}async onActionViewUser(){const e=this.model.get("user")?.id;if(!e)return!0;const{User:t}=await Promise.resolve().then(()=>require("./chunks/Dialog-2gXM2UcO.js")).then(e=>e.User$1);return await l.default.showModelById(t,e),!0}async onActionViewGroup(){const e=this.model.get("group")?.id;if(!e)return!0;const{Group:t}=await Promise.resolve().then(()=>require("./chunks/Dialog-2gXM2UcO.js")).then(e=>e.Group$1);return await l.default.showModelById(t,e),!0}async onActionToggleActive(){return this.model.get("is_active")?this.onActionDeactivateMember():this.onActionActivateMember()}async onActionDeactivateMember(){return!(await l.default.confirm(`Deactivate <strong>${this.model.get("user.display_name")}</strong>'s membership in <strong>${this.model.get("group.name")}</strong>?`,"Deactivate Member"))||(200===(await this.model.save({is_active:!1})).status?this.getApp()?.toast?.success("Member deactivated"):this.getApp()?.toast?.error("Failed to deactivate member"),!0)}async onActionActivateMember(){return!(await l.default.confirm(`Activate <strong>${this.model.get("user.display_name")}</strong>'s membership in <strong>${this.model.get("group.name")}</strong>?`,"Activate Member"))||(200===(await this.model.save({is_active:!0})).status?this.getApp()?.toast?.success("Member activated"):this.getApp()?.toast?.error("Failed to activate member"),!0)}async onActionRemoveMember(){return!(await l.default.confirm(`Remove <strong>${this.model.get("user.display_name")}</strong> from <strong>${this.model.get("group.name")}</strong>? This cannot be undone.`,"Remove Member"))||((await this.model.destroy()).success?(this.getApp()?.toast?.success("Member removed"),this.emit("member:removed",{model:this.model})):this.getApp()?.toast?.error("Failed to remove member"),!0)}_onModelChange(){}static create(e={}){return new MemberView(e)}}n.Member.VIEW_CLASS=MemberView;class MemberTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_members",pageName:"Manage Members",router:"admin/members",Collection:n.MemberList,formEdit:n.MemberForms.edit,itemViewClass:MemberView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"user.display_name",label:"User",formatter:"default('Unknown User')"},{key:"user.email",label:"Email",formatter:"default('No Email')"},{key:"group.name",label:"Group",formatter:"default('Unknown Group')"},{key:"role",label:"Role",formatter:"badge"},{key:"status",label:"Status",formatter:"badge"},{key:"created",label:"Added",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No members found. Click "Add Member" to add users to groups.',batchBarLocation:"top",batchActions:[{label:"Remove",icon:"bi bi-person-dash",action:"batch-remove"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Change Role",icon:"bi bi-person-gear",action:"batch-role"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class ApiKey extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/group/apikey",...t})}}class ApiKeyList extends t.Collection{constructor(e={}){super({ModelClass:ApiKey,endpoint:"/api/group/apikey",size:25,...e})}}const g={create:{title:"Create API Key",fields:[{name:"name",type:"text",label:"Name",placeholder:"Mobile App v2",required:!0,columns:12,help:"A descriptive name to identify this key."},{name:"group",type:"number",label:"Group ID",required:!0,columns:12,help:"The group this key is scoped to."},{name:"permissions",type:"textarea",label:"Permissions (JSON)",placeholder:'{"view_orders": true, "create_orders": true}',columns:12,help:"JSON dict of permissions to grant. Leave empty for no permissions."}]},edit:{title:"Edit API Key",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"is_active",type:"switch",label:"Active",columns:12,help:"Deactivate to revoke access without deleting the key."},{name:"permissions",type:"textarea",label:"Permissions (JSON)",columns:12,help:"JSON dict of granted permissions."}]}};class GroupView extends t.View{constructor(e={}){super({className:"group-view",...e}),this.model=e.model||new s.Group(e.data||{}),this.template='\n <div class="group-view-container">\n \x3c!-- Header --\x3e\n <div data-container="group-header"></div>\n \x3c!-- Side Nav --\x3e\n <div data-container="group-sidenav" style="min-height: 400px;"></div>\n </div>\n '}async onInit(){this.header=new t.View({containerId:"group-header",template:'\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left Side: Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n {{#model.avatar}}\n {{{model.avatar|avatar(\'md\',\'rounded\')}}}\n {{/model.avatar}}\n {{^model.avatar}}\n <div class="d-flex align-items-center justify-content-center rounded bg-light" style="width: 56px; height: 56px;">\n <i class="bi bi-people text-secondary" style="font-size: 1.5rem;"></i>\n </div>\n {{/model.avatar}}\n <div>\n <h3 class="mb-0">{{model.name|default(\'Unnamed Group\')}}</h3>\n <div class="d-flex align-items-center gap-2 mt-1">\n <span class="badge bg-primary bg-opacity-10 text-primary" style="font-size: 0.72rem;">{{model.kind|capitalize}}</span>\n {{#model.parent}}\n <span class="text-muted small">\n <i class="bi bi-diagram-3 me-1"></i>\n <a href="#" data-action="view-parent" data-id="{{model.parent.id}}" class="text-decoration-none">{{model.parent.name}}</a>\n </span>\n {{/model.parent}}\n </div>\n {{#model.metadata.timezone}}\n <div class="text-muted small mt-1"><i class="bi bi-clock me-1"></i>{{model.metadata.timezone}}</div>\n {{/model.metadata.timezone}}\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center justify-content-end gap-3">\n <span class="d-inline-flex align-items-center gap-1" title="{{model.is_active|boolean(\'Group Active\',\'Group Inactive\')}}">\n <i class="bi {{model.is_active|boolean(\'bi-toggle-on text-success\',\'bi-toggle-off text-secondary\')}}" style="font-size: 1.1rem;"></i>\n <span class="small">{{model.is_active|boolean(\'Active\',\'Inactive\')}}</span>\n </span>\n </div>\n {{#model.last_activity}}\n <div class="text-muted small mt-1">Last active {{model.last_activity|relative}}</div>\n {{/model.last_activity}}\n {{#model.created}}\n <div class="text-muted small mt-1">Created {{model.created|date}}</div>\n {{/model.created}}\n </div>\n <div data-container="group-context-menu"></div>\n </div>\n </div>'}),this.header.setModel(this.model),this.addChild(this.header);const i=new t.View({model:this.model,template:'\n <style>\n .gv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .gv-section-label:first-child { margin-top: 0; }\n .gv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .gv-field-row:last-child { border-bottom: none; }\n .gv-field-label { width: 140px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .gv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .gv-field-action { color: #6c757d; cursor: pointer; font-size: 0.8rem; margin-left: auto; padding: 0.15rem 0.4rem; border-radius: 4px; background: none; border: none; }\n .gv-field-action:hover { background: #f0f0f0; color: #0d6efd; }\n </style>\n\n <div class="gv-section-label">Group</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Name</div>\n <div class="gv-field-value">{{model.name}}</div>\n <button type="button" class="gv-field-action" data-action="edit-group" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Kind</div>\n <div class="gv-field-value"><span class="badge bg-primary bg-opacity-10 text-primary">{{model.kind|capitalize}}</span></div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Status</div>\n <div class="gv-field-value">\n {{#model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#d1e7dd; color:#0f5132; border-radius:3px;">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span style="font-size:0.65rem; padding:0.15em 0.45em; background:#fff3cd; color:#856404; border-radius:3px;">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">ID</div>\n <div class="gv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.id}}</div>\n </div>\n\n <div class="gv-section-label">Hierarchy</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Parent</div>\n <div class="gv-field-value">\n {{#model.parent}}\n <a href="#" data-action="view-parent" data-id="{{model.parent.id}}" class="text-decoration-none">{{model.parent.name}}</a>\n <span class="text-muted small ms-1">({{model.parent.kind|capitalize}})</span>\n {{/model.parent}}\n {{^model.parent}}<span style="color:#adb5bd; font-style:italic; font-size:0.85rem;">None — top-level group</span>{{/model.parent}}\n </div>\n </div>\n\n <div class="gv-section-label">Settings</div>\n {{#model.metadata.timezone}}\n <div class="gv-field-row">\n <div class="gv-field-label">Timezone</div>\n <div class="gv-field-value">{{model.metadata.timezone}}</div>\n </div>\n {{/model.metadata.timezone}}\n {{#model.metadata.domain}}\n <div class="gv-field-row">\n <div class="gv-field-label">Domain</div>\n <div class="gv-field-value">{{model.metadata.domain}}</div>\n </div>\n {{/model.metadata.domain}}\n {{#model.metadata.portal}}\n <div class="gv-field-row">\n <div class="gv-field-label">Portal URL</div>\n <div class="gv-field-value"><a href="{{model.metadata.portal}}" target="_blank" class="text-decoration-none">{{model.metadata.portal}}</a></div>\n </div>\n {{/model.metadata.portal}}\n {{#model.metadata.eod_hour}}\n <div class="gv-field-row">\n <div class="gv-field-label">End of Day</div>\n <div class="gv-field-value">{{model.metadata.eod_hour}}:00</div>\n </div>\n {{/model.metadata.eod_hour}}\n\n <div class="gv-section-label">Dates</div>\n <div class="gv-field-row">\n <div class="gv-field-label">Created</div>\n <div class="gv-field-value">{{model.created|datetime|default(\'—\')}}</div>\n </div>\n <div class="gv-field-row">\n <div class="gv-field-label">Modified</div>\n <div class="gv-field-value">{{model.modified|datetime|default(\'—\')}}</div>\n </div>\n '}),o=new n.TableView({collection:new n.MemberList({params:{group:this.model.get("id"),size:10}}),hideActivePillNames:["group"],clickAction:"view",showAdd:!0,addButtonLabel:"Invite",onAdd:e=>this.onInviteClick(e),columns:[{key:"user.display_name",label:"User",sortable:!0},{key:"user.email",label:"Email",sortable:!0},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Joined",formatter:"date",sortable:!0}]}),l=new n.TableView({collection:new s.GroupList({params:{parent:this.model.get("id"),size:10}}),hideActivePillNames:["parent"],clickAction:"view",showAdd:!0,addButtonLabel:"Add Group",onAdd:()=>this.onActionAddChildGroup(),columns:[{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",formatter:"badge"},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge bg-success bg-opacity-10 text-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge bg-secondary bg-opacity-10 text-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"created",label:"Created",formatter:"date",sortable:!0}]}),r=new n.TableView({collection:new a.IncidentEventList({params:{size:10,model_name:"account.Group",model_id:this.model.get("id")}}),hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),d=new n.TableView({collection:new ApiKeyList({params:{group:this.model.get("id"),size:10}}),hideActivePillNames:["group"],clickAction:"view",showAdd:!0,addButtonLabel:"Create Key",addFormConfig:{...g.create,defaults:{group:this.model.get("id")}},columns:[{key:"name",label:"Name",sortable:!0},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge bg-success bg-opacity-10 text-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge bg-secondary bg-opacity-10 text-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}]}),c=new AdminMetadataSection({model:this.model}),m=new n.TableView({collection:new n.LogList({params:{size:10,model_name:"account.Group",model_id:this.model.get("id")}}),permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});this.sideNavView=new SideNavView({containerId:"group-sidenav",activeSection:"details",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:i},{key:"members",label:"Members",icon:"bi-people",view:o},{key:"children",label:"Sub-Groups",icon:"bi-diagram-3",view:l},{key:"api_keys",label:"API Keys",icon:"bi-key",view:d},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:r},{key:"logs",label:"Logs",icon:"bi-journal-text",view:m,permissions:"view_logs"},{type:"divider",label:"Settings"},{key:"metadata",label:"Metadata",icon:"bi-braces",view:c}]}),this.addChild(this.sideNavView);const u=new e.ContextMenu({containerId:"group-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Group",action:"edit-group",icon:"bi-pencil"},{type:"divider"},{label:"Invite Member",action:"invite-member",icon:"bi-person-plus"},{label:"Add Sub-Group",action:"add-child-group",icon:"bi-diagram-3"},{type:"divider"},this.model.get("is_active")?{label:"Deactivate Group",action:"deactivate-group",icon:"bi-toggle-off"}:{label:"Activate Group",action:"activate-group",icon:"bi-toggle-on"}]}});this.addChild(u)}async onActionEditGroup(){await s.Dialog.showModelForm({title:`Edit Group — ${this.model.get("name")}`,model:this.model,size:"lg",formConfig:s.GroupForms.detailed})&&await this.render()}async onActionInviteMember(){return this.onInviteClick(new Event("click"))}async onInviteClick(e){e?.preventDefault&&(e.preventDefault(),e.stopPropagation());const t=await s.Dialog.showForm({title:`Invite User to ${this.model.get("name")}`,size:"sm",fields:[{type:"email",name:"email",label:"Email",required:!0,cols:12}]});if(!t?.email)return!0;const i=this.getApp(),a=await i.rest.POST("/api/group/member/invite",{group:this.model.id,email:t.email});return a.success?(i.toast.success("User invited successfully"),"members"===this.sideNavView?.getActiveSection()&&await this.sideNavView.showSection("members")):i.toast.error(a.message||"Failed to invite user"),!0}async onActionAddChildGroup(){const e=await s.Dialog.showForm({title:`Add Sub-Group to ${this.model.get("name")}`,size:"sm",fields:s.GroupForms.create.fields.filter(e=>"parent"!==e.name)});if(!e)return!0;e.parent=this.model.id;const t=new s.Group(e),i=await t.save();return 200===i.status||201===i.status?(this.getApp()?.toast?.success("Sub-group created"),"children"===this.sideNavView?.getActiveSection()&&await this.sideNavView.showSection("children")):this.getApp()?.toast?.error(i.message||"Failed to create sub-group"),!0}async onActionDeactivateGroup(){return!(await s.Dialog.confirm(`Are you sure you want to deactivate <strong>${this.model.get("name")}</strong>?`,"Deactivate Group"))||(200===(await this.model.save({is_active:!1})).status?(this.getApp()?.toast?.success("Group deactivated"),await this.render()):this.getApp()?.toast?.error("Failed to deactivate group"),!0)}async onActionActivateGroup(){return!(await s.Dialog.confirm(`Are you sure you want to activate <strong>${this.model.get("name")}</strong>?`,"Activate Group"))||(200===(await this.model.save({is_active:!0})).status?(this.getApp()?.toast?.success("Group activated"),await this.render()):this.getApp()?.toast?.error("Failed to activate group"),!0)}async onActionViewParent(e,t){const i=t?.dataset?.id;if(!i)return!0;const a=new s.Group({id:i});return await a.fetch(),a.id&&s.Dialog.showDialog({title:!1,size:"lg",body:new GroupView({model:a}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}),!0}async showSection(e){this.sideNavView&&await this.sideNavView.showSection(e)}getActiveSection(){return this.sideNavView?this.sideNavView.getActiveSection():null}_onModelChange(){}static create(e={}){return new GroupView(e)}}s.Group.VIEW_CLASS=GroupView;class GroupTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_groups",pageName:"Manage Groups",router:"admin/groups",Collection:s.GroupList,formCreate:s.GroupForms.create,formEdit:s.GroupForms.edit,itemViewClass:GroupView,viewDialogOptions:{header:!1},defaultQuery:{sort:"-id",is_active:1},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Display Name"},{key:"kind|badge",label:"Kind",filter:{type:"select",options:s.Group.GroupKindOptions}},{key:"is_active|yesnoicon",label:"Enabled",visibility:"lg"},{key:"parent.name",label:"Parent",formatter:"default('-')",visibility:"md",class:"text-muted fs-8"},{key:"created",label:"Created",className:"text-muted fs-8",formatter:"epoch|datetime",visibility:"lg"},{key:"last_activity",label:"Activity",className:"text-muted fs-8",formatter:"relative",visibility:"lg"}],filters:[{key:"is_active",label:"Active",type:"select",options:[{label:"Active",value:!0},{label:"Inactive",value:!1}]}],contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Group"},{icon:"bi-bullseye",action:"make-active",label:"Make Active Group"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No groups found. Click "Add Group" to create your first one.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"},{label:"Move",icon:"bi bi-arrow-right",action:"batch-move"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}onActionMakeActive(e,t){const s=this.collection.get(t.dataset.id);this.getApp().setActiveGroup(s)}}class DeviceLocationRow extends n.TableRow{get locationText(){const e=this.model?.get("geolocation")||{};return[e.city,e.region].filter(Boolean).join(", ")||e.country_name||"—"}get countryName(){return this.model?.get("geolocation")?.country_name||""}get ispName(){const e=this.model?.get("geolocation")||{};return e.isp||e.asn_org||""}get threatFlags(){const e=this.model?.get("geolocation")||{},t=[];return e.is_vpn&&t.push('<span class="badge bg-warning text-dark" style="font-size:0.6rem;">VPN</span>'),e.is_tor&&t.push('<span class="badge bg-danger" style="font-size:0.6rem;">Tor</span>'),e.is_proxy&&t.push('<span class="badge bg-warning text-dark" style="font-size:0.6rem;">Proxy</span>'),t.join(" ")}get hasThreatFlags(){const e=this.model?.get("geolocation")||{};return!!(e.is_vpn||e.is_tor||e.is_proxy)}}class DeviceView extends t.View{constructor(e={}){super({className:"device-view",...e}),this.model=e.model||new s.UserDevice(e.data||{}),this.deviceInfo=this.model.get("device_info")||{},this.deviceIcon=this._getIcon(this.deviceInfo),this.browserFull=this._getBrowser(),this.osFull=this._getOS(),this.deviceFull=this._getDevice(),this.isMobile=this._isMobile(),this.template='\n <style>\n .dv-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }\n .dv-identity { display: flex; align-items: center; gap: 1rem; }\n .dv-icon-wrap { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; flex-shrink: 0; }\n .dv-title { font-size: 1.15rem; font-weight: 600; margin: 0; line-height: 1.3; }\n .dv-subtitle { font-size: 0.8rem; color: #6c757d; margin-top: 0.15rem; }\n .dv-status { text-align: right; display: flex; align-items: flex-start; gap: 0.75rem; }\n .dv-last-seen-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.04em; }\n .dv-last-seen-value { font-size: 0.88rem; font-weight: 500; }\n .dv-last-seen-ip { font-size: 0.75rem; color: #6c757d; margin-top: 0.1rem; }\n </style>\n\n <div class="dv-header">\n <div class="dv-identity">\n <div class="dv-icon-wrap bg-primary bg-opacity-10 text-primary">\n <i class="bi {{deviceIcon}}"></i>\n </div>\n <div>\n <h4 class="dv-title">{{browserFull}} <span class="fw-normal text-muted">on</span> {{osFull}}</h4>\n <div class="dv-subtitle">\n {{deviceFull}}\n {{#model.user.display_name}}\n <span class="text-muted mx-1">·</span>\n <a href="#" data-action="view-user" class="text-decoration-none">{{model.user.display_name}}</a>\n {{/model.user.display_name}}\n </div>\n </div>\n </div>\n <div class="dv-status">\n <div>\n <div class="dv-last-seen-label">Last Seen</div>\n <div class="dv-last-seen-value">{{model.last_seen|relative}}</div>\n {{#model.last_ip}}<div class="dv-last-seen-ip">{{model.last_ip}}</div>{{/model.last_ip}}\n </div>\n <div data-container="device-context-menu"></div>\n </div>\n </div>\n\n <div data-container="device-sidenav" style="min-height: 300px;"></div>\n '}_getBrowser(){const e=this.deviceInfo?.user_agent||{},t=[e.family,e.major].filter(Boolean);return t.length?t.join(" "):"Unknown Browser"}_getOS(){const e=this.deviceInfo?.os||{},t=[e.major,e.minor].filter(Boolean).join(".");return e.family?`${e.family} ${t}`.trim():"Unknown OS"}_getDevice(){const e=this.deviceInfo?.device||{},t=[e.brand,e.family].filter(Boolean),s=t.length?t.join(" "):"Unknown Device";return e.model?`${s} (${e.model})`:s}_isMobile(){const e=this.deviceInfo?.device||{},t=this.deviceInfo?.os||{};return["iPhone","Android","iPad"].some(s=>(e.family||"").includes(s)||(t.family||"").includes(s))}_getIcon(e){const t=e?.os?.family?.toLowerCase()||"",s=e?.user_agent?.family?.toLowerCase()||"",i=e?.device?.family?.toLowerCase()||"";return s.includes("chrome")?"bi-browser-chrome":s.includes("firefox")?"bi-browser-firefox":s.includes("safari")?"bi-browser-safari":s.includes("edge")?"bi-browser-edge":t.includes("mac")||t.includes("ios")?"bi-apple":t.includes("windows")?"bi-windows":t.includes("android")?"bi-android2":t.includes("linux")?"bi-ubuntu":i.includes("iphone")?"bi-phone":i.includes("ipad")?"bi-tablet":"bi-laptop"}async onInit(){const i=new t.View({model:this.model,template:'\n <style>\n .dv-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .dv-section-label:first-child { margin-top: 0; }\n .dv-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .dv-field-row:last-child { border-bottom: none; }\n .dv-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .dv-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .dv-ua-string { font-family: ui-monospace, monospace; font-size: 0.73rem; color: #6c757d; word-break: break-all; line-height: 1.5; padding: 0.5rem 0.75rem; background: #f8f9fa; border-radius: 6px; margin-top: 0.25rem; }\n </style>\n\n <div class="dv-section-label">Browser</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Name</div>\n <div class="dv-field-value">{{model.device_info.user_agent.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Version</div>\n <div class="dv-field-value">{{model.device_info.user_agent.major|default(\'—\')}}{{#model.device_info.user_agent.minor}}.{{model.device_info.user_agent.minor}}{{/model.device_info.user_agent.minor}}{{#model.device_info.user_agent.patch}}.{{model.device_info.user_agent.patch}}{{/model.device_info.user_agent.patch}}</div>\n </div>\n\n <div class="dv-section-label">Operating System</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Name</div>\n <div class="dv-field-value">{{model.device_info.os.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Version</div>\n <div class="dv-field-value">{{model.device_info.os.major|default(\'—\')}}{{#model.device_info.os.minor}}.{{model.device_info.os.minor}}{{/model.device_info.os.minor}}{{#model.device_info.os.patch}}.{{model.device_info.os.patch}}{{/model.device_info.os.patch}}</div>\n </div>\n\n <div class="dv-section-label">Hardware</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Brand</div>\n <div class="dv-field-value">{{model.device_info.device.brand|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Family</div>\n <div class="dv-field-value">{{model.device_info.device.family|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Model</div>\n <div class="dv-field-value">{{model.device_info.device.model|default(\'—\')}}</div>\n </div>\n\n <div class="dv-section-label">Identification</div>\n <div class="dv-field-row">\n <div class="dv-field-label">Device ID</div>\n <div class="dv-field-value" style="font-family: ui-monospace, monospace; font-size: 0.78rem;">{{model.duid|truncate_middle(32)}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Last IP</div>\n <div class="dv-field-value">{{model.last_ip|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">First Seen</div>\n <div class="dv-field-value">{{model.first_seen|epoch|datetime|default(\'—\')}}</div>\n </div>\n <div class="dv-field-row">\n <div class="dv-field-label">Last Seen</div>\n <div class="dv-field-value">{{model.last_seen|epoch|datetime|default(\'—\')}}</div>\n </div>\n\n {{#model.device_info.string}}\n <div class="dv-section-label">User Agent String</div>\n <div class="dv-ua-string">{{model.device_info.string}}</div>\n {{/model.device_info.string}}\n '}),a=new n.TableView({collection:new s.UserDeviceLocationList({params:{user_device:this.model.get("id"),size:10}}),hideActivePillNames:["user_device"],clickAction:"view",itemClass:DeviceLocationRow,selectable:!1,columns:[{key:"ip_address",label:"Location",template:'\n <div style="font-size:0.85rem; font-weight:500;">\n <i class="bi bi-geo-alt text-muted me-1" style="font-size:0.95rem; vertical-align:middle;"></i>{{locationText}}\n {{#countryName}} <span class="text-muted fw-normal">· {{countryName}}</span>{{/countryName}}\n </div>\n <div style="font-size:0.73rem; color:#6c757d; margin-top:0.15rem;">\n {{model.ip_address}}\n {{#ispName}} <span class="text-muted mx-1">·</span> {{ispName}}{{/ispName}}\n {{#hasThreatFlags|bool}} <span class="ms-1">{{{threatFlags}}}</span>{{/hasThreatFlags|bool}}\n </div>'},{key:"first_seen",label:"First Seen",formatter:"epoch|relative",width:"110px"},{key:"last_seen",label:"Last Seen",formatter:"epoch|relative",width:"110px"}]});this.sideNavView=new SideNavView({containerId:"device-sidenav",activeSection:"details",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:[{key:"details",label:"Details",icon:"bi-info-circle",view:i},{key:"locations",label:"Locations",icon:"bi-geo-alt",view:a}]}),this.addChild(this.sideNavView);const o=new e.ContextMenu({containerId:"device-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View User",action:"view-user",icon:"bi-person"},{label:"Block Device",action:"block-device",icon:"bi-shield-slash",disabled:!0},{type:"divider"},{label:"Delete Record",action:"delete-device",icon:"bi-trash",danger:!0}]}});this.addChild(o)}async onActionViewUser(){this.emit("view-user",{userId:this.model.get("user")?.id})}async onActionDeleteDevice(){return!(await s.Dialog.confirm("Are you sure you want to delete this device record?","Delete Device"))||((await this.model.destroy()).success&&this.emit("device:deleted",{model:this.model}),!0)}static async show(e){const t=await s.UserDevice.getByDuid(e);return t?s.Dialog.showDialog({title:!1,size:"lg",body:new DeviceView({model:t}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(s.Dialog.alert({message:`Could not find device with DUID: ${e}`,type:"warning"}),null)}}s.UserDevice.VIEW_CLASS=DeviceView;class UserDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_user_devices",pageName:"User Devices",router:"admin/user/devices",Collection:s.UserDeviceList,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"duid",label:"Device ID",sortable:!0,formatter:"truncate_middle(16)"},{key:"user.display_name",label:"User",sortable:!0,formatter:"default('—')"},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')"},{key:"device_info.os.family",label:"OS",formatter:"default('—')"},{key:"last_ip",label:"Last IP",sortable:!0},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime"},{key:"last_seen",label:"Last Seen",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No user devices found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class UserDeviceLocationTablePage extends e.Page{constructor(e={}){super({...e,pageName:"Device Locations",className:"device-locations-page"}),this.name=e.name||"admin_user_device_locations",this.route=e.router||"admin/user/device-locations"}async getTemplate(){return'\n <div class="container-fluid">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h4 class="mb-1">Device Locations</h4>\n <p class="text-muted mb-0 small">Login locations across all users</p>\n </div>\n </div>\n <div data-container="tabs"></div>\n </div>\n '}async onInit(){const e=new LoginLocationMapView({height:400,mapStyle:"dark"}),t=new n.TableView({collection:new LoginEventList({params:{size:20}}),searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showExport:!0,columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"user.display_name",label:"User",sortable:!0},{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px"}]});t.onTabActivated=async()=>{await(t.collection?.fetch())},this.tabView=new a.TabView({containerId:"tabs",tabs:{Map:e,Logins:t},activeTab:"Map"}),this.addChild(this.tabView)}}class GeoIPView extends t.View{constructor(e={}){super({className:"geoip-view",...e}),this.model=e.model||new a.GeoLocatedIP(e.data||{}),this.hasCoordinates=this.model.get("latitude")&&this.model.get("longitude"),this.template='\n <div class="geoip-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-globe-americas"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.ip_address}}</h3>\n <div class="text-muted small">\n {{model.city|default(\'Unknown Location\')}}, {{model.country_name|default(\'Unknown Location\')}}\n </div>\n <div class="text-muted small mt-1">\n ISP: {{model.isp|capitalize}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Risk Summary + Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n \x3c!-- Risk summary --\x3e\n <div class="text-end">\n <div class="d-flex align-items-baseline justify-content-end gap-2">\n <span class="text-muted">Risk:</span>\n <span class="fw-bold fs-4\n {{#model.is_threat}} text-danger {{/model.is_threat}}\n {{#model.is_suspicious}} text-warning {{/model.is_suspicious}}\n {{^model.is_threat}}{{^model.is_suspicious}} text-success {{/model.is_suspicious}}{{/model.is_threat}}\n ">{{#model.threat_level}}{{model.threat_level|capitalize}}{{/model.threat_level}}{{^model.threat_level}}Unknown{{/model.threat_level}}</span>\n </div>\n <div class="mt-1 small d-flex align-items-center justify-content-end gap-2">\n <span class="text-muted">Score:</span>\n <span class="fw-semibold">{{model.risk_score|default(\'—\')}}</span>\n </div>\n <div class="mt-1 d-flex align-items-center justify-content-end gap-2">\n <i class="bi bi-shield-lock {{#model.is_tor}}fs-4 text-success{{/model.is_tor}}{{^model.is_tor}}text-muted{{/model.is_tor}}" data-bs-toggle="tooltip" title="TOR exit"></i>\n <i class="bi bi-shield {{#model.is_vpn}}fs-4 text-success{{/model.is_vpn}}{{^model.is_vpn}}text-muted{{/model.is_vpn}}" data-bs-toggle="tooltip" title="VPN detected"></i>\n <i class="bi bi-cloud {{#model.is_cloud}}fs-4 text-success{{/model.is_cloud}}{{^model.is_cloud}}text-muted{{/model.is_cloud}}" data-bs-toggle="tooltip" title="Cloud provider"></i>\n <i class="bi bi-hdd-stack {{#model.is_datacenter}}fs-4 text-success{{/model.is_datacenter}}{{^model.is_datacenter}}text-muted{{/model.is_datacenter}}" data-bs-toggle="tooltip" title="Datacenter"></i>\n <i class="bi bi-phone {{#model.is_mobile}}fs-4 text-success{{/model.is_mobile}}{{^model.is_mobile}}text-muted{{/model.is_mobile}}" data-bs-toggle="tooltip" title="Mobile connection"></i>\n <i class="bi bi-diagram-3 {{#model.is_proxy}}fs-4 text-success{{/model.is_proxy}}{{^model.is_proxy}}text-muted{{/model.is_proxy}}" data-bs-toggle="tooltip" title="Proxy"></i>\n </div>\n </div>\n \x3c!-- Actions: context menu aligned to top (not vertically centered) --\x3e\n <div class="d-flex align-items-start">\n <div data-container="geoip-context-menu"></div>\n </div>\n </div>\n </div>\n\n \x3c!-- Content --\x3e\n <div data-container="geoip-sidenav"></div>\n </div>\n '}async onInit(){this.detailsView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"ip_address",label:"IP Address",cols:4},{name:"subnet",label:"Subnet",cols:4},{name:"country_name",label:"Country",cols:4},{name:"country_code",label:"Country Code",cols:4},{name:"region",label:"Region",cols:4},{name:"city",label:"City",cols:4},{name:"postal_code",label:"Postal Code",cols:4},{name:"timezone",label:"Timezone",cols:4},{name:"latitude",label:"Latitude",cols:4},{name:"longitude",label:"Longitude",cols:4}]}),this.networkView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"is_tor",label:"TOR Exit Node",formatter:"yesnoicon",cols:4},{name:"is_vpn",label:"VPN",formatter:"yesnoicon",cols:4},{name:"is_proxy",label:"Proxy",formatter:"yesnoicon",cols:4},{name:"is_cloud",label:"Cloud Provider",formatter:"yesnoicon",cols:4},{name:"is_datacenter",label:"Datacenter",formatter:"yesnoicon",cols:4},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:4},{name:"mobile_carrier",label:"Mobile Carrier",cols:8},{name:"asn",label:"ASN",cols:4},{name:"asn_org",label:"ASN Organization",cols:8},{name:"isp",label:"ISP",cols:12},{name:"connection_type",label:"Connection Type",cols:6}]}),this.riskView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"threat_level",label:"Threat Level",formatter:"capitalize",cols:6},{name:"risk_score",label:"Risk Score",cols:6},{name:"is_threat",label:"Threat",formatter:"yesnoicon",cols:6},{name:"is_suspicious",label:"Suspicious",formatter:"yesnoicon",cols:6},{name:"is_known_attacker",label:"Known Attacker",formatter:"yesnoicon",cols:6},{name:"is_known_abuser",label:"Known Abuser",formatter:"yesnoicon",cols:6}]}),this.blockView=new r.default({model:this.model,className:"p-3",showEmptyValues:!1,emptyValueText:"—",columns:2,fields:[{name:"is_blocked",label:"Blocked",formatter:"yesnoicon",cols:6},{name:"block_count",label:"Block Count",cols:6},{name:"blocked_reason",label:"Block Reason",cols:12},{name:"blocked_at",label:"Blocked At",formatter:"datetime",cols:6},{name:"blocked_until",label:"Blocked Until",formatter:"datetime",cols:6},{name:"is_whitelisted",label:"Whitelisted",formatter:"yesnoicon",cols:6},{name:"whitelisted_reason",label:"Whitelist Reason",cols:6}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"provider",label:"Data Provider",formatter:"capitalize",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6},{name:"last_seen",label:"Last Seen",formatter:"datetime",cols:6},{name:"expires_at",label:"Expires",formatter:"datetime",cols:6}]});const t=new a.IncidentEventList({params:{size:5,source_ip:this.model.get("ip_address")}});this.eventsView=new n.TableView({collection:t,hideActivePillNames:["source_ip"],columns:[{key:"id",label:"ID",sortable:!0,width:"40px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]});const s=new n.LogList({params:{size:5,ip:this.model.get("ip_address")}});this.trafficView=new n.TableView({collection:s,permissions:"view_logs",hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",filter:{name:"created",type:"daterange",startName:"dr_start",endName:"dr_end",fieldName:"dr_field",label:"Date Range",format:"YYYY-MM-DD",displayFormat:"MMM DD, YYYY",separator:" to "}},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});const i=new n.LogList({params:{size:5,model_name:"account.GeoLocatedIP",model_id:this.model.get("id")}});this.logsView=new n.TableView({collection:i,permissions:"view_logs",hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime"},{key:"level",label:"Level",sortable:!0,filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{name:"log",label:"Log"}]});const o=[];if(this.hasCoordinates){const e=this.model.get("latitude"),t=this.model.get("longitude"),s=[this.model.get("city")||"Unknown",this.model.get("region")||"",this.model.get("country_name")||""].filter(Boolean).join(", ");this.mapView=new d.MapView({markers:[{lat:e,lng:t,popup:`<strong>${this.model.get("ip_address")}</strong><br>${s}`}],tileLayer:"light",zoom:4,height:450}),o.push({key:"map",label:"Map",icon:"bi-map",view:this.mapView})}o.push({key:"location",label:"Location",icon:"bi-geo-alt",view:this.detailsView},{key:"network",label:"Network",icon:"bi-diagram-3",view:this.networkView},{key:"risk",label:"Risk & Reputation",icon:"bi-shield-exclamation",view:this.riskView},{key:"block",label:"Block & Whitelist",icon:"bi-slash-circle",view:this.blockView},{type:"divider",label:"Activity"},{key:"events",label:"Events",icon:"bi-calendar-event",view:this.eventsView},{key:"traffic",label:"Traffic",icon:"bi-arrow-left-right",view:this.trafficView,permissions:"view_logs"},{key:"logs",label:"Logs",icon:"bi-journal-text",view:this.logsView,permissions:"view_logs"},{type:"divider",label:"Record"},{key:"metadata",label:"Metadata",icon:"bi-braces",view:this.metadataView}),this.sideNavView=new SideNavView({containerId:"geoip-sidenav",activeSection:this.hasCoordinates?"map":"location",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500,sections:o}),this.addChild(this.sideNavView);const l=[{label:"Edit Location",action:"edit-location",icon:"bi-geo-alt"},{label:"Edit Security",action:"edit-security",icon:"bi-shield-lock"},{label:"Edit Network",action:"edit-network",icon:"bi-diagram-3"},{type:"divider"},{label:"Refresh Geolocation",action:"refresh-geoip",icon:"bi-arrow-clockwise"}];this.hasCoordinates&&l.push({label:"View on Map",action:"view-on-map",icon:"bi-map"}),l.push({type:"divider"},{label:"Block IP",action:"block-ip",icon:"bi-slash-circle",class:"text-danger"},{label:"Unblock IP",action:"unblock-ip",icon:"bi-unlock",class:"text-success"},{label:"Whitelist IP",action:"whitelist-ip",icon:"bi-check-circle",class:"text-primary"},{label:"Remove Whitelist",action:"unwhitelist-ip",icon:"bi-x-circle"},{label:"Refresh Threat Data",action:"threat-analysis",icon:"bi-shield-exclamation"}),l.push({type:"divider"},{label:"Delete Record",action:"delete-geoip",icon:"bi-trash",danger:!0});const c=new e.ContextMenu({containerId:"geoip-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:l}});this.addChild(c)}async onAfterRender(){await super.onAfterRender(),window.bootstrap&&window.bootstrap.Tooltip&&this.element&&this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(e=>{const t=window.bootstrap.Tooltip.getInstance(e);t&&"function"==typeof t.dispose&&t.dispose(),new window.bootstrap.Tooltip(e)})}async onActionEditLocation(){await s.Dialog.showModelForm({title:`Edit Location - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_LOCATION_FORM})&&(await this.render(),this.getApp()?.toast?.success("Location updated successfully"))}async onActionEditSecurity(){await s.Dialog.showModelForm({title:`Edit Security - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_SECURITY_FORM})&&(await this.render(),this.getApp()?.toast?.success("Security settings updated successfully"))}async onActionEditNetwork(){await s.Dialog.showModelForm({title:`Edit Network - ${this.model.get("ip_address")}`,model:this.model,formConfig:a.GeoLocatedIP.EDIT_NETWORK_FORM})&&(await this.render(),this.getApp()?.toast?.success("Network information updated successfully"))}async onActionRefreshGeoip(){await this.model.save({refresh:!0}),this.getApp()?.toast?.info("Refresh request sent for "+this.model.get("ip_address"))}async onActionBlockIp(){const e=await s.Dialog.showForm({title:"Block IP",icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Suspicious activity"},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!e)return!0;const t=await this.model.save({block:{reason:e.reason,ttl:parseInt(e.ttl)}});return t.success||200===t.status?(this.getApp()?.toast?.success("IP blocked successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await s.Dialog.showForm({title:"Unblock IP",icon:"bi-unlock",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",placeholder:"e.g., False positive"}]});if(!e)return!0;const t=await this.model.save({unblock:e.reason||"Unblocked from admin"});return t.success||200===t.status?(this.getApp()?.toast?.success("IP unblocked successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await s.Dialog.showForm({title:"Whitelist IP",icon:"bi-check-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Known office IP"}]});if(!e)return!0;const t=await this.model.save({whitelist:e.reason});return t.success||200===t.status?(this.getApp()?.toast?.success("IP whitelisted successfully"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}async onActionUnwhitelistIp(){if(!(await s.Dialog.confirm("Remove this IP from the whitelist?","Remove Whitelist")))return!0;const e=await this.model.save({unwhitelist:1});return e.success||200===e.status?(this.getApp()?.toast?.success("IP removed from whitelist"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to remove from whitelist"),!0}async onActionThreatAnalysis(e,t){try{t&&(t.disabled=!0);const e=await this.model.save({threat_analysis:1});e.success||200===e.status?(this.getApp()?.toast?.success("Threat data refreshed"),await this.model.fetch()):this.getApp()?.toast?.error("Failed to refresh threat data")}finally{t&&(t.disabled=!1)}return!0}async onActionViewOnMap(){if(this.hasCoordinates){const e=`https://www.google.com/maps/search/?api=1&query=${this.model.get("latitude")},${this.model.get("longitude")}`;window.open(e,"_blank")}}async onActionDeleteGeoip(){await s.Dialog.confirm(`Are you sure you want to delete the GeoIP record for "${this.model.get("ip_address")}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("geoip:deleted",{model:this.model})}static async show(e){const t=await a.GeoLocatedIP.lookup(e);if(t){const e=new GeoIPView({model:t}),i=new s.Dialog({header:!1,size:"lg",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});return await i.render(!0,document.body),i.show(),i}return s.Dialog.alert({message:`Could not find geolocation data for IP: ${e}`,type:"warning"}),null}}a.GeoLocatedIP.VIEW_CLASS=GeoIPView;class GeoLocatedIPTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_system_geoip",pageName:"GeoIP Cache",router:"admin/system/geoip",Collection:a.GeoLocatedIPList,itemView:GeoIPView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",sortable:!0,formatter:"default('—')"},{key:"region",label:"Region",sortable:!0,formatter:"default('—')"},{key:"country_name",label:"Country",sortable:!0,formatter:"default('—')"},{key:"isp",label:"ISP",sortable:!0,formatter:"default('—')"},{key:"threat_level",label:"Threat",formatter:"default('—')"}],filters:[{key:"country_code",label:"Country",type:"text"},{key:"threat_level",label:"Threat Level",type:"select",options:[{label:"None",value:"none"},{label:"Low",value:"low"},{label:"Medium",value:"medium"},{label:"High",value:"high"},{label:"Critical",value:"critical"}]},{key:"isp__icontains",label:"ISP",type:"text"},{key:"is_blocked",label:"Blocked",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]},{key:"is_vpn",label:"VPN",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]},{key:"is_tor",label:"TOR",type:"select",options:[{label:"Yes",value:"true"},{label:"No",value:"false"}]}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No GeoIP records found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},tableViewOptions:{addButtonLabel:"Lookup IP",onAdd:e=>{e.preventDefault(),this.onLookup()}}})}async onLookup(){const e=await this.getApp().showForm({title:"Lookup IP",fields:[{name:"ip",type:"text",required:!0}]});if(e&&e.ip){const t=await a.GeoLocatedIP.lookup(e.ip);t&&this.tableView._onRowView({model:t})}}}class ApiKeyView extends t.View{constructor(e={}){super({className:"api-key-view",...e}),this.model=e.model||new ApiKey(e.data||{}),this.template='\n <div class="api-key-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left: Icon & Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-key"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.name|default(\'Unnamed Key\')}}</h3>\n <div class="text-muted small">\n ID: {{model.id}}\n <span class="mx-2">|</span>\n Group: {{model.group.name|default(model.group)}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.is_active|boolean(\'bg-success\',\'bg-secondary\')}}">\n {{model.is_active|boolean(\'Active\',\'Inactive\')}}\n </span>\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Meta & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|datetime}}</div>\n </div>\n <div data-container="apikey-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Token Preview</h6>\n <p class="mb-1 font-monospace small text-muted">\n The raw token is only shown once at creation time.\n </p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Permissions</h6>\n {{#model.permissions}}\n <pre class="mb-0 small">{{model.permissions|json}}</pre>\n {{/model.permissions}}\n {{^model.permissions}}\n <span class="text-muted small">No permissions granted</span>\n {{/model.permissions}}\n </div>\n {{#model.limits}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Rate Limit Overrides</h6>\n <pre class="mb-0 small">{{model.limits|json}}</pre>\n </div>\n {{/model.limits}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Usage</h6>\n <p class="mb-0 small text-muted">\n Include in requests as:\n <code>Authorization: apikey <token></code>\n </p>\n </div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("is_active"),s=new e.ContextMenu({containerId:"apikey-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-key",icon:"bi-pencil"},t?{label:"Deactivate",action:"deactivate-key",icon:"bi-x-circle"}:{label:"Activate",action:"activate-key",icon:"bi-check-circle"},{type:"divider"},{label:"Delete Key",action:"delete-key",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionEditKey(){const e=this.getApp();await e.showModelForm({title:`Edit API Key — ${this.model.get("name")}`,model:this.model,formConfig:g.edit})&&this.render()}async onActionDeactivateKey(){const e=this.getApp();if(!(await e.confirm({title:"Deactivate API Key",message:`Deactivate "${this.model.get("name")}"? Requests using this key will be rejected.`,confirmLabel:"Deactivate",confirmClass:"btn-warning"})))return;e.showLoading();const t=await this.model.save({is_active:!1});e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key deactivated"),this.render()):e.toast.error("Failed to deactivate key")}async onActionActivateKey(){const e=this.getApp();e.showLoading();const t=await this.model.save({is_active:!0});e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key activated"),this.render()):e.toast.error("Failed to activate key")}async onActionDeleteKey(){const e=this.getApp();if(!(await e.confirm({title:"Delete API Key",message:`Permanently delete "${this.model.get("name")}"? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("API key deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete key")}}ApiKey.VIEW_CLASS=ApiKeyView,ApiKey.ADD_FORM=g.create,ApiKey.EDIT_FORM=g.edit;class ApiKeyTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_api_keys",pageName:"API Keys",router:"admin/api-keys",Collection:ApiKeyList,itemViewClass:ApiKeyView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"group.name",label:"Group",sortable:!0,formatter:"default('—')"},{key:"is_active",label:"Status",formatter:"boolean('Active|bg-success','Inactive|bg-secondary')|badge",width:"100px"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New API Key",emptyMessage:"No API keys found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionAdd(){const e=this.getApp(),t=new ApiKey,s=await e.showForm({model:t,...g.create});if(!s)return;const i=await t.save(s);if(!i?.data?.status)return void e.showError(i?.data?.error||"Failed to create API key");const a=i.data?.data?.token;await e.showAlert({title:"API Key Created — Save Your Token",message:a?`Copy this token now. It will not be shown again.\n\n${a}`:"API key created successfully.",type:a?"warning":"success",size:"lg"}),this.collection.add(t),this.tableView?.refresh()}}class CloudWatchChart extends i.MetricsChart{constructor(e={}){super({endpoint:"/api/aws/cloudwatch/fetch",account:e.resourceType||e.account||"ec2",category:e.category||null,slugs:e.slugs||(e.slug?[e.slug]:null),granularity:e.granularity||"hours",title:e.title||"CloudWatch",defaultDateRange:e.defaultDateRange||"24h",showDateRange:!1,...e}),this.stat=e.stat||"avg",this.resourceType=e.resourceType||e.account||"ec2"}buildApiParams(){const e=super.buildApiParams();return e.stat=this.stat,e}setStat(e){return this.stat=e,this.fetchData()}}const v=[{account:"ec2",category:"cpu",title:"EC2 CPU",unit:"%"},{account:"ec2",category:"net_out",title:"EC2 Network Out",unit:"bytes"},{account:"ec2",category:"memory",title:"EC2 Memory",unit:"%"},{account:"ec2",category:"disk",title:"EC2 Disk",unit:"%"},{account:"rds",category:"cpu",title:"RDS CPU",unit:"%"},{account:"rds",category:"conns",title:"RDS Connections",unit:""},{account:"rds",category:"read_latency",title:"RDS Read Latency",unit:"s"},{account:"rds",category:"write_latency",title:"RDS Write Latency",unit:"s"},{account:"redis",category:"cpu",title:"Redis CPU",unit:"%"},{account:"redis",category:"conns",title:"Redis Connections",unit:""},{account:"redis",category:"cache_misses",title:"Redis Cache Misses",unit:""},{account:"redis",category:"cache_hits",title:"Redis Cache Hits",unit:""}];function f(e){return"%"===e?{label:"%",beginAtZero:!0,max:100}:"bytes"===e?{label:"Bytes",beginAtZero:!0}:"s"===e?{label:"Seconds",beginAtZero:!0}:{beginAtZero:!0}}class CloudWatchDashboardPage extends e.Page{constructor(e={}){super({...e,title:"CloudWatch Monitoring",className:"cloudwatch-dashboard-page"})}async getTemplate(){return`\n <style>\n .cw-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n @media (max-width: 992px) { .cw-grid { grid-template-columns: 1fr; } }\n </style>\n <div class="container-fluid">\n <p class="text-muted mb-3">AWS CloudWatch resource monitoring</p>\n <div class="cw-grid" id="cw-grid">\n ${v.map((e,t)=>`<div id="cw-chart-${t}"></div>`).join("")}\n </div>\n </div>\n `}async onInit(){this.getApp()?.showLoading("Loading CloudWatch...");try{for(let e=0;e<v.length;e++){const t=v[e],s=new CloudWatchChart({containerId:`cw-chart-${e}`,account:t.account,category:t.category,title:t.title,height:160,yAxis:f(t.unit),responsive:!0,showGranularity:!0,showDateRange:!0,defaultDateRange:"24h",granularity:"hours"});this.addChild(s)}}finally{this.getApp()?.hideLoading()}}}class SecurityStatsBar extends t.View{constructor(e={}){super({...e,className:"security-stats-bar"}),this.model=new a.IncidentStats,this.counts={ipBlocks:0,blockedDevices:0,blocksToday:0,newCountryLogins:0}}async getTemplate(){return'\n <div class="row g-3 mb-4">\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-exclamation-triangle text-danger fs-4"></i>\n <div>\n <div class="text-muted small">Open Incidents</div>\n <div class="fw-bold fs-5">{{model.incidents.open}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-ticket-perforated text-warning fs-4"></i>\n <div>\n <div class="text-muted small">Open Tickets</div>\n <div class="fw-bold fs-5">{{model.tickets.open}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-shield-x text-danger fs-4"></i>\n <div>\n <div class="text-muted small">IP Blocks</div>\n <div class="fw-bold fs-5">{{counts.ipBlocks}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-phone-fill text-warning fs-4"></i>\n <div>\n <div class="text-muted small">Blocked Devices</div>\n <div class="fw-bold fs-5">{{counts.blockedDevices}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-person-slash text-info fs-4"></i>\n <div>\n <div class="text-muted small">Blocks Today</div>\n <div class="fw-bold fs-5">{{counts.blocksToday}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class="col">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body py-3">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-geo-alt-fill text-success fs-4"></i>\n <div>\n <div class="text-muted small">New-Country Logins</div>\n <div class="fw-bold fs-5">{{counts.newCountryLogins}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}async onBeforeRender(){await this.fetchAll()}async fetchAll(){const e=this.getApp()?.rest,t=/* @__PURE__ */(new Date).toISOString().slice(0,10),[s,...i]=await Promise.allSettled([this.model.fetch(),e.GET("/api/system/geoip?is_blocked=true&size=0"),e.GET("/api/account/bouncer/device?risk_tier=blocked&size=0"),e.GET(`/api/account/bouncer/signal?decision=block&dr_start=${t}&size=0`),e.GET(`/api/account/logins?is_new_country=true&dr_start=${t}&size=0`)]),a=["ipBlocks","blockedDevices","blocksToday","newCountryLogins"];i.forEach((e,t)=>{"fulfilled"===e.status&&e.value?.data&&(this.counts[a[t]]=e.value.data.count||0)})}async refresh(){await this.fetchAll(),await this.render()}}class OverviewTab extends t.View{constructor(e={}){super({...e,className:"overview-tab"})}async getTemplate(){return'\n <div class="row g-4">\n <div class="col-xl-6 col-12" data-container="events-widget"></div>\n <div class="col-xl-6 col-12" data-container="incidents-widget"></div>\n </div>\n '}async onInit(){this.eventsWidget=new i.MetricsMiniChartWidget({containerId:"events-widget",icon:"bi bi-activity fs-2",title:"System Events",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#154360",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["incident_events"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:140,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-events"}),this.addChild(this.eventsWidget),this.incidentsWidget=new i.MetricsMiniChartWidget({containerId:"incidents-widget",icon:"bi bi-exclamation-triangle fs-2",title:"System Incidents",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#7D6608",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["incidents"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:140,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.25)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-incidents"}),this.addChild(this.incidentsWidget)}async refresh(){await Promise.allSettled([this.eventsWidget?.refresh(),this.incidentsWidget?.refresh()])}}class ThreatsTab extends t.View{constructor(e={}){super({...e,className:"threats-tab"})}async getTemplate(){return'\n <div class="row g-4">\n <div class="col-xl-4 col-lg-6 col-12" data-container="firewall-blocks-widget"></div>\n <div class="col-xl-4 col-lg-6 col-12" data-container="bouncer-blocks-widget"></div>\n <div class="col-xl-4 col-lg-6 col-12" data-container="bouncer-prescreen-widget"></div>\n </div>\n '}async onInit(){this.firewallBlocksWidget=new i.MetricsMiniChartWidget({containerId:"firewall-blocks-widget",icon:"bi bi-shield-x fs-2",title:"Firewall Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#922B21",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["firewall:blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-firewall-blocks"}),this.addChild(this.firewallBlocksWidget),this.bouncerBlocksWidget=new i.MetricsMiniChartWidget({containerId:"bouncer-blocks-widget",icon:"bi bi-person-slash fs-2",title:"Bouncer Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#6C3483",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["bouncer:blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-bouncer-blocks"}),this.addChild(this.bouncerBlocksWidget),this.bouncerPrescreenWidget=new i.MetricsMiniChartWidget({containerId:"bouncer-prescreen-widget",icon:"bi bi-funnel fs-2",title:"Pre-Screen Blocks",subtitle:'{{now_value}} <span class="subtitle-label">{{now_label}}</span>',background:"#1A5276",textColor:"#FFFFFF",endpoint:"/api/metrics/fetch",granularity:"days",slugs:["bouncer:pre_screen_blocks"],account:"incident",chartType:"line",showTooltip:!0,showXAxis:!1,height:120,color:"rgba(255,255,255,0.9)",fill:!0,fillColor:"rgba(255,255,255,0.2)",smoothing:.3,defaultDateRange:"7d",valueFormat:"number",showTrending:!0,showSettings:!0,settingsKey:"incident-dashboard-bouncer-prescreen"}),this.addChild(this.bouncerPrescreenWidget)}async refresh(){await Promise.allSettled([this.firewallBlocksWidget?.refresh(),this.bouncerBlocksWidget?.refresh(),this.bouncerPrescreenWidget?.refresh()])}}class GeographyTab extends t.View{constructor(e={}){super({...e,className:"geography-tab"})}async getTemplate(){return'\n <div class="card shadow-sm mb-4">\n <div class="card-header border-0 bg-transparent d-flex align-items-center justify-content-between">\n <div>\n <h6 class="mb-0 text-uppercase small text-muted">Global Event Hotspots</h6>\n <span class="text-muted small">Clusters sized by total events</span>\n </div>\n <span class="badge bg-info-subtle text-info">Interactive</span>\n </div>\n <div class="card-body p-0" data-container="events-country-map"></div>\n </div>\n\n <div class="row g-4">\n <div class="col-lg-6">\n <div class="card shadow-sm h-100">\n <div class="card-header border-0 bg-transparent">\n <h6 class="mb-0 text-uppercase small text-muted">Events by Country</h6>\n <span class="text-muted small">Hotspots from the last 24 hours</span>\n </div>\n <div class="card-body p-3" data-container="events-by-country-chart"></div>\n </div>\n </div>\n <div class="col-lg-6">\n <div class="card shadow-sm h-100">\n <div class="card-header border-0 bg-transparent">\n <h6 class="mb-0 text-uppercase small text-muted">Incidents by Country</h6>\n <span class="text-muted small">Highest volume regions</span>\n </div>\n <div class="card-body p-3" data-container="incidents-by-country-chart"></div>\n </div>\n </div>\n </div>\n '}async onInit(){this.eventsCountryMap=new d.MetricsCountryMapView({containerId:"events-country-map",category:"incident_events_by_country",account:"incident",maxCountries:20,metricLabel:"Events",height:360,mapStyle:"dark"}),this.addChild(this.eventsCountryMap),this.eventsByCountryChart=new i.MetricsChart({title:'<i class="bi bi-globe-central-south-asia me-2"></i> Events by Country',endpoint:"/api/metrics/fetch",account:"incident",category:"incident_events_by_country",granularity:"days",chartType:"line",showDateRange:!1,showMetricsFilter:!1,height:220,maxDatasets:10,colors:["rgba(32, 201, 151, 0.85)"],yAxis:{label:"Events",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"events-by-country-chart"}),this.addChild(this.eventsByCountryChart),this.incidentsByCountryChart=new i.MetricsChart({title:'<i class="bi bi-geo-alt me-2"></i> Incidents by Country',endpoint:"/api/metrics/fetch",account:"incident",category:"incidents_by_country",granularity:"days",chartType:"line",showDateRange:!1,showMetricsFilter:!1,height:220,maxDatasets:10,colors:["rgba(255, 193, 7, 0.85)"],yAxis:{label:"Incidents",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"incidents-by-country-chart"}),this.addChild(this.incidentsByCountryChart)}async refresh(){await Promise.allSettled([this.eventsCountryMap?.refresh(),this.eventsByCountryChart?.refresh(),this.incidentsByCountryChart?.refresh()])}}class LoginMapTab extends t.View{constructor(e={}){super({...e,className:"login-map-tab"})}async getTemplate(){return'<div data-container="login-map"></div>'}async onInit(){this.loginMap=new LoginLocationMapView({containerId:"login-map",height:480,mapStyle:"dark"}),this.addChild(this.loginMap)}onTabActivated(){this.loginMap?.onTabActivated()}async refresh(){await(this.loginMap?.refresh())}}class LoginActivityTab extends t.View{constructor(e={}){super({...e,className:"login-activity-tab"})}async getTemplate(){return'<div data-container="login-table"></div>'}async onInit(){this.loginTable=new n.TableView({containerId:"login-table",collection:new LoginEventList({params:{sort:"-created",size:20}}),searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"user.display_name",label:"User",sortable:!0},{key:"ip_address",label:"IP Address",sortable:!0},{key:"city",label:"City",formatter:"default('—')"},{key:"region",label:"Region",formatter:"default('—')"},{key:"country_code",label:"Country",sortable:!0,filter:{type:"text",label:"Country Code"}},{key:"source",label:"Source",sortable:!0,filter:{type:"select",options:[{value:"password",label:"Password"},{value:"magic",label:"Magic Link"},{value:"sms",label:"SMS"},{value:"totp",label:"TOTP"},{value:"oauth",label:"OAuth"}]}},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px",filter:{type:"select",options:[{value:"true",label:"Yes"},{value:"false",label:"No"}]}}]}),this.addChild(this.loginTable)}async onTabActivated(){await this.refresh()}async refresh(){await(this.loginTable?.collection?.fetch())}}class IncidentDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Security Dashboard",className:"incident-dashboard-page"})}async getTemplate(){return'\n <div class="container-fluid incident-dashboard">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <p class="text-muted mb-0">Security Dashboard</p>\n <small class="text-info">\n <i class="bi bi-activity me-1"></i>\n Real-time visibility into incidents, threats, and enforcement\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh dashboard">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n </div>\n\n <div data-container="stats-bar"></div>\n <div data-container="tabs"></div>\n </div>\n '}async onInit(){this.getApp()?.showLoading("Loading Security Dashboard...");try{this.statsBar=new SecurityStatsBar({containerId:"stats-bar"}),this.addChild(this.statsBar),this.overviewTab=new OverviewTab,this.threatsTab=new ThreatsTab,this.geographyTab=new GeographyTab,this.loginMapTab=new LoginMapTab,this.loginActivityTab=new LoginActivityTab,this.tabView=new a.TabView({containerId:"tabs",tabs:{Overview:this.overviewTab,Threats:this.threatsTab,Geography:this.geographyTab,"Login Map":this.loginMapTab,"Login Activity":this.loginActivityTab},activeTab:"Overview"}),this.addChild(this.tabView)}finally{this.getApp()?.hideLoading()}}async onActionRefreshAll(e,t){const s=t||e?.currentTarget||null,i=s?.querySelector?.("i");i?.classList.add("bi-spin"),s&&(s.disabled=!0);const a=this.tabView.getActiveTab(),n=this.tabView.getTab(a);await Promise.allSettled([this.statsBar.refresh(),n?.refresh?.()]),i?.classList.remove("bi-spin"),s&&(s.disabled=!1)}}class StackTraceView extends t.View{constructor(e={}){super({className:"stack-trace-view",...e}),this.stackTrace=e.stackTrace||"",this.template="\n <div class=\"stack-trace-container p-3\">\n <style>\n .stack-trace-line {\n font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;\n font-size: 13px;\n line-height: 1.6;\n padding: 4px 8px;\n margin: 0;\n border-left: 3px solid transparent;\n }\n .stack-trace-line:hover {\n background-color: rgba(0, 0, 0, 0.05);\n }\n .stack-trace-error {\n color: #dc3545;\n font-weight: 600;\n border-left-color: #dc3545;\n background-color: rgba(220, 53, 69, 0.05);\n }\n .stack-trace-file {\n color: #0d6efd;\n font-weight: 500;\n border-left-color: #0d6efd;\n }\n .stack-trace-function {\n color: #6610f2;\n font-weight: 500;\n }\n .stack-trace-location {\n color: #6c757d;\n font-size: 12px;\n }\n .stack-trace-line-number {\n color: #fd7e14;\n font-weight: 600;\n }\n .stack-trace-context {\n color: #495057;\n background-color: rgba(0, 0, 0, 0.02);\n }\n .stack-trace-container {\n background-color: #f8f9fa;\n border: 1px solid #dee2e6;\n border-radius: 0.375rem;\n max-height: 600px;\n overflow-y: auto;\n }\n </style>\n <div class=\"stack-trace-content\">\n {{{formattedStackTrace}}}\n </div>\n </div>\n "}async onBeforeRender(){this.formattedStackTrace=this.formatStackTrace(this.stackTrace)}formatStackTrace(e){if(!e)return'<div class="text-muted p-3">No stack trace available</div>';const t=("string"==typeof e?e:JSON.stringify(e,null,2)).split("\n");let s="";return t.forEach((e,t)=>{if(!e.trim())return void(s+='<div class="stack-trace-line"> </div>');if(0===t&&(e.includes("Error:")||e.includes("Exception:")))return void(s+=`<div class="stack-trace-line stack-trace-error">${this.escapeHtml(e)}</div>`);let i=e.match(/(.+?)\s*\(([^:]+):(\d+):(\d+)\)/);if(i){const[,e,t,a,n]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-function">${this.escapeHtml(e.trim())}</span>\n <span class="stack-trace-location"> (${this.escapeHtml(t)}:<span class="stack-trace-line-number">${a}</span>:${n})</span>\n </div>`)}if(i=e.match(/^\s*at\s+([^:]+):(\d+):(\d+)/),i){const[,e,t,a]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-location">at ${this.escapeHtml(e)}:<span class="stack-trace-line-number">${t}</span>:${a}</span>\n </div>`)}if(i=e.match(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)/),i){const[,e,t,a]=i;return void(s+=`<div class="stack-trace-line stack-trace-file">\n <span class="stack-trace-location">File "${this.escapeHtml(e)}", line <span class="stack-trace-line-number">${t}</span>, in </span>\n <span class="stack-trace-function">${this.escapeHtml(a)}</span>\n </div>`)}e.trim().startsWith("at ")?s+=`<div class="stack-trace-line stack-trace-file">${this.escapeHtml(e)}</div>`:s+=`<div class="stack-trace-line stack-trace-context">${this.escapeHtml(e)}</div>`}),s}updateStackTrace(e){this.stackTrace=e,this.render()}}const y=[{value:"block",label:"Block IP",icon:"bi-slash-circle",description:"Block the source IP address for a specified duration",fields:[{name:"ttl",type:"select",label:"Duration",options:[{value:"3600",label:"1 hour"},{value:"21600",label:"6 hours"},{value:"86400",label:"24 hours"},{value:"604800",label:"7 days"},{value:"2592000",label:"30 days"},{value:"0",label:"Permanent"}],default:"86400"}],build:e=>`block://?ttl=${e.ttl||86400}`,preview:e=>`Block source IP for ${{3600:"1 hour",21600:"6 hours",86400:"24 hours",604800:"7 days",2592e3:"30 days",0:"permanently"}[e.ttl]||e.ttl+"s"}`},{value:"email",label:"Email",icon:"bi-envelope",description:"Send an email notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission or key@name)",default:"perm@manage_security"}],build:e=>`email://${e.target||"perm@manage_security"}`,preview:e=>`Email notification to ${e.target||"perm@manage_security"}`},{value:"sms",label:"SMS",icon:"bi-chat-dots",description:"Send an SMS notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission)",default:"perm@manage_security"}],build:e=>`sms://${e.target||"perm@manage_security"}`,preview:e=>`SMS notification to ${e.target||"perm@manage_security"}`},{value:"notify",label:"Push Notification",icon:"bi-bell",description:"Send a push notification to users with a permission",fields:[{name:"target",type:"text",label:"Target (perm@permission)",default:"perm@manage_security"}],build:e=>`notify://${e.target||"perm@manage_security"}`,preview:e=>`Push notification to ${e.target||"perm@manage_security"}`},{value:"ticket",label:"Create Ticket",icon:"bi-ticket-detailed",description:"Automatically create a support ticket",fields:[{name:"priority",type:"select",label:"Priority",options:[{value:"1",label:"1 - Low"},{value:"3",label:"3 - Normal"},{value:"5",label:"5 - Medium"},{value:"8",label:"8 - High"},{value:"10",label:"10 - Critical"}],default:"5"}],build:e=>`ticket://?priority=${e.priority||5}`,preview:e=>`Create ticket with ${{1:"Low",3:"Normal",5:"Medium",8:"High",10:"Critical"}[e.priority]||"priority "+e.priority} priority`},{value:"job",label:"Run Job",icon:"bi-gear-wide-connected",description:"Run an async job (Python module path)",fields:[{name:"func",type:"text",label:"Module Path",placeholder:"myapp.security.handlers.on_incident"}],build:e=>`job://${e.func||""}`,preview:e=>`Run job: ${e.func||"(no function specified)"}`},{value:"llm",label:"LLM Triage",icon:"bi-stars",description:"Use LLM to analyze and triage the incident",fields:[],build:()=>"llm://",preview:()=>"LLM-powered incident triage and analysis"}];class HandlerBuilderView extends t.View{constructor(e={}){super({className:"handler-builder-view",...e}),this.handlerString=e.value||"",this._parseExisting(),this.template=`\n <style>\n .hb-container { border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; background: #f8f9fa; }\n .hb-type-select { margin-bottom: 0.75rem; }\n .hb-fields { margin-bottom: 0.75rem; }\n .hb-preview { padding: 0.5rem 0.75rem; background: #e9ecef; border-radius: 6px; font-size: 0.85rem; }\n .hb-preview code { font-size: 0.8rem; }\n .hb-preview .hb-desc { color: #495057; margin-bottom: 0.25rem; }\n .hb-preview .hb-raw { color: #6c757d; font-family: ui-monospace, monospace; }\n </style>\n <div class="hb-container">\n <div class="hb-type-select">\n <label class="form-label fw-semibold small">Handler Type</label>\n <select class="form-select form-select-sm" data-action="change-type" id="hb-type-select">\n <option value="">— Select handler type —</option>\n ${y.map(e=>`<option value="${e.value}">${e.label}</option>`).join("")}\n </select>\n </div>\n <div id="hb-fields" class="hb-fields"></div>\n <div id="hb-preview" class="hb-preview" style="display:none;"></div>\n </div>\n `}_parseExisting(){if(!this.handlerString)return this.selectedType=null,void(this.fieldValues={});const e=this.handlerString.match(/^(\w+):\/\/(.*)$/);if(!e)return this.selectedType=null,void(this.fieldValues={});const[,t,s]=e;this.selectedType=t,this.fieldValues={};const i=y.find(e=>e.value===t);if(i)if(s.startsWith("?")){const e=new URLSearchParams(s);for(const t of i.fields){const s=e.get(t.name);null!==s&&(this.fieldValues[t.name]=s)}}else 1===i.fields.length&&(this.fieldValues[i.fields[0].name]=s)}onAfterRender(){const e=this.element?.querySelector("#hb-type-select");e&&this.selectedType&&(e.value=this.selectedType,this._renderFields(),this._updatePreview())}onActionChangeType(e){const t=e.target;this.selectedType=t.value||null,this.fieldValues={};const s=y.find(e=>e.value===this.selectedType);if(s)for(const i of s.fields)void 0!==i.default&&(this.fieldValues[i.name]=i.default);return this._renderFields(),this._updatePreview(),this._emitChange(),!0}_renderFields(){const e=this.element?.querySelector("#hb-fields");if(!e)return;const t=y.find(e=>e.value===this.selectedType);t&&t.fields.length?e.innerHTML=t.fields.map(e=>{const t=this.fieldValues[e.name]||e.default||"";if("select"===e.type){const s=e.options.map(e=>`<option value="${e.value}" ${e.value===String(t)?"selected":""}>${e.label}</option>`).join("");return`\n <div class="mb-2">\n <label class="form-label small">${e.label}</label>\n <select class="form-select form-select-sm" data-field="${e.name}" data-action="field-change">${s}</select>\n </div>`}return`\n <div class="mb-2">\n <label class="form-label small">${e.label}</label>\n <input type="text" class="form-control form-control-sm" data-field="${e.name}" data-action="field-change"\n value="${t}" placeholder="${e.placeholder||""}">\n </div>`}).join(""):e.innerHTML=""}onActionFieldChange(e){const t=e.target,s=t.dataset.field;return s&&(this.fieldValues[s]=t.value,this._updatePreview(),this._emitChange()),!0}_updatePreview(){const e=this.element?.querySelector("#hb-preview");if(!e)return;const t=y.find(e=>e.value===this.selectedType);if(!t)return void(e.style.display="none");const s=t.build(this.fieldValues),i=t.preview(this.fieldValues);e.style.display="block",e.innerHTML=`\n <div class="hb-desc"><i class="bi ${t.icon} me-1"></i>${i}</div>\n <div class="hb-raw"><code>${s}</code></div>\n `}_emitChange(){const e=this.getValue();this.emit("change",e)}getValue(){const e=y.find(e=>e.value===this.selectedType);return e?e.build(this.fieldValues):""}setValue(e){if(this.handlerString=e||"",this._parseExisting(),this.isMounted()){const e=this.element?.querySelector("#hb-type-select");e&&(e.value=this.selectedType||""),this._renderFields(),this._updatePreview()}}}class RuleSetView extends t.View{constructor(e={}){super({className:"ruleset-view",...e}),this.model=e.model||new a.RuleSet(e.data||{}),this.template='\n <div class="ruleset-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary"><i class="bi bi-gear-wide-connected"></i></div>\n <div>\n <h3 class="mb-1">{{model.name}}</h3>\n <div class="text-muted small">Scope: {{model.category}} | Priority: {{model.priority}}</div>\n </div>\n </div>\n <div data-container="ruleset-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="ruleset-tabs"></div>\n </div>\n '}async onInit(){const t=this.model.get("match_by"),s=a.MatchByOptions.find(e=>e.value===t),i=s?s.label:String(t),o=this.model.get("bundle_by"),l=a.BundleByOptions.find(e=>e.value===o),d=l?l.label:String(o),c=this.model.get("trigger_count"),m=this.model.get("trigger_window"),u=this.model.get("retrigger_every");this.configView=new r.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:[{name:"name",label:"Name",cols:6},{name:"id",label:"RuleSet ID",cols:3},{name:"is_active",label:"Active",formatter:"yesnoicon",cols:3},{name:"category",label:"Event Category",formatter:"badge",cols:4},{name:"priority",label:"Evaluation Priority",cols:4},{name:"match_by",label:"Match Logic",template:i,cols:4},{name:"bundle_by",label:"Bundle By",template:d,cols:4},{name:"bundle_minutes",label:"Bundle Window (min)",cols:4},{name:"bundle_by_rule_set",label:"Bundle by RuleSet",formatter:"yesnoicon",cols:4},{name:"trigger_count",label:"Trigger Count",template:null!=c?String(c)+" events":"Immediate (first event)",cols:4},{name:"trigger_window",label:"Trigger Window",template:null!=m?m+" minutes":"All events counted",cols:4},{name:"retrigger_every",label:"Re-trigger Every",template:null!=u?u+" events":"Fire once only",cols:4},{name:"handler",label:"Handler Chain",cols:12}]});const b=new a.RuleList({params:{parent:this.model.get("id")}});this.rulesView=new n.TableView({collection:b,hideActivePillNames:["parent"],columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"field_name",label:"Field"},{key:"comparator",label:"Comparator",width:"120px"},{key:"value",label:"Value"},{key:"value_type",label:"Type",width:"100px"}],showAdd:!0,clickAction:"edit",actions:["edit","delete"],contextMenu:[{label:"Edit Rule",action:"edit",icon:"bi-pencil"},{label:"Duplicate Rule",action:"duplicate",icon:"bi-files"},{divider:!0},{label:"Delete Rule",action:"delete",icon:"bi-trash",danger:!0}],addFormDefaults:{parent:this.model.get("id")}}),this.tabView=new a.TabView({containerId:"ruleset-tabs",tabs:{Configuration:this.configView,Rules:this.rulesView},activeTab:"Configuration"}),this.addChild(this.tabView);const h=new e.ContextMenu({containerId:"ruleset-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit RuleSet",action:"edit-ruleset",icon:"bi-pencil"},{label:"Edit Handler",action:"edit-handler",icon:"bi-tools"},{label:"Disable",action:"disable-ruleset",icon:"bi-toggle-off"},{type:"divider"},{label:"Delete RuleSet",action:"delete-ruleset",icon:"bi-trash",danger:!0}]}});this.addChild(h)}async onActionEditHandler(){const e=new HandlerBuilderView({value:this.model.get("handler")||""});if("save"===await s.Dialog.showDialog({title:"Configure Handler",body:e,size:"md",scrollable:!0,buttons:[{text:"Cancel",class:"btn-secondary",dismiss:!0},{text:"Save",class:"btn-primary",action:"save"}]})){const t=e.getValue();t&&(200===(await this.model.save({handler:t})).status?(this.getApp()?.toast?.success("Handler updated"),await this.render()):this.getApp()?.toast?.error("Failed to update handler"))}}async onActionEditRuleset(){await s.Dialog.showModelForm({title:`Edit RuleSet - ${this.model.get("name")}`,model:this.model,formConfig:a.RuleSet.EDIT_FORM})&&await this.render()}async onActionDisableRuleset(){const e=!this.model.get("is_active");try{this.model.set("is_active",e),await this.model.save(),await this.render(),this.getApp()?.toast?.success(`RuleSet ${e?"enabled":"disabled"} successfully`)}catch(t){this.getApp()?.toast?.error(`Failed to update RuleSet: ${t.message}`)}}async onActionDeleteRuleset(){if(await s.Dialog.confirm({title:"Delete RuleSet",message:`Are you sure you want to delete the ruleset "${this.model.get("name")}"? This action cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("RuleSet deleted successfully");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("ruleset:deleted",{model:this.model})}catch(e){this.getApp()?.toast?.error(`Failed to delete RuleSet: ${e.message}`)}}}RuleSetView.VIEW_CLASS=RuleSetView;class IncidentHistoryAdapter{constructor(e){this.incidentId=e,this.collection=new a.IncidentHistoryList({params:{parent:this.incidentId,sort:"created",size:100}})}async fetch(){await this.collection.fetch();const e=this.collection.models.map(e=>this.transform(e));return await Promise.all(e.map(async e=>{"system_event"===e.type&&e.content&&(e.content=await this._renderMarkdown(e.content))})),e}transform(e){return{id:e.get("id"),type:"comment"===e.get("kind")?"user_comment":"system_event",author:{name:e.get("user.display_name")||"System",avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]}}async addNote(e){const t=new a.IncidentHistory,s=await t.save({parent:this.incidentId,note:e.text,kind:"comment",media:e.files&&e.files.length>0?e.files[0].id:null});return s.success&&await this.collection.fetch(),s}async _renderMarkdown(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}}class AssistantConversation extends t.Model{constructor(e={}){super(e,{endpoint:"/api/assistant/conversation"})}}class AssistantConversationList extends t.Collection{constructor(e={}){super({ModelClass:AssistantConversation,endpoint:"/api/assistant/conversation",size:50,...e})}}class AssistantMessageView extends a.ChatMessageView{constructor(e={}){super(e),this._blockViews=[],this._needsMarkdown="assistant"===this.message.role&&!!this.message.content}async onAfterRender(){if(await super.onAfterRender(),this._needsMarkdown&&(this._needsMarkdown=!1,await this._renderMarkdown()),this._setupCollapsibleMessage(),!this.message.blocks||0===this.message.blocks.length)return;const e=this.element.querySelector(`[data-container="blocks-${this.message.id||this.id}"]`);if(e)for(let s=0;s<this.message.blocks.length;s++){const i=this.message.blocks[s],a=document.createElement("div");a.className="assistant-block mb-3",e.appendChild(a);try{"table"===i.type?await this._renderTableBlock(i,a):"chart"===i.type?await this._renderChartBlock(i,a):"stat"===i.type?this._renderStatBlock(i,a):"action"===i.type?this._renderActionBlock(i,a):"list"===i.type?this._renderListBlock(i,a):"alert"===i.type?this._renderAlertBlock(i,a):"progress"===i.type&&this._renderProgressBlock(i,a)}catch(t){console.error("Failed to render block:",i.type,t);const e=document.createElement("div");e.className="alert alert-warning small",e.textContent=`Failed to render ${i.type} block`,a.appendChild(e)}}}_createCollapsibleCard(e,{icon:t,title:s,subtitle:i}){const a=`block-${this.message.id||this.id}-${++AssistantMessageView._blockCounter}`,n=this._escapeHtml.bind(this),o=document.createElement("div");o.className="assistant-collapsible-block",o.innerHTML=`\n <a class="assistant-block-toggle collapsed" data-bs-toggle="collapse"\n href="#${a}" role="button" aria-expanded="false">\n <span class="assistant-block-toggle-icon">\n <i class="bi ${t}"></i>\n </span>\n <span class="assistant-block-toggle-text">\n <span class="assistant-block-toggle-title">${n(s||"Data")}</span>\n ${i?`<span class="assistant-block-toggle-subtitle">${n(i)}</span>`:""}\n </span>\n <i class="bi bi-chevron-down assistant-block-chevron"></i>\n </a>\n <div class="collapse" id="${a}">\n <div class="assistant-block-body"></div>\n </div>\n `,e.appendChild(o);const l=o.querySelector(".collapse");let r=[];return l.addEventListener("shown.bs.collapse",()=>{r.forEach(e=>e()),r=[]},{once:!0}),{body:o.querySelector(".assistant-block-body"),onShow:e=>r.push(e)}}async _renderTableBlock(e,s){const{default:i}=await Promise.resolve().then(()=>require("./chunks/Passkeys-DNpner4L.js")).then(e=>e.TableView$1),a=(e.rows||[]).length,n=(e.columns||[]).length,{body:o}=this._createCollapsibleCard(s,{icon:"bi-table",title:e.title||"Table",subtitle:`${a} rows · ${n} columns`}),l=(e.columns||[]).map(e=>"string"==typeof e?{key:e,label:e}:e),r=l.map(e=>e.key),d=(e.rows||[]).map((e,s)=>{const i={id:s};return r.forEach((t,s)=>{i[t]=void 0!==e[s]?e[s]:""}),new t.Model(i)}),c=new t.Collection({preloaded:!0});c.add(d);const m=new i({collection:c,columns:l,paginated:!1,sortable:!1,searchable:!1,filterable:!1,showRefresh:!1,showAdd:!1});this._blockViews.push(m),this.addChild(m),o.appendChild(m.element),m.render(!1)}async _renderChartBlock(e,t){const s=e.chart_type||"line",i=(e.series||[]).length,a=e.labels?.length||0,n="pie"===s,{body:o,onShow:l}=this._createCollapsibleCard(t,{icon:{line:"bi-graph-up",bar:"bi-bar-chart-fill",pie:"bi-pie-chart-fill",area:"bi-graph-up"}[s]||"bi-graph-up",title:e.title||{line:"Line Chart",bar:"Bar Chart",pie:"Pie Chart",area:"Area Chart"}[s]||"Chart",subtitle:n?`${a} segments`:`${i} series · ${a} points`}),r=document.createElement("div");r.className="assistant-chart-body",o.appendChild(r);const d={labels:e.labels||[],datasets:(e.series||[]).map(e=>({label:e.name,data:e.values}))};if(n){const{default:e}=await Promise.resolve().then(()=>require("./chunks/MiniPieChart-NgLN6VnD.js")),t=new e({width:180,height:180,legendPosition:"right",data:d});this._blockViews.push(t),this.addChild(t),r.appendChild(t.element),t.render(!1)}else{const{default:e}=await Promise.resolve().then(()=>require("./chunks/MiniSeriesChart-BAf92vx5.js")),t=new e({chartType:"area"===s?"line":s,fill:"area"===s,height:200,legendPosition:"top",data:d});this._blockViews.push(t),this.addChild(t),r.appendChild(t.element),t.render(!1)}}_renderStatBlock(e,t){const s=e.items||[],i=document.createElement("div");i.className="d-flex flex-wrap gap-2",s.forEach(e=>{const t=document.createElement("div");t.className="assistant-stat-card card",t.innerHTML=`\n <div class="card-body text-center py-2 px-3">\n <div class="text-muted small">${this._escapeHtml(e.label)}</div>\n <div class="fw-bold fs-5">${this._escapeHtml(String(e.value))}</div>\n </div>\n `,i.appendChild(t)}),t.appendChild(i)}_renderActionBlock(e,t){const s=this._escapeHtml.bind(this),i=document.createElement("div");i.className="assistant-action-card",i.innerHTML=`\n <div class="assistant-action-header">${s(e.title||"Action Required")}</div>\n ${e.description?`<div class="assistant-action-desc">${s(e.description)}</div>`:""}\n <div class="assistant-action-buttons"></div>\n `;const a=i.querySelector(".assistant-action-buttons");(e.actions||[]).forEach((t,s)=>{const i=document.createElement("button");i.className=0===s?"btn btn-sm btn-primary":"btn btn-sm btn-outline-secondary",i.textContent=t.label,i.addEventListener("click",()=>{a.querySelectorAll("button").forEach(e=>{e.disabled=!0,e.classList.add("assistant-action-dimmed")}),i.classList.remove("assistant-action-dimmed"),i.classList.add("assistant-action-chosen");const s=this.getApp();s?.ws?.isConnected&&s.ws.send({type:"assistant_action",conversation_id:this.message._conversationId,action_id:e.action_id,value:t.value})}),a.appendChild(i)}),t.appendChild(i)}_renderListBlock(e,t){const s=this._escapeHtml.bind(this),i=document.createElement("div");i.className="assistant-list-card";let a="";e.title&&(a+=`<div class="assistant-list-title">${s(e.title)}</div>`),a+='<dl class="assistant-list-items">',(e.items||[]).forEach(e=>{a+=`\n <div class="assistant-list-row">\n <dt>${s(e.label)}</dt>\n <dd>${s(String(e.value??""))}</dd>\n </div>`}),a+="</dl>",i.innerHTML=a,t.appendChild(i)}_renderAlertBlock(e,t){const s=this._escapeHtml.bind(this),i=e.level||"info",a={info:"bi-info-circle-fill",success:"bi-check-circle-fill",warning:"bi-exclamation-triangle-fill",error:"bi-x-circle-fill"},n=document.createElement("div");n.className=`assistant-alert alert ${{info:"alert-info",success:"alert-success",warning:"alert-warning",error:"alert-danger"}[i]||"alert-info"}`,n.innerHTML=`\n <i class="bi ${a[i]||a.info} me-2"></i>\n <div class="assistant-alert-content">\n ${e.title?`<strong>${s(e.title)}</strong>`:""}\n <div>${s(e.message||"")}</div>\n </div>\n `,t.appendChild(n)}_renderProgressBlock(e,t){const s=this._escapeHtml.bind(this),i=e.steps||[],a=i.filter(e=>"done"===e.status).length,n=i.length>0?Math.round(a/i.length*100):0,o=document.createElement("div");o.className="assistant-progress-card",e.plan_id&&(o.dataset.planId=e.plan_id);let l="";const r={pending:"bi-circle",in_progress:"bi-arrow-repeat",done:"bi-check-circle-fill",skipped:"bi-slash-circle"};i.forEach(e=>{l+=`\n <div class="assistant-progress-step step-${e.status}" data-step-id="${e.id}">\n <i class="bi ${r[e.status]||r.pending} step-icon"></i>\n <div class="step-content">\n <span class="step-description">${s(e.description)}</span>\n ${e.summary?`<span class="step-summary">${s(e.summary)}</span>`:""}\n </div>\n </div>`}),o.innerHTML=`\n <div class="assistant-progress-header">\n <span class="assistant-progress-title">${s(e.title||"Plan")}</span>\n <span class="assistant-progress-counter">${a} of ${i.length}</span>\n </div>\n <div class="progress" style="height: 4px; margin-bottom: 10px;">\n <div class="progress-bar" role="progressbar" style="width: ${n}%"></div>\n </div>\n <div class="assistant-progress-steps">${l}</div>\n `,t.appendChild(o)}updateProgressStep(e,t,s,i){const a=this.element?.querySelector(`[data-plan-id="${e}"]`);if(!a)return;const n={pending:"bi-circle",in_progress:"bi-arrow-repeat",done:"bi-check-circle-fill",skipped:"bi-slash-circle"},o=a.querySelector(`[data-step-id="${t}"]`);if(o){o.className=`assistant-progress-step step-${s}`;const e=o.querySelector(".step-icon");e&&(e.className=`bi ${n[s]||n.pending} step-icon`);let t=o.querySelector(".step-summary");i&&(t||(t=document.createElement("span"),t.className="step-summary",o.querySelector(".step-content").appendChild(t)),t.textContent=i)}const l=a.querySelectorAll(".assistant-progress-step"),r=a.querySelectorAll(".step-done").length,d=a.querySelector(".assistant-progress-counter");d&&(d.textContent=`${r} of ${l.length}`);const c=a.querySelector(".progress-bar");c&&(c.style.width=`${l.length>0?Math.round(r/l.length*100):0}%`)}_setupCollapsibleMessage(){if("assistant"!==this.message.role)return;const e=this.element?.querySelector(".message-text");e&&e.textContent.trim()&&requestAnimationFrame(()=>{if(e.scrollHeight<=300)return;e.classList.add("message-collapsed"),e.style.setProperty("--collapse-height","300px");const t=document.createElement("button");t.className="message-expand-toggle",t.innerHTML='<i class="bi bi-chevron-down me-1"></i>Show more',t.addEventListener("click",()=>{const s=e.classList.toggle("message-collapsed");t.innerHTML=s?'<i class="bi bi-chevron-down me-1"></i>Show more':'<i class="bi bi-chevron-up me-1"></i>Show less'}),e.parentNode.insertBefore(t,e.nextSibling)})}async _renderMarkdown(){const e=this.element?.querySelector(".message-text");if(!e)return;const t=this.message.content;try{const s=this.getApp(),i=await s.rest.post("/api/docit/render",{markdown:t}),a=i?.data?.data?.html||i?.data?.html;if(a)return void(e.innerHTML=a)}catch(s){}e.innerHTML=AssistantMessageView.markdownToHtml(t)}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}static markdownToHtml(e){const t=document.createElement("div");t.textContent=e;let s=t.innerHTML;return s=s.replace(/```(\w*)\n([\s\S]*?)```/g,(e,t,s)=>`<pre class="assistant-code-block"><code>${s.trim()}</code></pre>`),s=s.replace(/`([^`]+)`/g,'<code class="assistant-inline-code">$1</code>'),s=s.replace(/^### (.+)$/gm,'<h6 class="assistant-heading mt-3 mb-1">$1</h6>'),s=s.replace(/^## (.+)$/gm,'<h5 class="assistant-heading mt-3 mb-1">$1</h5>'),s=s.replace(/^# (.+)$/gm,'<h4 class="assistant-heading mt-3 mb-2">$1</h4>'),s=s.replace(/^---+$/gm,'<hr class="my-2 opacity-25">'),s=s.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>"),s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>"),s=s.replace(/\*(.+?)\*/g,"<em>$1</em>"),s=s.replace(/((?:^- .+$\n?)+)/gm,e=>`<ul class="assistant-list mb-2">${e.trim().split("\n").map(e=>`<li>${e.replace(/^- /,"")}</li>`).join("")}</ul>`),s=s.replace(/\n{2,}/g,"</p><p>"),s=s.replace(/\n/g,"<br>"),s=`<p>${s}</p>`,s=s.replace(/<p>\s*<\/p>/g,""),s=s.replace(/<p>\s*(<h[456]|<hr|<ul|<pre|<\/ul>|<\/pre>)/g,"$1"),s=s.replace(/(<\/h[456]>|<hr[^>]*>|<\/ul>|<\/pre>)\s*<\/p>/g,"$1"),s}}AssistantMessageView._blockCounter=0;class AssistantConversationListView extends t.View{constructor(e={}){super({className:"assistant-conversation-list",...e}),this.collection=e.collection,this.activeId=null}getTemplate(){return'\n <div class="conversation-list-header">\n <button class="btn btn-outline-secondary w-100" data-action="new-conversation">\n <i class="bi bi-plus-lg me-1"></i> New conversation\n </button>\n </div>\n <div class="conversation-list-items" data-container="items"></div>\n '}async onInit(){await this.collection.fetch()}async onAfterRender(){this._renderItems()}_renderItems(){const e=this.element.querySelector('[data-container="items"]');if(!e)return;e.innerHTML="";const t=this.collection.models||[];if(0===t.length)return void(e.innerHTML='\n <div class="text-center text-muted small p-4">\n No conversations yet.<br>Start by typing a message.\n </div>\n ');const s=this._groupByDate(t);for(const[i,a]of s){const t=document.createElement("div");t.className="conversation-date-header px-3 py-1 text-muted small fw-semibold text-uppercase",t.textContent=i,e.appendChild(t),a.forEach(t=>{const s=t.get("id"),i=t.get("title")||t.get("summary")||"New conversation",a=t.get("modified")||t.get("created"),n=this._relativeTime(a),o=s===this.activeId,l=document.createElement("div");l.className="conversation-item px-3 py-2"+(o?" active":""),l.dataset.id=s,l.innerHTML=`\n <div class="d-flex align-items-start">\n <div class="flex-grow-1 overflow-hidden">\n <div class="text-truncate conversation-title">${this._escapeHtml(i)}</div>\n ${n?`<div class="conversation-time text-muted">${n}</div>`:""}\n </div>\n <button class="btn btn-sm btn-link text-muted p-0 ms-2 conversation-delete" data-action="delete-conversation" data-id="${s}" title="Delete">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n `,l.addEventListener("click",e=>{e.target.closest('[data-action="delete-conversation"]')||(this.setActive(s),this.emit("conversation:select",{id:s,model:t}))}),e.appendChild(l)})}}_groupByDate(e){const s=/* @__PURE__ */new Date,i=new Date(s.getFullYear(),s.getMonth(),s.getDate()),a=new Date(i);a.setDate(a.getDate()-1);const n=/* @__PURE__ */new Map;n.set("Today",[]),n.set("Yesterday",[]),n.set("Earlier",[]),e.forEach(e=>{const s=e.get("created")||e.get("modified"),o=new Date(t.dataFormatter.normalizeEpoch(s)),l=new Date(o.getFullYear(),o.getMonth(),o.getDate());l>=i?n.get("Today").push(e):l>=a?n.get("Yesterday").push(e):n.get("Earlier").push(e)});const o=[];for(const[t,l]of n)l.length>0&&o.push([t,l]);return o}setActive(e){this.activeId=e,this.element.querySelectorAll(".conversation-item").forEach(t=>{t.classList.toggle("active",String(t.dataset.id)===String(e))})}onActionNewConversation(){this.setActive(null),this.emit("conversation:new")}async onActionDeleteConversation(e,t){const i=t.dataset.id;if(!(await s.Dialog.confirm({title:"Delete Conversation",message:"Are you sure you want to delete this conversation? This cannot be undone.",confirmText:"Delete",confirmClass:"btn-danger"})))return;const a=this.collection.models.find(e=>String(e.get("id"))===String(i));a&&(await a.destroy(),await this.refresh(),this.emit("conversation:deleted",{id:i}))}async refresh(){await this.collection.fetch(),this._renderItems()}_relativeTime(e){if(!e)return"";const s=new Date(t.dataFormatter.normalizeEpoch(e));if(isNaN(s))return"";const i=Date.now()-s.getTime(),a=Math.floor(i/1e3);if(a<60)return"just now";const n=Math.floor(a/60);if(n<60)return`${n}m ago`;const o=Math.floor(n/60);if(o<24)return`${o}h ago`;const l=Math.floor(o/24);return l<7?`${l}d ago`:`${s.toLocaleString("default",{month:"short"})} ${s.getDate()}`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}}class AssistantView extends t.View{constructor(e={}){super({className:"assistant-view",...e}),this.app=e.app,this.ws=this.app?.ws,this.conversationId=null,this._wsHandlers={},this._messageIdCounter=0,this._hasMessages=!1,this._activePlans={}}getTemplate(){return`\n <div class="assistant-layout">\n <div class="assistant-sidebar" data-container="conversation-list"></div>\n <div class="assistant-main">\n <div class="assistant-welcome" data-ref="welcome">\n <div class="assistant-welcome-content">\n <div class="assistant-welcome-icon">\n <i class="bi bi-stars"></i>\n </div>\n <h3 class="assistant-welcome-title">Hi ${this._escapeHtml(this.app?.activeUser?.get("first_name")||"there")}</h3>\n <p class="assistant-welcome-subtitle">How can I help you today?</p>\n <div class="assistant-suggestions">\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="Show me a summary of recent activity">\n <i class="bi bi-activity"></i>\n <span>Recent activity summary</span>\n </button>\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="How many active users are there?">\n <i class="bi bi-people"></i>\n <span>Active user count</span>\n </button>\n <button class="assistant-suggestion" data-action="use-suggestion" data-text="Show me system health metrics">\n <i class="bi bi-heart-pulse"></i>\n <span>System health check</span>\n </button>\n </div>\n </div>\n </div>\n <div class="assistant-chat-area" data-container="chat-area"></div>\n <div class="assistant-input-wrapper">\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message the assistant..." rows="1" data-ref="input"></textarea>\n <button class="assistant-send-btn" data-action="send" type="button" title="Send message" data-ref="send-btn">\n <i class="bi bi-arrow-up"></i>\n </button>\n <button class="assistant-stop-btn d-none" data-action="stop" type="button" title="Stop generating" data-ref="stop-btn">\n <i class="bi bi-stop-fill"></i>\n </button>\n </div>\n <div class="assistant-input-footer">\n <span class="assistant-connection-indicator" data-ref="status">\n <span class="status-dot connected"></span>\n </span>\n <span class="text-muted">Press Enter to send, Shift+Enter for new line</span>\n </div>\n </div>\n </div>\n </div>\n `}async onInit(){this.conversations=new AssistantConversationList,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new a.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView),this.conversationListView.on("conversation:select",e=>this._onConversationSelect(e)),this.conversationListView.on("conversation:new",()=>this._onNewConversation()),this.conversationListView.on("conversation:deleted",e=>this._onConversationDeleted(e)),this._subscribeWS()}async onAfterRender(){await super.onAfterRender();const e=this.element.querySelector('[data-ref="input"]');e&&(e.addEventListener("input",()=>this._autoResize(e)),e.addEventListener("keydown",e=>this._handleKeydown(e)),setTimeout(()=>e.focus(),100)),this._updateConnectionStatus()}_autoResize(e){e.style.height="auto",e.style.height=Math.min(e.scrollHeight,200)+"px"}_handleKeydown(e){"Enter"!==e.key||e.shiftKey||(e.preventDefault(),this._sendMessage())}onActionUseSuggestion(e,t){const s=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!s)return;const i=this.element.querySelector('[data-ref="input"]');i&&(i.value=s,this._autoResize(i)),this._sendMessage()}onActionSend(){this._sendMessage()}async _sendMessage(){const e=this.element.querySelector('[data-ref="input"]');if(!e)return;const t=e.value.trim();t&&(e.value="",e.style.height="auto",this._showChatArea(),await this.chatView.adapter.addNote({text:t,files:[]}))}_showChatArea(){if(this._hasMessages)return;this._hasMessages=!0;const e=this.element.querySelector('[data-ref="welcome"]'),t=this.element.querySelector('[data-container="chat-area"]');e&&e.classList.add("d-none"),t&&t.classList.remove("d-none")}_showWelcome(){this._hasMessages=!1;const e=this.element.querySelector('[data-ref="welcome"]'),t=this.element.querySelector('[data-container="chat-area"]');e&&e.classList.remove("d-none"),t&&t.classList.add("d-none")}_setInputEnabled(e){const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),i=this.element?.querySelector('[data-ref="stop-btn"]');t&&(t.disabled=!e),s&&s.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),this._responseTimeout&&clearTimeout(this._responseTimeout),e||(this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4))}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Response cancelled.");const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_onResponseTimeout(){this._responseTimeout=null,this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Request timed out. Please try again.")}_createAdapter(){return{fetch:async()=>{if(!this.conversationId)return[];try{const e=new AssistantConversation({id:this.conversationId});await e.fetch({graph:"detail"});const t=(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean);return AssistantView._collapseMessages(t)}catch(e){return 404===e.status&&(this._onNewConversation(),this._showSystemMessage("Conversation not found.")),[]}},addNote:async e=>{if(!e.text||!e.text.trim())return{success:!1};const t={id:"local-"+ ++this._messageIdCounter,role:"user",author:{id:this.app?.activeUser?.id,name:this.app?.activeUser?.get("display_name")||"You"},content:e.text,timestamp:/* @__PURE__ */(new Date).toISOString()};if(this.chatView.addMessage(t),this._setInputEnabled(!1),this.ws&&this.ws.isConnected)this.ws.send({type:"assistant_message",message:e.text,conversation_id:this.conversationId});else try{const t=await this.app.rest.post("/api/assistant",{message:e.text,conversation_id:this.conversationId}),s=t?.data?.data||t?.data||t;s.conversation_id&&(this.conversationId=s.conversation_id),s.response&&this.chatView.addMessage(this._transformMessage(s.response)),this._setInputEnabled(!0)}catch(s){this._handleAPIError(s)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),tool_call:e=>this._onToolCall(e),response:e=>this._onResponse(e),error:e=>this._onError(e),plan:e=>this._onPlan(e),plan_update:e=>this._onPlanUpdate(e),message:e=>this._dispatchWSMessage(e),connected:()=>this._updateConnectionStatus(),disconnected:()=>this._updateConnectionStatus(),reconnecting:()=>this._updateConnectionStatus()},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.on("message:assistant_response",this._wsHandlers.response),this.ws.on("message:assistant_error",this._wsHandlers.error),this.ws.on("message:assistant_plan",this._wsHandlers.plan),this.ws.on("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.on("message:message",this._wsHandlers.message),this.ws.on("connected",this._wsHandlers.connected),this.ws.on("disconnected",this._wsHandlers.disconnected),this.ws.on("reconnecting",this._wsHandlers.reconnecting))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.off("message:assistant_response",this._wsHandlers.response),this.ws.off("message:assistant_error",this._wsHandlers.error),this.ws.off("message:assistant_plan",this._wsHandlers.plan),this.ws.off("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.off("message:message",this._wsHandlers.message),this.ws.off("connected",this._wsHandlers.connected),this.ws.off("disconnected",this._wsHandlers.disconnected),this.ws.off("reconnecting",this._wsHandlers.reconnecting),this._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_tool_call":this._onToolCall(t);break;case"assistant_response":this._onResponse(t);break;case"assistant_error":this._onError(t);break;case"assistant_plan":this._onPlan(t);break;case"assistant_plan_update":this._onPlanUpdate(t)}}_isMyConversation(e){return!e.conversation_id||!this.conversationId||String(e.conversation_id)===String(this.conversationId)}_adoptConversationId(e){e.conversation_id&&!this.conversationId&&(this.conversationId=e.conversation_id,this.conversationListView.refresh())}_onThinking(e){this._isMyConversation(e)&&(this._adoptConversationId(e),this._showChatArea(),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1))}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4))}_onResponse(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0),this._adoptConversationId(e);const t=this.element?.querySelector('[data-ref="input"]');t&&t.focus();const s=this._transformMessage({id:e.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:e.response||e.content||e.message||"",blocks:e.blocks||[],tool_calls:e.tool_calls_made||e.tool_calls||[],created:e.timestamp||/* @__PURE__ */(new Date).toISOString()});this.chatView.addMessage(s)}_onError(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0),this._adoptConversationId(e);const t=e.error||e.message||"An error occurred";this._showSystemMessage(t)}_onPlan(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._showChatArea();const t=e.plan;t&&(this._activePlans[t.plan_id]=t,this.chatView.addMessage({id:`plan-${t.plan_id}`,role:"assistant",author:{name:"Assistant"},content:"",timestamp:/* @__PURE__ */(new Date).toISOString(),blocks:[{type:"progress",...t}],tool_calls:[]}))}_onPlanUpdate(e){if(!this._isMyConversation(e))return;const t=this._activePlans[e.plan_id];if(t){const s=t.steps.find(t=>t.id===e.step_id);s&&(s.status=e.status,s.summary=e.summary)}const s=this.chatView.messageViews.get(`plan-${e.plan_id}`);s?.updateProgressStep&&s.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}async _onConversationSelect(e){this.conversationId=e.id,this.conversationListView.setActive(e.id),this._showChatArea(),await this.chatView.refresh()}_onNewConversation(){this.conversationId=null,this.conversationListView.setActive(null),this.chatView.clearMessages(),this._setInputEnabled(!0),this._showWelcome();const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_onConversationDeleted(e){String(e.id)===String(this.conversationId)&&this._onNewConversation()}_transformMessage(e){if("tool_result"===e.role)return null;let t=e.content||e.text||"",s=e.blocks||[],i=e.tool_calls||[];if(i.length>0){const e=i.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),i=i.filter(e=>"tool_use"===e.type)}if(0===s.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,s=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Assistant"}:e.author||{name:e.user?.display_name||this.app?.activeUser?.get("display_name")||"You",id:e.user?.id||a},content:t,timestamp:e.created||e.timestamp,blocks:s,tool_calls:i,_conversationId:this.conversationId}}static _collapseMessages(e){const t=/* @__PURE__ */new Set(["create_plan","update_plan","load_tools"]),s=[];for(const i of e){"assistant"===i.role&&i.tool_calls?.length>0&&(i.tool_calls=i.tool_calls.filter(e=>!t.has(e.name)));const e=!!i.content,a=i.tool_calls?.length>0,n=i.blocks?.length>0;if("assistant"!==i.role||e||a||n){if("assistant"===i.role&&!e&&a&&!n){const e=s[s.length-1];if("assistant"===e?.role&&!e.content&&e.tool_calls?.length>0&&!e.blocks?.length){e.tool_calls=[...e.tool_calls,...i.tool_calls];continue}}s.push(i)}}return s}static _parseBlocks(e){const t=/```assistant_block\s*\n([\s\S]*?)```/g,s=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress"]),i=[];let a;for(;null!==(a=t.exec(e));)try{const e=JSON.parse(a[1].trim());e&&s.has(e.type)&&i.push(e)}catch(n){}return{content:e.replace(t,"").replace(/\n{3,}/g,"\n\n").trim(),blocks:i}}_showSystemMessage(e){this._showChatArea(),this.chatView.addMessage({id:"sys-"+ ++this._messageIdCounter,type:"system_event",content:e,timestamp:/* @__PURE__ */(new Date).toISOString()})}_handleAPIError(e){404===e.status?this._showSystemMessage("Assistant is not enabled on this server."):503===e.status?this._showSystemMessage("LLM API key not configured. Contact your administrator."):this._showSystemMessage("Failed to send message. Please try again."),this._setInputEnabled(!0)}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");if(e)if(this.ws?.isConnected)e.className="status-dot connected",e.title="Connected";else if(this.ws?.isReconnecting)e.className="status-dot reconnecting",e.title="Reconnecting...";else{e.className="status-dot disconnected",e.title="Disconnected";const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]');t&&(t.disabled=!0),s&&s.classList.add("d-none")}}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}const w=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,default:AssistantView},Symbol.toStringTag,{value:"Module"}));class AssistantContextAdapter{constructor({app:e,modelName:t,pk:s,conversationId:i}){this.app=e,this.modelName=t,this.pk=s,this.conversationId=i,this._messageIdCounter=0,this._onConversationCreated=null}async fetch(){if(!this.conversationId)try{const e=await this.app.rest.post("/api/assistant/context",{model:this.modelName,pk:this.pk}),t=e?.data?.data||e?.data||e;this.conversationId=t.conversation_id,this._onConversationCreated&&this._onConversationCreated(this.conversationId)}catch(e){return[]}try{const e=new AssistantConversation({id:this.conversationId});await e.fetch({graph:"detail"});const t=(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean);return AssistantView._collapseMessages(t)}catch(e){return 404===e.status&&this.conversationId&&!this._fetchRetried?(this._fetchRetried=!0,this.conversationId=null,this.fetch()):[]}}async addNote(e){return e.text&&e.text.trim()?{success:!0}:{success:!1}}_transformMessage(e){if("tool_result"===e.role)return null;let t=e.content||e.text||"",s=e.blocks||[],i=e.tool_calls||[];if(i.length>0){const e=i.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),i=i.filter(e=>"tool_use"===e.type)}if(0===s.length&&t.includes("assistant_block")){const e=/```assistant_block\s*\n([\s\S]*?)```/g,i=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress"]);let n;for(;null!==(n=e.exec(t));)try{const e=JSON.parse(n[1].trim());e&&i.has(e.type)&&s.push(e)}catch(a){}t=t.replace(e,"").replace(/\n{3,}/g,"\n\n").trim()}return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Assistant"}:e.author||{name:e.user?.display_name||this.app?.activeUser?.get("display_name")||"You",id:e.user?.id||this.app?.activeUser?.id},content:t,timestamp:e.created||e.timestamp,blocks:s,tool_calls:i,_conversationId:this.conversationId}}}class AssistantContextChat extends t.View{constructor(e={}){super({className:"assistant-context-chat",...e}),this.app=e.app,this.ws=this.app?.ws,this.adapter=e.adapter,this._wsHandlers={},this._messageIdCounter=0,this._activePlans={},this.template='\n <div class="d-flex flex-column h-100">\n <div class="flex-grow-1" style="min-height: 0;" data-container="chat-area"></div>\n <div class="assistant-input-wrapper border-top p-2">\n <div class="d-flex gap-2 align-items-end">\n <textarea class="form-control" placeholder="Ask the AI assistant..." rows="1" data-ref="input" style="resize: none; max-height: 150px;"></textarea>\n <button class="btn btn-primary" data-action="send" type="button" data-ref="send-btn">\n <i class="bi bi-arrow-up"></i>\n </button>\n <button class="btn btn-outline-danger d-none" data-action="stop" type="button" data-ref="stop-btn">\n <i class="bi bi-stop-fill"></i>\n </button>\n </div>\n <div class="d-flex align-items-center gap-2 mt-1">\n <span class="assistant-connection-indicator" data-ref="status">\n <span class="status-dot connected" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #198754;"></span>\n </span>\n <span class="text-muted small">Enter to send, Shift+Enter for new line</span>\n </div>\n </div>\n </div>\n '}async onInit(){this.chatView=new a.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this.adapter}),this.addChild(this.chatView),this._subscribeWS()}async onAfterRender(){await super.onAfterRender();const e=this.element.querySelector('[data-ref="input"]');e&&(e.addEventListener("input",()=>this._autoResize(e)),e.addEventListener("keydown",e=>this._handleKeydown(e)),setTimeout(()=>e.focus(),100)),this._updateConnectionStatus()}_autoResize(e){e.style.height="auto",e.style.height=Math.min(e.scrollHeight,150)+"px"}_handleKeydown(e){"Enter"!==e.key||e.shiftKey||(e.preventDefault(),this._sendMessage())}onActionSend(){this._sendMessage()}async _sendMessage(){const e=this.element.querySelector('[data-ref="input"]');if(!e)return;const t=e.value.trim();if(!t)return;if(e.value="",e.style.height="auto",!this.adapter.conversationId&&(await this.adapter.fetch(),!this.adapter.conversationId))return void this.app?.toast?.error("Failed to create conversation");const s={id:"local-"+ ++this._messageIdCounter,role:"user",author:{id:this.app?.activeUser?.id,name:this.app?.activeUser?.get("display_name")||"You"},content:t,timestamp:/* @__PURE__ */(new Date).toISOString()};if(this.chatView.addMessage(s),this._setInputEnabled(!1),this.ws&&this.ws.isConnected)this.ws.send({type:"assistant_message",message:t,conversation_id:this.adapter.conversationId});else try{const e=await this.app.rest.post("/api/assistant",{message:t,conversation_id:this.adapter.conversationId}),s=e?.data?.data||e?.data||e;s.response&&this.chatView.addMessage(this.adapter._transformMessage({id:s.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:s.response,blocks:s.blocks||[],created:/* @__PURE__ */(new Date).toISOString()})),this._setInputEnabled(!0)}catch(i){this.app?.toast?.error("Failed to send message"),this._setInputEnabled(!0)}}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_setInputEnabled(e){const t=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),i=this.element?.querySelector('[data-ref="stop-btn"]');t&&(t.disabled=!e),s&&s.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),this._responseTimeout&&clearTimeout(this._responseTimeout),e||(this._responseTimeout=setTimeout(()=>{this.chatView.hideThinking(),this._setInputEnabled(!0),this.app?.toast?.warning("Request timed out")},6e4))}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),tool_call:e=>this._onToolCall(e),response:e=>this._onResponse(e),error:e=>this._onError(e),plan:e=>this._onPlan(e),plan_update:e=>this._onPlanUpdate(e),message:e=>this._dispatchWSMessage(e)},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.on("message:assistant_response",this._wsHandlers.response),this.ws.on("message:assistant_error",this._wsHandlers.error),this.ws.on("message:assistant_plan",this._wsHandlers.plan),this.ws.on("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.on("message:message",this._wsHandlers.message))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_tool_call",this._wsHandlers.tool_call),this.ws.off("message:assistant_response",this._wsHandlers.response),this.ws.off("message:assistant_error",this._wsHandlers.error),this.ws.off("message:assistant_plan",this._wsHandlers.plan),this.ws.off("message:assistant_plan_update",this._wsHandlers.plan_update),this.ws.off("message:message",this._wsHandlers.message),this._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_tool_call":this._onToolCall(t);break;case"assistant_response":this._onResponse(t);break;case"assistant_error":this._onError(t);break;case"assistant_plan":this._onPlan(t);break;case"assistant_plan_update":this._onPlanUpdate(t)}}_isMyConversation(e){return!e.conversation_id||!this.adapter.conversationId||String(e.conversation_id)===String(this.adapter.conversationId)}_onThinking(e){this._isMyConversation(e)&&(this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1))}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=setTimeout(()=>{this.chatView.hideThinking(),this._setInputEnabled(!0),this.app?.toast?.warning("Request timed out")},6e4))}_onResponse(e){if(!this._isMyConversation(e))return;this.chatView.hideThinking(),this._setInputEnabled(!0);const t=this.element?.querySelector('[data-ref="input"]');t&&t.focus();const s=this.adapter._transformMessage({id:e.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:e.response||e.content||e.message||"",blocks:e.blocks||[],tool_calls:e.tool_calls_made||e.tool_calls||[],created:e.timestamp||/* @__PURE__ */(new Date).toISOString()});this.chatView.addMessage(s)}_onError(e){this._isMyConversation(e)&&(this.chatView.hideThinking(),this._setInputEnabled(!0),console.error("[AssistantContextChat] WS error:",e.error||e.message),this.app?.toast?.error("The assistant encountered an error. Please try again."))}_onPlan(e){if(!this._isMyConversation(e))return;const t=e.plan;t&&(this._activePlans[t.plan_id]=t,this.chatView.addMessage({id:`plan-${t.plan_id}`,role:"assistant",author:{name:"Assistant"},content:"",timestamp:/* @__PURE__ */(new Date).toISOString(),blocks:[{type:"progress",...t}],tool_calls:[],_conversationId:this.adapter.conversationId}))}_onPlanUpdate(e){if(!this._isMyConversation(e))return;const t=this._activePlans[e.plan_id];if(t){const s=t.steps.find(t=>t.id===e.step_id);s&&(s.status=e.status,s.summary=e.summary)}const s=this.chatView.messageViews.get(`plan-${e.plan_id}`);s?.updateProgressStep&&s.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");e&&(this.ws?.isConnected?(e.style.background="#198754",e.title="Connected"):(e.style.background="#dc3545",e.title="Disconnected"))}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}async function _(e,t){const i=e.getApp();if(!i)return;const a=e.model,n=a.get("id"),o=(a.get("metadata")||{}).assistant_conversation_id||null,l=new AssistantContextAdapter({app:i,modelName:t,pk:n,conversationId:o});l._onConversationCreated=async e=>{try{await a.save({metadata:{assistant_conversation_id:e}})}catch(t){}};const r=new AssistantContextChat({app:i,adapter:l}),d=new s.Dialog({header:!0,title:"AI Assistant",size:"xl",body:r,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await d.render(!0,document.body),d.show()}function x(e){if("boolean"==typeof e)return"bool";if("number"==typeof e)return Number.isInteger(e)?"int":"float";const t=Number(e);return""===e||isNaN(t)?"str":Number.isInteger(t)?"int":"float"}const k={new:{badge:"bg-info",icon:"bi-bell-fill",label:"New",help:"Unhandled — needs triage by human or LLM agent"},open:{badge:"bg-primary",icon:"bi-folder2-open",label:"Open",help:"Claimed by an operator for investigation"},investigating:{badge:"bg-warning text-dark",icon:"bi-search",label:"Investigating",help:"Actively being investigated (human or LLM)"},paused:{badge:"bg-secondary",icon:"bi-pause-circle-fill",label:"Paused",help:"On hold — waiting for external input"},resolved:{badge:"bg-success",icon:"bi-check-circle-fill",label:"Resolved",help:"Root cause addressed, no further action needed"},closed:{badge:"bg-dark",icon:"bi-x-circle-fill",label:"Closed",help:"Closed — archived for record keeping"},ignored:{badge:"bg-secondary",icon:"bi-eye-slash-fill",label:"Ignored",help:"Noise — review periodically to tune rules"},pending:{badge:"bg-light text-dark border",icon:"bi-hourglass-split",label:"Pending",help:"Below trigger threshold — accumulating events"}};function A(e){return k[(e||"").toLowerCase()]||k.new}function S(e){const t=parseInt(e)||5;return t>=9?{color:"text-white",bg:"bg-danger",label:"Critical"}:t>=7?{color:"text-white",bg:"bg-danger",label:"High"}:t>=5?{color:"text-dark",bg:"bg-warning",label:"Medium"}:t>=3?{color:"text-white",bg:"bg-info",label:"Low"}:{color:"text-white",bg:"bg-secondary",label:"Info"}}function C(e){const t=(e||"").toLowerCase();return"resolved"===t||"closed"===t?{icon:"bi-check-circle-fill",color:"text-success"}:"open"===t||"investigating"===t?{icon:"bi-exclamation-triangle-fill",color:"text-danger"}:"new"===t?{icon:"bi-bell-fill",color:"text-info"}:"paused"===t||"ignored"===t?{icon:"bi-pause-circle-fill",color:"text-warning"}:{icon:"bi-shield-exclamation",color:"text-secondary"}}class GeoIPSummaryCard extends t.View{constructor(e={}){super({className:"geoip-summary-card",...e}),this.sourceIP=e.sourceIP,this.ipInfo=e.ipInfo||null,this.geoData=null,this.threatBadgeClass="bg-secondary",this.threatLevel="Unknown",this.isBlocked=!1,this.isWhitelisted=!1,this.blockedReason="",this.geoModel=null,this.template='\n {{#geoData}}\n <div class="card shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div class="d-flex align-items-center gap-3">\n <div class="text-primary">\n <i class="bi bi-globe-americas fs-3"></i>\n </div>\n <div>\n <div class="mb-1">\n <a role="button" class="fw-semibold font-monospace text-decoration-none" data-action="view-geoip">{{sourceIP}}</a>\n {{#isBlocked|bool}}\n <span class="badge bg-danger ms-2" data-bs-toggle="tooltip" title="{{blockedReason}}"><i class="bi bi-slash-circle me-1"></i>Blocked</span>\n {{/isBlocked|bool}}\n {{#isWhitelisted|bool}}\n <span class="badge bg-success ms-2"><i class="bi bi-check-circle me-1"></i>Whitelisted</span>\n {{/isWhitelisted|bool}}\n </div>\n <div class="text-muted small">\n {{geoData.city|default(\'Unknown\')}}, {{geoData.country_name|default(\'Unknown\')}}\n {{#geoData.country_code}}\n <span class="text-muted">({{geoData.country_code}})</span>\n {{/geoData.country_code}}\n </div>\n <div class="text-muted small">\n {{geoData.isp|default(\'Unknown ISP\')}}\n {{#geoData.asn}} · {{geoData.asn}}{{/geoData.asn}}\n {{#geoData.connection_type}} · {{geoData.connection_type}}{{/geoData.connection_type}}\n </div>\n </div>\n </div>\n <div class="text-end">\n <span class="badge {{threatBadgeClass}}">{{threatLevel}}</span>\n {{#geoData.risk_score}}\n <div class="text-muted small mt-1">Risk Score: {{geoData.risk_score}}</div>\n {{/geoData.risk_score}}\n <div class="d-flex gap-1 mt-1 justify-content-end">\n {{#geoData.is_tor|bool}}<span class="badge bg-danger-subtle text-danger" title="TOR Exit Node">TOR</span>{{/geoData.is_tor|bool}}\n {{#geoData.is_vpn|bool}}<span class="badge bg-warning-subtle text-warning" title="VPN Detected">VPN</span>{{/geoData.is_vpn|bool}}\n {{#geoData.is_proxy|bool}}<span class="badge bg-info-subtle text-info" title="Proxy">Proxy</span>{{/geoData.is_proxy|bool}}\n {{#geoData.is_datacenter|bool}}<span class="badge bg-secondary-subtle text-secondary" title="Datacenter IP">DC</span>{{/geoData.is_datacenter|bool}}\n {{#geoData.is_known_attacker|bool}}<span class="badge bg-danger" title="Known Attacker">Attacker</span>{{/geoData.is_known_attacker|bool}}\n {{#geoData.is_known_abuser|bool}}<span class="badge bg-danger-subtle text-danger" title="Known Abuser">Abuser</span>{{/geoData.is_known_abuser|bool}}\n </div>\n </div>\n </div>\n \x3c!-- Inline actions --\x3e\n <div class="mt-3 pt-2 border-top d-flex gap-2">\n {{^isBlocked|bool}}\n <button class="btn btn-outline-danger btn-sm" data-action="block-ip">\n <i class="bi bi-slash-circle me-1"></i>Block IP\n </button>\n {{/isBlocked|bool}}\n {{#isBlocked|bool}}\n <button class="btn btn-outline-success btn-sm" data-action="unblock-ip">\n <i class="bi bi-unlock me-1"></i>Unblock IP\n </button>\n {{/isBlocked|bool}}\n {{^isWhitelisted|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="whitelist-ip">\n <i class="bi bi-check-circle me-1"></i>Whitelist\n </button>\n {{/isWhitelisted|bool}}\n <button class="btn btn-outline-secondary btn-sm" data-action="view-geoip">\n <i class="bi bi-box-arrow-up-right me-1"></i>Full GeoIP Record\n </button>\n </div>\n </div>\n </div>\n {{/geoData}}\n {{^geoData}}\n <div class="card shadow-sm">\n <div class="card-body text-muted text-center py-3">\n <i class="bi bi-globe me-2"></i>No GeoIP data available for {{sourceIP}}\n </div>\n </div>\n {{/geoData}}\n '}async onInit(){if(this.sourceIP){if(this.ipInfo)this.geoData=this.ipInfo,this.geoModel=new a.GeoLocatedIP(this.ipInfo);else try{this.geoModel=await a.GeoLocatedIP.lookup(this.sourceIP),this.geoModel&&(this.geoData=this.geoModel.attributes)}catch(e){return}this.geoData&&(this.threatLevel=(this.geoData.threat_level||"unknown").toUpperCase(),this.threatBadgeClass=this._getThreatBadgeClass(this.geoData.threat_level),this.isBlocked=!!this.geoData.is_blocked,this.isWhitelisted=!!this.geoData.is_whitelisted,this.blockedReason=this.geoData.blocked_reason||"Blocked")}}_getThreatBadgeClass(e){const t=(e||"").toLowerCase();return"high"===t||"critical"===t?"bg-danger":"medium"===t?"bg-warning text-dark":"low"===t?"bg-success":"bg-secondary"}async onActionViewGeoip(){await GeoIPView.show(this.sourceIP)}async onActionBlockIp(){const e=await s.Dialog.showForm({title:`Block IP — ${this.sourceIP}`,icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Suspicious activity from incident"},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({block:{reason:e.reason,ttl:parseInt(e.ttl)}});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} blocked`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isBlocked=!0,this.blockedReason=e.reason,await this.render()):this.getApp().toast.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await s.Dialog.showForm({title:`Unblock IP — ${this.sourceIP}`,icon:"bi-unlock",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",placeholder:"e.g., False positive"}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({unblock:e.reason||"Unblocked from incident view"});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} unblocked`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isBlocked=!1,await this.render()):this.getApp().toast.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await s.Dialog.showForm({title:`Whitelist IP — ${this.sourceIP}`,icon:"bi-check-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,placeholder:"e.g., Known office IP"}]});if(!e)return!0;if(!this.geoModel)return!0;const t=await this.geoModel.save({whitelist:e.reason});return t.success||200===t.status?(this.getApp()?.toast?.success(`IP ${this.sourceIP} whitelisted`),await this.geoModel.fetch(),this.geoData=this.geoModel.attributes,this.isWhitelisted=!0,this.isBlocked=!1,await this.render()):this.getApp().toast.error("Failed to whitelist IP"),!0}}class QuickActionsBar extends t.View{constructor(e={}){super({className:"quick-actions-bar mb-3",...e}),this.incident=e.incident;const t=(this.incident.get("status")||"").toLowerCase();this.isActive=["new","open","investigating"].includes(t),this.isResolved=["resolved","closed"].includes(t),this.isProtected=!!this.incident.get("metadata")?.do_not_delete,this.template='\n <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">\n <div class="d-flex align-items-center gap-2">\n {{#isActive|bool}}\n <button class="btn btn-success btn-sm" data-action="quick-resolve" data-bs-toggle="tooltip" title="Mark this incident as resolved">\n <i class="bi bi-check-circle me-1"></i>Resolve\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-pause" data-bs-toggle="tooltip" title="Put on hold">\n <i class="bi bi-pause-circle"></i>\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-ignore" data-bs-toggle="tooltip" title="Mark as noise">\n <i class="bi bi-eye-slash"></i>\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-escalate" data-bs-toggle="tooltip" title="Escalate priority">\n <i class="bi bi-arrow-up-circle"></i>\n </button>\n {{/isActive|bool}}\n {{#isResolved|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="quick-reopen" data-bs-toggle="tooltip" title="Re-open for further investigation">\n <i class="bi bi-arrow-counterclockwise me-1"></i>Re-open\n </button>\n {{/isResolved|bool}}\n </div>\n <div class="d-flex align-items-center gap-2">\n {{#isProtected|bool}}\n <button class="btn btn-warning btn-sm" data-action="quick-unprotect" data-bs-toggle="tooltip" title="Remove deletion protection">\n <i class="bi bi-shield-fill-check me-1"></i>Protected\n </button>\n {{/isProtected|bool}}\n {{^isProtected|bool}}\n <button class="btn btn-outline-secondary btn-sm" data-action="quick-protect" data-bs-toggle="tooltip" title="Protect from auto-deletion">\n <i class="bi bi-shield me-1"></i>Protect\n </button>\n {{/isProtected|bool}}\n <button class="btn btn-outline-primary btn-sm" data-action="quick-create-ticket" data-bs-toggle="tooltip" title="Create a review ticket">\n <i class="bi bi-ticket-perforated me-1"></i>Ticket\n </button>\n <button class="btn btn-outline-dark btn-sm" data-action="quick-analyze-llm" data-bs-toggle="tooltip" title="LLM agent reviews events, merges related incidents, and proposes rules">\n <i class="bi bi-robot me-1"></i>LLM Analyze\n </button>\n <button class="btn btn-outline-primary btn-sm" data-action="quick-ask-ai" data-bs-toggle="tooltip" title="Chat with AI about this incident">\n <i class="bi bi-chat-dots me-1"></i>Ask AI\n </button>\n </div>\n </div>\n '}async onActionQuickResolve(){await this.incident.save({status:"resolved"}),this.getApp()?.toast?.success("Incident resolved"),this.emit("incident:updated")}async onActionQuickPause(){await this.incident.save({status:"paused"}),this.getApp()?.toast?.success("Incident paused"),this.emit("incident:updated")}async onActionQuickIgnore(){await this.incident.save({status:"ignored"}),this.getApp()?.toast?.success("Incident ignored"),this.emit("incident:updated")}async onActionQuickReopen(){await this.incident.save({status:"open"}),this.getApp()?.toast?.success("Incident re-opened"),this.emit("incident:updated")}async onActionQuickCreateTicket(){this.emit("create-ticket")}async onActionQuickEscalate(){const e=parseInt(this.incident.get("priority"))||5,t=Math.min(e+2,10);await this.incident.save({priority:t}),this.getApp()?.toast?.success(`Priority escalated to ${t}`),this.emit("incident:updated")}async onActionQuickAnalyzeLlm(){this.emit("analyze-llm")}async onActionQuickAskAi(){this.emit("ask-ai")}async onActionQuickProtect(){await this.incident.save({metadata:{do_not_delete:!0}}),this.getApp()?.toast?.success("Incident protected from deletion"),this.emit("incident:updated")}async onActionQuickUnprotect(){await this.incident.save({metadata:{do_not_delete:!1}}),this.getApp()?.toast?.success("Deletion protection removed"),this.emit("incident:updated")}}class LLMAnalysisResultsView extends t.View{constructor(e={}){super({className:"llm-analysis-results mb-3",...e}),this.analysis=e.analysis||{},this.incident=e.incident,this.summary=this.analysis.summary||"",this.summaryHtml="",this.hasProposedRule=!!this.analysis.proposed_ruleset_id,this.proposedRulesetId=this.analysis.proposed_ruleset_id,this.mergedCount=(this.analysis.merged_incidents||[]).length,this.mergedIds=(this.analysis.merged_incidents||[]).join(", "),this.template='\n <div class="card border-info shadow-sm">\n <div class="card-header bg-info bg-opacity-10 d-flex align-items-center justify-content-between">\n <div>\n <i class="bi bi-robot me-2 text-info"></i>\n <strong>LLM Analysis</strong>\n <span class="text-muted small ms-2">AI-generated triage</span>\n </div>\n <button class="btn btn-outline-info btn-sm" data-action="re-analyze" data-bs-toggle="tooltip" title="Run a fresh LLM analysis">\n <i class="bi bi-arrow-clockwise me-1"></i>Re-analyze\n </button>\n </div>\n <div class="card-body">\n {{#summary}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-chat-left-text me-1"></i>Summary</h6>\n <div class="bg-light rounded p-3 small llm-summary-content">{{{summaryHtml}}}</div>\n </div>\n {{/summary}}\n {{#mergedCount}}\n <div class="mb-3">\n <span class="badge bg-primary"><i class="bi bi-union me-1"></i>{{mergedCount}} incident(s) merged</span>\n <span class="text-muted small ms-2">IDs: {{mergedIds}}</span>\n </div>\n {{/mergedCount}}\n {{#hasProposedRule|bool}}\n <div class="d-flex align-items-center gap-2">\n <span class="badge bg-success"><i class="bi bi-gear me-1"></i>Proposed Rule</span>\n <button class="btn btn-outline-primary btn-sm" data-action="view-proposed-rule">\n <i class="bi bi-box-arrow-up-right me-1"></i>View Proposed RuleSet #{{proposedRulesetId}}\n </button>\n <span class="text-muted small">(created disabled — review before enabling)</span>\n </div>\n {{/hasProposedRule|bool}}\n </div>\n </div>\n '}async onBeforeRender(){this.summary&&!this.summaryHtml&&(this.summaryHtml=await async function(e,t){if(!t)return"";try{const s=e.getApp(),i=await s.rest.post("/api/docit/render",{markdown:t}),a=i?.data?.data?.html||i?.data?.html;if(a)return a}catch(i){}return`<pre style="white-space: pre-wrap;">${s=t,s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}</pre>`;var s}(this,this.summary))}async onActionReAnalyze(){this.emit("analyze-llm")}async onActionViewProposedRule(){if(this.proposedRulesetId)try{const e=new a.RuleSet({id:this.proposedRulesetId});await e.fetch();const t=new RuleSetView({model:e}),i=new s.Dialog({header:!1,size:"xl",body:t,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await i.render(!0,document.body),i.show()}catch(e){this.getApp()?.toast?.error("Could not load proposed RuleSet")}}}class IncidentOverviewSection extends t.View{constructor(e={}){super({className:"incident-overview-section p-3",template:'\n <div data-container="quick-actions" class="mb-3"></div>\n <div data-container="llm-analysis-results"></div>\n <div data-container="overview-data" class="mb-3"></div>\n <div data-container="geoip-summary"></div>\n {{#serverInfo}}\n <div class="text-muted small mt-2"><i class="bi bi-hdd-rack me-1"></i>{{serverInfo}}</div>\n {{/serverInfo}}\n ',...e})}async onInit(){this.quickActions=new QuickActionsBar({containerId:"quick-actions",incident:this.model}),this.quickActions.on("incident:updated",()=>this.emit("incident:updated")),this.quickActions.on("create-ticket",()=>this.emit("create-ticket")),this.quickActions.on("analyze-llm",()=>this.emit("analyze-llm")),this.quickActions.on("ask-ai",()=>this.emit("ask-ai")),this.addChild(this.quickActions),this._showLlmAnalysisIfAvailable();const e=A(this.model.get("status")),t=S(this.model.get("priority"));this.statusHelp=e.help,this.priorityHelp=t.label;const s=this.model.get("title")||"",i=this.model.get("details")||"",a=[{name:"status",label:"Status",formatter:"badge",cols:3},{name:"priority",label:"Priority",cols:3},{name:"category",label:"Category",formatter:"badge",cols:3},{name:"event_count",label:"Events",cols:3},{name:"scope",label:"Scope",cols:3},{name:"hostname",label:"Hostname",cols:3},{name:"created",label:"Created",formatter:"epoch|datetime",cols:3},{name:"modified",label:"Last Updated",formatter:"epoch|datetime",cols:3},{name:"title",label:"Title",cols:12}];i&&i!==s&&a.push({name:"details",label:"Details",cols:12}),this.model.get("model_name")&&(a.push({name:"model_name",label:"Related Model",cols:6}),a.push({name:"model_id",label:"Model ID",cols:6})),this.dataView=new r.default({containerId:"overview-data",model:this.model,columns:2,showEmptyValues:!1,fields:a}),this.addChild(this.dataView);const n=this.model.get("metadata")||{},o=[];n.server&&o.push(n.server),n.timezone&&o.push(n.timezone),this.serverInfo=o.length?o.join(" · "):null;const l=await this._resolveSourceIP();l&&(this.geoipCard=new GeoIPSummaryCard({containerId:"geoip-summary",sourceIP:l,ipInfo:this.model.get("ip_info")}),this.addChild(this.geoipCard))}_showLlmAnalysisIfAvailable(){const e=(this.model.get("metadata")||{}).llm_analysis;e&&!this.llmResultsView&&(this.llmResultsView=new LLMAnalysisResultsView({containerId:"llm-analysis-results",analysis:e,incident:this.model}),this.llmResultsView.on("analyze-llm",()=>this.emit("analyze-llm")),this.addChild(this.llmResultsView))}refreshAnalysis(){this.llmResultsView&&(this.removeChild(this.llmResultsView),this.llmResultsView=null),this._showLlmAnalysisIfAvailable(),this.render()}async _resolveSourceIP(){const e=this.model.get("metadata")||{};if(e.source_ip)return e.source_ip;if(e.ip)return e.ip;if(e.ip_address)return e.ip_address;try{const e=new a.IncidentEventList({params:{incident:this.model.get("id"),size:1,sort:"-created"}});await e.fetch();const t=e.first();if(t)return t.get("source_ip")||t.get("ip_address")||null}catch(t){}return null}}class HttpRequestSection extends t.View{constructor(e={}){super({className:"http-request-section p-3",template:'\n <h6 class="mb-3"><i class="bi bi-globe2 me-2"></i>HTTP Request Details</h6>\n <div data-container="http-data"></div>\n ',...e}),this.metadata=e.metadata||{}}async onInit(){const e=this.metadata,t={get:t=>e[t],attributes:e};this.dataView=new r.default({containerId:"http-data",model:t,columns:2,showEmptyValues:!1,fields:[{name:"http_method",label:"Method",formatter:"badge",cols:3},{name:"http_status",label:"Status Code",cols:3},{name:"http_host",label:"Host",cols:6},{name:"http_path",label:"Path",cols:12},{name:"http_url",label:"URL",cols:12},{name:"http_protocol",label:"Protocol",cols:6},{name:"http_query_string",label:"Query String",cols:6},{name:"http_user_agent",label:"User Agent",cols:12}]}),this.addChild(this.dataView)}}class IPIntelligenceSection extends t.View{constructor(e={}){super({className:"ip-intelligence-section p-3",template:'\n <h6 class="mb-3"><i class="bi bi-shield-lock me-2"></i>Network</h6>\n <div data-container="ip-network" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-exclamation-triangle me-2"></i>Threat Assessment</h6>\n <div data-container="ip-threat" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-flag me-2"></i>Threat Flags</h6>\n <div data-container="ip-flags" class="mb-4"></div>\n <h6 class="mb-3"><i class="bi bi-slash-circle me-2"></i>Block Status</h6>\n <div data-container="ip-block"></div>\n ',...e}),this.ipInfo=e.ipInfo||{}}async onInit(){const e=this.ipInfo,t={get:t=>e[t],attributes:e};this.networkView=new r.default({containerId:"ip-network",model:t,columns:2,showEmptyValues:!1,fields:[{name:"ip_address",label:"IP Address",cols:6},{name:"subnet",label:"Subnet",cols:6},{name:"isp",label:"ISP",cols:6},{name:"asn",label:"ASN",cols:3},{name:"asn_org",label:"ASN Org",cols:3},{name:"mobile_carrier",label:"Mobile Carrier",cols:6},{name:"connection_type",label:"Connection Type",cols:6}]}),this.addChild(this.networkView),this.threatView=new r.default({containerId:"ip-threat",model:t,columns:2,showEmptyValues:!1,fields:[{name:"threat_level",label:"Threat Level",formatter:"badge",cols:4},{name:"risk_score",label:"Risk Score",cols:4},{name:"is_threat",label:"Threat",formatter:"yesnoicon",cols:2},{name:"is_suspicious",label:"Suspicious",formatter:"yesnoicon",cols:2}]}),this.addChild(this.threatView),this.flagsView=new r.default({containerId:"ip-flags",model:t,columns:2,showEmptyValues:!1,fields:[{name:"is_tor",label:"TOR",formatter:"yesnoicon",cols:3},{name:"is_vpn",label:"VPN",formatter:"yesnoicon",cols:3},{name:"is_proxy",label:"Proxy",formatter:"yesnoicon",cols:3},{name:"is_datacenter",label:"Datacenter",formatter:"yesnoicon",cols:3},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:3},{name:"is_cloud",label:"Cloud",formatter:"yesnoicon",cols:3},{name:"is_known_attacker",label:"Known Attacker",formatter:"yesnoicon",cols:3},{name:"is_known_abuser",label:"Known Abuser",formatter:"yesnoicon",cols:3}]}),this.addChild(this.flagsView),this.blockView=new r.default({containerId:"ip-block",model:t,columns:2,showEmptyValues:!1,fields:[{name:"is_blocked",label:"Blocked",formatter:"yesnoicon",cols:3},{name:"block_count",label:"Block Count",cols:3},{name:"is_whitelisted",label:"Whitelisted",formatter:"yesnoicon",cols:3},{name:"blocked_reason",label:"Block Reason",cols:3},{name:"blocked_at",label:"Blocked At",formatter:"epoch|datetime",cols:6},{name:"blocked_until",label:"Blocked Until",formatter:"epoch|datetime",cols:6},{name:"whitelisted_reason",label:"Whitelist Reason",cols:12}]}),this.addChild(this.blockView)}}class RuleEngineSection extends t.View{constructor(e={}){super({className:"rule-engine-section p-3",...e}),this.incident=e.incident;const t=this.incident.get("rule_set");this.rulesetId=t&&"object"==typeof t?t.id:t,this.rulesetModel=null,this.hasRuleset=!1,this.autoDeleteEnabled=!1,this.incidentProtected=!!this.incident.get("metadata")?.do_not_delete,this.template='\n {{#hasRuleset|bool}}\n {{#autoDeleteEnabled|bool}}\n {{^incidentProtected|bool}}\n <div class="alert alert-warning d-flex align-items-center mb-3" role="alert">\n <i class="bi bi-exclamation-triangle-fill me-2"></i>\n <div>This incident will be <strong>permanently deleted</strong> when resolved or closed. Events and history will also be removed.</div>\n </div>\n {{/incidentProtected|bool}}\n {{#incidentProtected|bool}}\n <div class="alert alert-info d-flex align-items-center mb-3" role="alert">\n <i class="bi bi-shield-fill-check me-2"></i>\n <div>Auto-delete is enabled on this rule, but this incident is <strong>protected</strong> from deletion.</div>\n </div>\n {{/incidentProtected|bool}}\n {{/autoDeleteEnabled|bool}}\n <div class="mb-3">\n <div class="d-flex align-items-center justify-content-between mb-2">\n <h6 class="mb-0"><i class="bi bi-gear-wide-connected me-2"></i>Linked RuleSet</h6>\n <div class="d-flex gap-2">\n <button class="btn btn-outline-primary btn-sm" data-action="edit-linked-ruleset">\n <i class="bi bi-pencil me-1"></i>Edit RuleSet\n </button>\n <button class="btn btn-outline-secondary btn-sm" data-action="view-linked-ruleset">\n <i class="bi bi-box-arrow-up-right me-1"></i>View Full Details\n </button>\n <button class="btn btn-outline-success btn-sm" data-action="create-rule-from-incident">\n <i class="bi bi-plus-circle me-1"></i>Create New Rule\n </button>\n </div>\n </div>\n <div data-container="ruleset-data"></div>\n <div class="mt-3">\n <h6 class="mb-2"><i class="bi bi-funnel me-2"></i>Rule Conditions</h6>\n <div data-container="ruleset-rules"></div>\n </div>\n </div>\n {{/hasRuleset|bool}}\n {{^hasRuleset|bool}}\n <div class="text-center py-5">\n <div class="text-muted mb-3">\n <i class="bi bi-gear fs-1"></i>\n </div>\n <h6 class="text-muted">No RuleSet Linked</h6>\n <p class="text-muted small mb-3">\n This incident was not created by a rule engine match.<br>\n You can create a new rule based on this incident\'s event pattern to catch similar events automatically.\n </p>\n <button class="btn btn-primary" data-action="create-rule-from-incident">\n <i class="bi bi-plus-circle me-1"></i>Create Rule from Incident\n </button>\n </div>\n {{/hasRuleset|bool}}\n '}async onInit(){if(!this.rulesetId)return;try{this.rulesetModel=new a.RuleSet({id:this.rulesetId}),await this.rulesetModel.fetch(),this.hasRuleset=!0,this.autoDeleteEnabled=!!this.rulesetModel.get("metadata")?.delete_on_resolution}catch(l){return}const e=this.rulesetModel.get("match_by"),t=a.MatchByOptions.find(t=>t.value===e),s=this.rulesetModel.get("bundle_by"),i=a.BundleByOptions.find(e=>e.value===s);this.rulesetDataView=new r.default({containerId:"ruleset-data",model:this.rulesetModel,className:"border rounded p-3 bg-light",columns:2,fields:[{name:"name",label:"Name",cols:6},{name:"category",label:"Scope",formatter:"badge",cols:3},{name:"is_active",label:"Active",formatter:"yesnoicon",cols:3},{name:"priority",label:"Priority",cols:3},{name:"match_by",label:"Match Logic",template:t?t.label:String(e),cols:3},{name:"bundle_by",label:"Bundle By",template:i?i.label:String(s),cols:3},{name:"bundle_minutes",label:"Bundle Window",cols:3},{name:"trigger_count",label:"Trigger Count",cols:3},{name:"trigger_window",label:"Trigger Window (min)",cols:3},{name:"retrigger_every",label:"Re-trigger Every",cols:3},{name:"handler",label:"Handler",cols:12}]}),this.addChild(this.rulesetDataView);const o=new a.RuleList({params:{parent:this.rulesetId}});this.rulesTable=new n.TableView({containerId:"ruleset-rules",collection:o,hideActivePillNames:["parent"],columns:[{key:"name",label:"Name"},{key:"field_name",label:"Field"},{key:"comparator",label:"Comparator",width:"110px"},{key:"value",label:"Value"},{key:"value_type",label:"Type",width:"80px"}],showAdd:!1,size:10,paginated:!1}),this.addChild(this.rulesTable)}async onActionEditLinkedRuleset(){this.rulesetModel&&await s.Dialog.showModelForm({title:`Edit RuleSet — ${this.rulesetModel.get("name")}`,model:this.rulesetModel,formConfig:a.RuleSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("RuleSet updated"))}async onActionViewLinkedRuleset(){if(!this.rulesetModel)return;const e=new RuleSetView({model:this.rulesetModel}),t=new s.Dialog({header:!1,size:"xl",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await t.render(!0,document.body),t.show()}async onActionCreateRuleFromIncident(){const e=this.incident,t=e.get("category")||"",i=e.get("scope")||"",n=e.get("metadata")||{},o=await s.Dialog.showForm({title:"Create RuleSet from Incident",icon:"bi-gear-wide-connected",formConfig:a.RuleSetForms.create,size:"lg",data:{name:`Rule: ${t||"custom"} (from incident #${e.get("id")})`,category:i||t,priority:10,is_active:!1,bundle_by:n.source_ip?4:0,bundle_minutes:30,match_by:0}});if(!o)return;const l=new a.RuleSet,r=await l.save({...o,bundle_by:parseInt(o.bundle_by),bundle_minutes:parseInt(o.bundle_minutes)||30,match_by:parseInt(o.match_by)||0});if(!r.success&&200!==r.status)return void this.getApp()?.toast?.error("Failed to create RuleSet");await this.incident.save({rule_set:l.id}),this.rulesetId=l.id,this.rulesetModel=l,this.hasRuleset=!0;const d=await this._showMetadataRulePicker(l,n);this.getApp()?.toast?.success(d?`RuleSet created with ${d} rule condition(s)`:"RuleSet created — add rule conditions to activate");const c=new RuleSetView({model:l}),m=new s.Dialog({header:!1,size:"xl",body:c,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await m.render(!0,document.body),m.show()}async _showMetadataRulePicker(e,t){const i=/* @__PURE__ */new Set(["title","details","scope","category","source_ip","hostname","model_name","model_id","country_code","country_name","latitude","longitude","stack_trace","traceback","do_not_delete","delete_on_resolution"]),n=Object.entries(t).filter(([e,t])=>!i.has(e)&&null!==t&&""!==t&&"object"!=typeof t).map(([e,t])=>({key:e,value:t,type:x(t)}));if(!n.length)return 0;const o=[{type:"html",columns:12,html:'<div class="text-muted small mb-3">\n Select metadata fields to create as rule conditions.\n Each selected field becomes an <code>==</code> match rule.\n </div>'},...n.map(e=>{return{name:`rule__${e.key}`,type:"switch",label:`${e.key} = ${t=String(e.value),t.length>30?t.slice(0,30)+"…":t}`,tooltip:`${e.type}: ${String(e.value)}`,value:!1,columns:6};var t})],l=await s.Dialog.showForm({title:"Create Rules from Metadata",icon:"bi-list-check",size:"lg",fields:o,submitText:"Create Rules",cancelText:"Skip"});if(!l)return 0;const r=n.filter(e=>l[`rule__${e.key}`]);if(!r.length)return 0;const d=this.getApp();try{await Promise.all(r.map((t,s)=>(new a.Rule).save({parent:e.id,name:`Match ${t.key}`,field_name:t.key,comparator:"==",value:String(t.value),value_type:t.type,index:s})))}catch(c){d?.toast?.warning("Some rule conditions failed to save")}return r.length}}class IncidentTicketsSection extends t.View{constructor(e={}){super({className:"incident-tickets-section p-3",template:'\n <div class="d-flex align-items-center justify-content-between mb-3">\n <h6 class="mb-0"><i class="bi bi-ticket-perforated me-2"></i>Related Tickets</h6>\n <button class="btn btn-primary btn-sm" data-action="create-ticket">\n <i class="bi bi-plus-circle me-1"></i>Create Ticket\n </button>\n </div>\n <div data-container="tickets-table"></div>\n ',...e}),this.incident=e.incident}async onInit(){const e=new a.TicketList({params:{incident:this.incident.get("id"),sort:"-created"}});this.ticketsTable=new n.TableView({containerId:"tickets-table",collection:e,hideActivePillNames:["incident"],columns:[{key:"id",label:"ID",width:"60px",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime",sortable:!0,width:"160px"},{key:"status",label:"Status",formatter:"badge",width:"100px"},{key:"category",label:"Category",formatter:"badge",width:"120px"},{key:"priority",label:"Priority",width:"80px",sortable:!0},{key:"title",label:"Title"}],actions:["view"],showAdd:!1,paginated:!0,size:10,emptyMessage:"No tickets linked to this incident."}),this.addChild(this.ticketsTable)}async onActionCreateTicket(){const e=this.incident,t=`Incident #${e.get("id")}: ${e.get("category")||e.get("title")||"Investigation"}`,i={...a.TicketForms.create,fields:a.TicketForms.create.fields.map(s=>"title"===s.name?{...s,value:t}:"category"===s.name?{...s,value:"incident"}:"priority"===s.name?{...s,value:e.get("priority")||5}:"incident"===s.name?{...s,value:e.get("id"),type:"hidden"}:s)},n=await s.Dialog.showForm(i);if(!n)return;const o=new a.Ticket,l=await o.save({...n,incident:e.get("id")});l.success||200===l.status?(this.getApp()?.toast?.success("Ticket created"),this.ticketsTable?.collection?.fetch()):this.getApp()?.toast?.error("Failed to create ticket")}}class RelatedIncidentsSection extends t.View{constructor(e={}){super({className:"related-incidents-section p-3",template:'\n <div class="mb-3">\n <h6 class="mb-1"><i class="bi bi-diagram-2 me-2"></i>Related Incidents</h6>\n <p class="text-muted small mb-0">Incidents sharing the same source IP or category</p>\n </div>\n <div data-container="related-table"></div>\n ',...e}),this.incident=e.incident,this.sourceIP=e.sourceIP}async onInit(){const e={id__not:this.incident.get("id"),sort:"-created",size:10};if(this.sourceIP)e.source_ip=this.sourceIP;else{const t=this.incident.get("category");t&&(e.category=t)}const t=new a.IncidentList({params:e});this.relatedTable=new n.TableView({containerId:"related-table",collection:t,hideActivePillNames:["id__not","source_ip","category"],columns:[{key:"id",label:"ID",width:"60px",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime",sortable:!0,width:"160px"},{key:"status",label:"Status",formatter:"badge",width:"100px"},{key:"category",label:"Category",formatter:"badge"},{key:"priority",label:"Priority",width:"80px",sortable:!0},{key:"title",label:"Title",formatter:"truncate(60)|default('—')"}],actions:["view"],showAdd:!1,paginated:!0,size:10,emptyMessage:"No related incidents found."}),this.addChild(this.relatedTable)}}class IncidentView extends t.View{constructor(e={}){super({className:"incident-view",...e}),this.model=e.model||new a.Incident(e.data||{}),this.incidentIcon=C(this.model.get("status")),this.statusCfg=A(this.model.get("status")),this.priorityCfg=S(this.model.get("priority")),this.isProtected=!!this.model.get("metadata")?.do_not_delete,this.template='\n <div class="incident-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{incidentIcon.color}}">\n <i class="bi {{incidentIcon.icon}}"></i>\n </div>\n <div>\n <h4 class="mb-1">Incident #{{model.id}}</h4>\n {{#model.title}}\n <div class="text-muted mb-2">{{model.title|truncate(80)}}</div>\n {{/model.title}}\n <div class="d-flex align-items-center gap-2 flex-wrap">\n <span class="badge {{statusCfg.badge}}" data-bs-toggle="tooltip" title="{{statusCfg.help}}">\n <i class="bi {{statusCfg.icon}} me-1"></i>{{statusCfg.label}}\n </span>\n <span class="badge {{priorityCfg.bg}} {{priorityCfg.color}}" data-bs-toggle="tooltip" title="Priority {{model.priority}} — {{priorityCfg.label}} severity">\n P{{model.priority}} · {{priorityCfg.label}}\n </span>\n {{#model.category}}\n <span class="badge bg-light text-dark border">{{model.category}}</span>\n {{/model.category}}\n {{#isProtected|bool}}\n <span class="badge bg-warning text-dark"><i class="bi bi-shield-fill-check me-1"></i>Protected</span>\n {{/isProtected|bool}}\n </div>\n <div class="text-muted small mt-1">\n {{model.created|datetime}}\n {{#model.scope}} · {{model.scope}}{{/model.scope}}\n {{#model.hostname}} · {{model.hostname}}{{/model.hostname}}\n {{#model.event_count}} · {{model.event_count}} events{{/model.event_count}}\n </div>\n </div>\n </div>\n <div data-container="incident-context-menu"></div>\n </div>\n\n \x3c!-- SideNav --\x3e\n <div data-container="incident-sidenav"></div>\n </div>\n '}async onInit(){this._sourceIP=await this._resolveSourceIP(),this.getApp().showLoading(),await this.model.fetch({params:{graph:"detailed"}}),this.getApp().hideLoading(),this.overviewSection=new IncidentOverviewSection({model:this.model});const e=this.overviewSection;e.on("incident:updated",()=>this._handleIncidentUpdated()),e.on("create-ticket",()=>this._handleCreateTicket()),e.on("analyze-llm",()=>this._handleAnalyzeLlm()),e.on("ask-ai",()=>this._handleAskAi());const t=new a.IncidentEventList({params:{incident:this.model.get("id")}}),s=new n.TableView({collection:t,hideActivePillNames:["incident"],columns:[{key:"id",label:"ID",width:"50px",sortable:!0},{key:"created",label:"Date / Category",sortable:!0,width:"160px",template:'<div>{{{model.created|epoch|datetime}}}</div><div class="text-muted small">{{{model.category|badge}}}</div>'},{key:"source_ip",label:"Source",sortable:!0,width:"130px",template:'<div>{{model.hostname}}</div><div class="text-muted small">{{model.source_ip}}</div>',filter:{type:"text"}},{key:"title",label:"Title",sortable:!0,formatter:"truncate(80)|default('—')"},{key:"level",label:"Level",sortable:!0,width:"60px",filter:{type:"text"}}],showAdd:!1,actions:["view"],paginated:!0,size:10}),i=new RuleEngineSection({incident:this.model}),o=new IncidentTicketsSection({incident:this.model}),l=new IncidentHistoryAdapter(this.model.get("id")),r=[{key:"Overview",label:"Overview",icon:"bi-shield-exclamation",view:e},{key:"Events",label:"Events",icon:"bi-list-ul",view:s},{key:"Rule Engine",label:"Rule Engine",icon:"bi-gear-wide-connected",view:i},{key:"Tickets",label:"Tickets",icon:"bi-ticket-perforated",view:o},{key:"History",label:"History",icon:"bi-chat-left-text",view:new a.ChatView({adapter:l})},{type:"divider",label:"Investigation"},{key:"Related Incidents",label:"Related",icon:"bi-diagram-2",view:new RelatedIncidentsSection({incident:this.model,sourceIP:this._sourceIP})}],d=this.model.get("metadata")||{},c=this.model.get("ip_info");if(d.http_method||d.http_path){const e=new HttpRequestSection({metadata:d});r.push({key:"HTTP Request",label:"HTTP Request",icon:"bi-globe2",view:e})}if(c){const e=new IPIntelligenceSection({ipInfo:c});r.push({key:"IP Intelligence",label:"IP Intel",icon:"bi-shield-lock",view:e})}const m=d.stack_trace||d.traceback,u=!!m,b=Object.keys(d).length>0;if((u||b)&&r.push({type:"divider",label:"Forensics"}),u){const e=new StackTraceView({stackTrace:m});r.push({key:"Stack Trace",label:"Stack Trace",icon:"bi-code-square",view:e})}if(b){const e=this._buildMetadataSection(d);r.push({key:"Metadata",label:"Metadata",icon:"bi-braces",view:e})}this.sideNav=new SideNavView({containerId:"incident-sidenav",sections:r,activeSection:"Overview",navWidth:180,contentPadding:"1.25rem 2rem",enableResponsive:!0,minWidth:500}),this.addChild(this.sideNav),this._buildContextMenu()}_buildMetadataSection(e){const s=[],i=["source_ip","hostname","user_agent","http_url","http_method","http_status","country_code","region","city","request_path","user","component","component_id","error_class","error_message","rule_id","risk_score","action","trigger"];for(const t of i)void 0!==e[t]&&null!==e[t]&&s.push({name:t,label:t.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),cols:6});return s.length>0?new t.View({model:this.model,metadata:e,hasStructuredFields:s.length>0,template:'\n <div class="p-3">\n {{#hasStructuredFields|bool}}\n <h6 class="mb-3"><i class="bi bi-list-check me-2"></i>Key Fields</h6>\n <div data-container="structured-metadata" class="mb-4"></div>\n {{/hasStructuredFields|bool}}\n <h6 class="mb-2"><i class="bi bi-braces me-2"></i>Raw Metadata</h6>\n <pre class="bg-light p-3 border rounded small"><code>{{{model.metadata|json}}}</code></pre>\n </div>\n ',onInit(){if(s.length>0){const t={get:t=>e[t],attributes:e};this.structuredView=new r.default({containerId:"structured-metadata",model:t,columns:2,showEmptyValues:!1,fields:s}),this.addChild(this.structuredView)}}}):new t.View({model:this.model,template:'<pre class="bg-light p-3 border rounded small"><code>{{{model.metadata|json}}}</code></pre>'})}_buildContextMenu(){const t=(this.model.get("status")||"").toLowerCase(),s=[];s.push({label:"Change Status",icon:"bi-arrow-repeat",header:!0}),"open"!==t&&s.push({label:"Open",action:"set-status-open",icon:"bi-folder2-open"}),"investigating"!==t&&s.push({label:"Investigate",action:"set-status-investigating",icon:"bi-search"}),"paused"!==t&&s.push({label:"Pause",action:"set-status-paused",icon:"bi-pause-circle"}),"resolved"!==t&&s.push({label:"Resolve",action:"set-status-resolved",icon:"bi-check-circle"}),"ignored"!==t&&s.push({label:"Ignore",action:"set-status-ignored",icon:"bi-eye-slash"}),s.push({type:"divider"}),s.push({label:"Edit Incident",action:"edit-incident",icon:"bi-pencil"}),s.push({label:"Change Priority",action:"change-priority",icon:"bi-arrow-up-circle"}),this.model.get("metadata")?.do_not_delete?s.push({label:"Remove Protection",action:"remove-protection",icon:"bi-shield"}):s.push({label:"Protect from Deletion",action:"protect-incident",icon:"bi-shield-fill-check"}),s.push({type:"divider"}),this._sourceIP&&(s.push({label:`Block IP (${this._sourceIP})`,action:"block-source-ip",icon:"bi-slash-circle",class:"text-danger"}),s.push({label:`View GeoIP (${this._sourceIP})`,action:"view-source-geoip",icon:"bi-globe"})),s.push({label:"Create Ticket",action:"create-ticket",icon:"bi-ticket-perforated"}),s.push({label:"Merge Incidents",action:"merge-incidents",icon:"bi-union"}),s.push({type:"divider"}),s.push({label:"Ask AI",action:"ask-ai",icon:"bi-chat-dots"}),s.push({label:"LLM Analyze",action:"analyze-llm",icon:"bi-robot"}),s.push({type:"divider"}),s.push({label:"Delete Incident",action:"delete-incident",icon:"bi-trash",danger:!0});const i=new e.ContextMenu({containerId:"incident-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:s}});this.addChild(i)}async onAfterRender(){await super.onAfterRender(),window.bootstrap&&window.bootstrap.Tooltip&&this.element&&this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(e=>{const t=window.bootstrap.Tooltip.getInstance(e);t&&"function"==typeof t.dispose&&t.dispose(),new window.bootstrap.Tooltip(e)})}async _resolveSourceIP(){const e=this.model.get("metadata")||{};if(e.source_ip)return e.source_ip;if(e.ip)return e.ip;if(e.ip_address)return e.ip_address;try{const e=new a.IncidentEventList({params:{incident:this.model.get("id"),size:1,sort:"-created"}});await e.fetch();const t=e.first();if(t)return t.get("source_ip")||t.get("ip_address")||null}catch(t){}return null}async _setStatus(e){await this.model.save({status:e}),this.getApp()?.toast?.success(`Status changed to ${e}`),this._handleIncidentUpdated()}async onActionSetStatusOpen(){await this._setStatus("open")}async onActionSetStatusInvestigating(){await this._setStatus("investigating")}async onActionSetStatusPaused(){await this._setStatus("paused")}async onActionSetStatusResolved(){await this._setStatus("resolved")}async onActionSetStatusIgnored(){await this._setStatus("ignored")}async onActionProtectIncident(){await this.model.save({metadata:{do_not_delete:!0}}),this.getApp()?.toast?.success("Incident protected from deletion"),this._handleIncidentUpdated()}async onActionRemoveProtection(){await this.model.save({metadata:{do_not_delete:!1}}),this.getApp()?.toast?.success("Deletion protection removed"),this._handleIncidentUpdated()}async onActionChangePriority(){const e=await s.Dialog.showForm({title:"Change Priority",icon:"bi-arrow-up-circle",size:"sm",fields:[{name:"priority",type:"select",label:"Priority",value:this.model.get("priority")||5,options:[{value:10,label:"10 — Critical"},{value:9,label:"9 — Critical"},{value:8,label:"8 — High"},{value:7,label:"7 — High"},{value:6,label:"6 — Medium"},{value:5,label:"5 — Medium"},{value:4,label:"4 — Low"},{value:3,label:"3 — Low"},{value:2,label:"2 — Info"},{value:1,label:"1 — Info"}]}]});e&&(await this.model.save({priority:parseInt(e.priority)}),this.getApp()?.toast?.success(`Priority changed to ${e.priority}`),this._handleIncidentUpdated())}async onActionEditIncident(){await s.Dialog.showModelForm({title:`Edit Incident #${this.model.id}`,model:this.model,formConfig:a.IncidentForms.edit})&&this._handleIncidentUpdated()}async onActionBlockSourceIp(){if(!this._sourceIP)return;const e=await a.GeoLocatedIP.lookup(this._sourceIP);if(!e)return void this.getApp()?.toast?.error("Could not find GeoIP record for this IP");const t=await s.Dialog.showForm({title:`Block IP — ${this._sourceIP}`,icon:"bi-slash-circle",size:"sm",fields:[{name:"reason",type:"text",label:"Reason",required:!0,value:`Blocked from incident #${this.model.id}`},{name:"ttl",type:"select",label:"Duration",options:[{value:3600,label:"1 hour"},{value:21600,label:"6 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"},{value:2592e3,label:"30 days"},{value:0,label:"Permanent"}],value:86400}]});if(!t)return;const i=await e.save({block:{reason:t.reason,ttl:parseInt(t.ttl)}});i.success||200===i.status?this.getApp()?.toast?.success(`IP ${this._sourceIP} blocked fleet-wide`):this.getApp()?.toast?.error("Failed to block IP")}async onActionViewSourceGeoip(){this._sourceIP&&await GeoIPView.show(this._sourceIP)}async onActionCreateTicket(){this._handleCreateTicket()}async _handleCreateTicket(){const e=`Incident #${this.model.get("id")}: ${this.model.get("category")||this.model.get("title")||"Investigation"}`,t=await s.Dialog.showForm({...a.TicketForms.create,fields:a.TicketForms.create.fields.map(t=>"title"===t.name?{...t,value:e}:"category"===t.name?{...t,value:"incident"}:"priority"===t.name?{...t,value:this.model.get("priority")||5}:"incident"===t.name?{...t,value:this.model.get("id"),type:"hidden"}:t)});if(!t)return;const i=new a.Ticket,n=await i.save({...t,incident:this.model.get("id")});n.success||200===n.status?this.getApp()?.toast?.success("Ticket created"):this.getApp()?.toast?.error("Failed to create ticket")}async onActionMergeIncidents(){const e=await s.Dialog.showForm({title:"Merge Incidents",icon:"bi-union",size:"sm",fields:[{name:"merge_ids",type:"text",label:"Incident IDs to merge into this one",required:!0,placeholder:"e.g., 102, 105, 108",help:"Comma-separated IDs. Events from those incidents will be merged here."}]});if(!e)return;const t=e.merge_ids.split(",").map(e=>parseInt(e.trim())).filter(e=>e&&e!==this.model.id);if(0===t.length)return;const i=await this.model.save({merge:t});i.success||200===i.status?(this.getApp()?.toast?.success(`Merged ${t.length} incident(s)`),this._handleIncidentUpdated()):this.getApp()?.toast?.error("Merge failed")}async onActionAskAi(){await this._handleAskAi()}async _handleAskAi(){await _(this,"incident.Incident")}async onActionAnalyzeLlm(){await this._handleAnalyzeLlm()}async _handleAnalyzeLlm(){if(!(await s.Dialog.confirm('Run LLM analysis on this incident? The AI agent will review all events, attempt to merge related incidents, and propose a new rule to catch similar patterns. The incident status will be set to "investigating".',"LLM Analysis",{confirmText:"Analyze",confirmClass:"btn-info"})))return;const e=this.getApp();e?.showLoading("Starting LLM analysis...");try{const t=await this.model.save({analyze:1});if(!t.success&&200!==t.status){const s=t.data?.error||t.error||"Failed to start analysis";return void e?.toast?.error(s)}e?.toast?.success("LLM analysis started — polling for results in the background..."),this._pollAnalysisProgress()}catch(t){e?.toast?.error(`Analysis failed: ${t.message}`)}finally{e?.hideLoading()}}_pollAnalysisProgress(){let e=0;const t=()=>{e++,e>60?this.getApp()?.toast?.error("Analysis is taking longer than expected. Check back later."):setTimeout(()=>{this.model.fetch().then(()=>{const e=this.model.get("metadata")||{};if(!e.analysis_in_progress)return e.llm_analysis?(this.getApp()?.toast?.success("LLM analysis complete"),this.overviewSection?.refreshAnalysis()):this.getApp()?.toast?.success("Analysis finished"),void this._handleIncidentUpdated();t()}).catch(()=>{t()})},5e3)};t()}async onActionDeleteIncident(){await s.Dialog.confirm(`Are you sure you want to delete incident #${this.model.id}? This action cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("incident:deleted",{model:this.model})}_handleIncidentUpdated(){this.incidentIcon=C(this.model.get("status")),this.statusCfg=A(this.model.get("status")),this.priorityCfg=S(this.model.get("priority")),this.render(),this.emit("incident:updated",{model:this.model})}}a.Incident.VIEW_CLASS=IncidentView;class IncidentTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_incidents",pageName:"Manage Incidents",router:"admin/incidents",Collection:a.IncidentList,formCreate:a.IncidentForms.create,formEdit:a.IncidentForms.edit,itemViewClass:IncidentView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-id",status:"new"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"status",label:"Status",filter:{type:"multiselect",options:["new","open","paused","resolved","qa","ignored"]}},{key:"created",label:"Created",formatter:"epoch|datetime",filter:{type:"daterange"}},{key:"scope",label:"Scope",sortable:!0,filter:{type:"text"}},{key:"category",label:"Category",sortable:!0,filter:{type:"text"}},{key:"priority",label:"Priority",filter:{type:"text"}},{key:"title",label:"title",formatter:"truncate(100)|default('No description')"}],filters:[{key:"category__not",label:"Not Category",filter:{type:"text"}},{key:"priority__gt",label:"Priority Greater Than",filter:{type:"number"}},{key:"priority__lt",label:"Priority Less Than",filter:{type:"number"}},{key:"metadata__rule_id",label:"Rule ID",filter:{type:"text"}},{key:"metadata__key",label:"Metadata Key",filter:{type:"text"}}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No incidents found. Click "Add Incident" to create your first incident.',batchBarLocation:"top",batchActions:[{label:"Open",icon:"bi bi-folder2-open",action:"open"},{label:"Resolve",icon:"bi bi-check-circle",action:"resolve"},{label:"Pause",icon:"bi bi-pause-circle",action:"pause"},{label:"Ignore",icon:"bi bi-x-circle",action:"ignore"},{label:"Merge",icon:"bi bi-merge",action:"merge"},{label:"Protect",icon:"bi bi-shield-fill-check",action:"protect"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchResolve(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to close ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"resolved"}))),this.tableView.collection.fetch())}async onActionBatchOpen(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to open ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"open"}))),this.tableView.collection.fetch())}async onActionBatchPause(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to pause ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"paused"}))),this.tableView.collection.fetch())}async onActionBatchIgnore(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Are you sure you want to ignore ${s.length} incidents?`)&&(await Promise.all(s.map(e=>e.model.save({status:"ignored"}))),this.tableView.collection.fetch())}async onActionBatchMerge(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp(),a=await i.showForm({title:`Merge ${s.length} incidents`,fields:[{name:"merge",type:"select",label:"Select Parent Incident",options:s.map(e=>({value:e.model.id,label:e.model.id})),required:!0}]});if(!a)return;const n=s.find(e=>e.model.id==a.merge)?.model;if(!n)return;const o=s.map(e=>e.model.id).filter(e=>e!=a.merge);await n.save({merge:o}),this.tableView.collection.fetch()}async onActionBatchProtect(e,t){const s=this.tableView.getSelectedItems();if(!s.length)return;const i=this.getApp();await i.confirm(`Protect ${s.length} incident(s) from deletion?`)&&(await Promise.all(s.map(e=>e.model.save({metadata:{do_not_delete:!0}}))),i.toast?.success(`${s.length} incident(s) protected`),this.tableView.collection.fetch())}}const I={user:s.User,userdevice:s.UserDevice,userdevicelocation:s.UserDeviceLocation,geolocatedip:a.GeoLocatedIP,member:n.Member,incident:a.Incident,incidentevent:a.IncidentEvent,ticket:a.Ticket,job:a.Job,log:n.Log,apikey:ApiKey};class EventView extends t.View{constructor(e={}){super({className:"event-view",...e}),this.model=e.model||new a.IncidentEvent(e.data||{}),this.eventIcon=this.getIconForEvent(this.model.get("level")),this.template='\n <div class="event-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{eventIcon.color}}">\n <i class="bi {{eventIcon.icon}}"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.title|default(\'System Event\')}}</h3>\n <div class="text-muted small">\n Category: {{model.category|capitalize}}\n </div>\n <div class="text-muted small mt-1">\n {{model.created|datetime}} from {{model.source_ip|default(\'Unknown IP\')}}\n </div>\n </div>\n </div>\n <div data-container="event-context-menu"></div>\n </div>\n\n \x3c!-- Body --\x3e\n <div data-container="event-tabs"></div>\n </div>\n '}getIconForEvent(e){return e>=40?{icon:"bi-exclamation-octagon-fill",color:"text-danger"}:e>=30?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:e>=20?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-bell-fill",color:"text-secondary"}}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",columns:2,fields:[{name:"id",label:"Event ID"},{name:"level",label:"Level"},{name:"hostname",label:"Hostname"},{name:"incident",label:"Incident ID"},{name:"model_name",label:"Related Model"},{name:"model_id",label:"Related Model ID"},{name:"details",label:"Details",columns:12}]});const s={Overview:this.overviewView},i=this.model.get("metadata")||{};i.stack_trace&&(this.stackTraceView=new StackTraceView({stackTrace:i.stack_trace}),s["Stack Trace"]=this.stackTraceView),Object.keys(i).length>0&&(this.metadataView=new t.View({model:this.model,template:'<pre class="bg-light p-3 border rounded"><code>{{{model.metadata|json}}}</code></pre>'}),s.Metadata=this.metadataView),this.tabView=new a.TabView({containerId:"event-tabs",tabs:s,activeTab:"Overview"}),this.addChild(this.tabView);const n=[{label:"View Incident",action:"view-incident",icon:"bi-shield-exclamation",disabled:!this.model.get("incident")},{label:"View Related Model",action:"view-model",icon:"bi-box-arrow-up-right",disabled:!this.model.get("model_id")},{type:"divider"},{label:"Delete Event",action:"delete-event",icon:"bi-trash",danger:!0}],o=new e.ContextMenu({containerId:"event-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:n}});this.addChild(o)}async onActionViewIncident(){const e=this.model.get("incident")||this.model.get("incident_id");if(!e)return this.getApp()?.toast?.warning("No incident linked to this event"),!0;const t=new a.Incident({id:e}),i=new IncidentView({model:t});await s.Dialog.showDialog({title:"Incident Details",body:i,size:"xl",scrollable:!0,header:!1,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]})}async onActionViewModel(){const e=this.model.get("model_name")||this.model.get("model_class"),t=this.model.get("model_id")||this.model.get("object_id");if(!e||!t)return this.getApp()?.toast?.warning("No related model linked to this event"),!0;const s=e.toLowerCase().replace(/[^a-z]/g,""),i=I[s];return i?i.VIEW_CLASS?void(await l.default.showModelById(i,t)):(this.getApp()?.toast?.warning(`No detail view available for ${e}`),!0):(this.getApp()?.toast?.warning(`Unknown model type: ${e}`),!0)}async onActionDeleteEvent(){await s.Dialog.confirm("Are you sure you want to delete this event? This action cannot be undone.","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("event:deleted",{model:this.model})}}a.IncidentEvent.VIEW_CLASS=EventView;class EventTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_events",pageName:"System Events",router:"admin/events",Collection:a.IncidentEventList,formEdit:a.IncidentEventForms.edit,itemViewClass:EventView,viewDialogOptions:{header:!1,size:"lg"},defaultQuery:{sort:"-id",category__not:"ossec"},columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"datetime",filter:{type:"daterange"}},{key:"level",label:"Level",sortable:!0,formatter:"badge",filter:{type:"select",options:[{value:"5",label:"Critical"},{value:"4",label:"Warning"},{value:"3",label:"Info"},{value:"2",label:"Debug"},{value:"1",label:"Trace"}]}},{key:"scope",label:"Scope",sortable:!0,formatter:"badge",filter:{type:"combobox",options:[{value:"account",label:"Account"},{value:"incident",label:"Incident"},{value:"ossec",label:"OSSEC"},{value:"fileman",label:"File Manager"},{value:"metrics",label:"Metrics"},{value:"jobs",label:"Jobs"},{value:"aws",label:"AWS"}]}},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"combobox",options:[{value:"rest_error",label:"Rest Error"},{value:"api_error",label:"API Error"},{value:"auth",label:"Auth"},{value:"database",label:"Database"}]}},{key:"title",label:"Title",sortable:!0,formatter:"truncate(50)"},{key:"source_ip",label:"Source IP",sortable:!0,filter:{type:"text"}},{key:"metadata.server",label:"Server",sortable:!0,filter:{type:"text"}}],filters:[{key:"category__not",label:"Not Category",filter:{type:"text"}},{key:"metadata__http_url__icontains",label:"URL Contains",filter:{type:"text"}},{key:"metadata__http_path__icontains",label:"Path Contains",filter:{type:"text"}},{key:"metadata__http_query_string__icontains",label:"Query String Contains",filter:{type:"text"}},{key:"metadata__rule_id",label:"Rule ID",filter:{type:"text"}},{key:"metadata__country_code",label:"Country",filter:{type:"text"}},{key:"metadata__region",label:"Region",filter:{type:"text"}},{key:"metadata__city__icontains",label:"City",filter:{type:"text"}},{key:"metadata__http_status",label:"HTTP Status",filter:{type:"text"}},{key:"model_name",label:"Model Name",filter:{type:"text"}},{key:"model_id",label:"Model ID",filter:{type:"text"}},{key:"metadata__user_email",label:"User Email",filter:{type:"text"}},{key:"metadata__http_user_agent__icontains",label:"User Agent Contains",filter:{type:"text"}}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No events found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class TicketNoteAdapter{constructor(e){this.ticketId=e,this.collection=new a.TicketNoteList({params:{parent:this.ticketId,sort:"created",size:100}})}async fetch(){await this.collection.fetch();const e=this.collection.models.map(e=>this.transform(e));return await Promise.all(e.map(async e=>{e.content&&(e.content=await this._renderMarkdown(e.content))})),e}transform(e){return{id:e.get("id"),type:e.get("user")?"user_comment":"system_event",author:{id:e.get("user.id"),name:e.get("user.display_name")||"System",avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]}}async addNote(e){const t=new a.TicketNote,s=await t.save({parent:this.ticketId,note:e.text,media:e.files&&e.files.length>0?e.files[0].id:null});return s.success&&await this.collection.fetch(),s}async _renderMarkdown(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}}const T={new:{badge:"bg-info",icon:"bi-bell-fill"},open:{badge:"bg-primary",icon:"bi-folder2-open"},in_progress:{badge:"bg-warning text-dark",icon:"bi-gear-fill"},pending:{badge:"bg-secondary",icon:"bi-pause-circle-fill"},resolved:{badge:"bg-success",icon:"bi-check-circle-fill"},qa:{badge:"bg-purple",icon:"bi-clipboard-check"},closed:{badge:"bg-dark",icon:"bi-x-circle-fill"},ignored:{badge:"bg-secondary",icon:"bi-eye-slash-fill"}};function P(e){const t=T[e]||{badge:"bg-secondary",icon:"bi-circle"};return`<span class="badge ${t.badge}"><i class="${t.icon} me-1"></i>${(e||"").replace("_"," ")}</span>`}class TicketDescriptionSection extends t.View{constructor(e={}){super({className:"ticket-description-section",...e}),this.descriptionHtml="",this.template='\n <div class="card mb-3">\n <div class="card-body">\n <div class="ticket-description-content">{{{descriptionHtml}}}</div>\n </div>\n </div>\n '}async onBeforeRender(){const e=this.model.get("description")||"";this.descriptionHtml=await async function(e){if(!e)return"";try{const s=await t.rest.post("/api/docit/render",{markdown:e}),i=s?.data?.data?.html||s?.data?.html;if(i)return i}catch(i){}const s=document.createElement("div");return s.textContent=e,`<pre style="white-space: pre-wrap;">${s.innerHTML}</pre>`}(e)}}class LinkedIncidentCard extends t.View{constructor(e={}){super({className:"linked-incident-card",...e}),this.incident=e.incident||{},this.incidentTitle=this.incident.title||"Untitled",this.incidentId=this.incident.id,this.incidentStatus=this.incident.status||"unknown",this.incidentPriority=this.incident.priority,this.incidentCategory=this.incident.category||"",this.incidentScope=this.incident.scope||"",this.template='\n <div class="card border-start border-3 border-warning mb-3">\n <div class="card-body py-2 px-3">\n <div class="d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center gap-2 flex-grow-1" style="min-width: 0;">\n <i class="bi bi-exclamation-triangle-fill text-warning flex-shrink-0"></i>\n <div style="min-width: 0;">\n <div class="fw-semibold text-truncate">Incident #{{incidentId}}: {{incidentTitle}}</div>\n <div class="text-muted small">\n {{{statusBadge}}}\n <span class="ms-2">Priority: {{incidentPriority}}</span>\n {{#incidentCategory}}\n <span class="ms-2">{{{incidentCategory|badge}}}</span>\n {{/incidentCategory}}\n </div>\n </div>\n </div>\n <button class="btn btn-outline-primary btn-sm flex-shrink-0 ms-2" data-action="open-incident">\n <i class="bi bi-box-arrow-up-right me-1"></i>Open\n </button>\n </div>\n </div>\n </div>\n '}async onBeforeRender(){this.statusBadge=P(this.incidentStatus)}async onActionOpenIncident(){if(this.incidentId)try{const e=new a.Incident({id:this.incidentId});await e.fetch({params:{graph:"detailed"}});const t=new IncidentView({model:e}),i=new s.Dialog({header:!1,size:"xl",body:t,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});await i.render(!0,document.body),i.show()}catch(e){this.getApp()?.toast?.error("Failed to load incident")}}}class TicketView extends t.View{constructor(e={}){super({className:"ticket-view",...e}),this.model=e.model||new a.Ticket(e.data||{}),this.template='\n <div class="ticket-view-container d-flex flex-column h-100">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3 flex-shrink-0">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n {{{statusBadge}}}\n {{#model.category}}\n <span class="badge bg-secondary">{{model.category}}</span>\n {{/model.category}}\n <span class="text-muted small">Ticket #{{model.id}}</span>\n </div>\n <h4 class="mb-1">{{model.title}}</h4>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-flag-fill me-1"></i>Priority {{model.priority}}</span>\n {{#assigneeName}}\n <span><i class="bi bi-person-fill me-1"></i>{{assigneeName}}</span>\n {{/assigneeName}}\n {{#model.created}}\n <span><i class="bi bi-clock me-1"></i>{{model.created|relative}}</span>\n {{/model.created}}\n {{#model.modified}}\n <span><i class="bi bi-pencil me-1"></i>{{model.modified|relative}}</span>\n {{/model.modified}}\n </div>\n </div>\n <div class="d-flex align-items-center gap-2 flex-shrink-0">\n <button class="btn btn-outline-primary btn-sm" data-action="ask-ai" data-bs-toggle="tooltip" title="Chat with AI about this ticket">\n <i class="bi bi-robot me-1"></i>Ask AI\n </button>\n <div data-container="ticket-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Linked Incident --\x3e\n <div data-container="linked-incident"></div>\n\n \x3c!-- Description (collapsible) --\x3e\n {{#hasDescription|bool}}\n <div class="mb-3">\n <a class="text-muted small d-inline-flex align-items-center gap-1" data-bs-toggle="collapse" href="#ticket-desc-{{model.id}}" role="button" aria-expanded="false">\n <i class="bi bi-chevron-right"></i>\n <i class="bi bi-file-text me-1"></i>Description\n </a>\n <div class="collapse" id="ticket-desc-{{model.id}}">\n <div data-container="ticket-description" class="mt-2"></div>\n </div>\n </div>\n {{/hasDescription|bool}}\n\n \x3c!-- Chat / Notes --\x3e\n <div class="d-flex align-items-center justify-content-between mb-2">\n <h6 class="mb-0 text-muted"><i class="bi bi-chat-left-text me-1"></i>Notes</h6>\n <button class="btn btn-outline-secondary btn-sm" data-action="refresh-notes">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n <div class="flex-grow-1" style="min-height: 0;" data-container="chat-view"></div>\n </div>\n '}async onBeforeRender(){this.statusBadge=P(this.model.get("status"));const e=this.model.get("assignee");this.assigneeName=e?.display_name||("string"==typeof e?e:null),this.hasDescription=!!this.model.get("description")}async onInit(){const t=this.model.get("incident");t&&"object"==typeof t&&t.id&&(this.linkedIncident=new LinkedIncidentCard({containerId:"linked-incident",incident:t}),this.addChild(this.linkedIncident)),this.model.get("description")&&(this.descriptionView=new TicketDescriptionSection({containerId:"ticket-description",model:this.model}),this.addChild(this.descriptionView)),this.adapter=new TicketNoteAdapter(this.model.get("id")),this.chatView=new a.ChatView({containerId:"chat-view",adapter:this.adapter,theme:"compact",currentUserId:this.getCurrentUserId(),inputPlaceholder:"Add a note...",inputButtonText:"Add Note"}),this.addChild(this.chatView);const s=new e.ContextMenu({containerId:"ticket-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{type:"divider"},{label:"Change Status",action:"change-status",icon:"bi-tag"},{label:"Set Priority",action:"set-priority",icon:"bi-flag"},{label:"Assign User",action:"assign-user",icon:"bi-person"},{type:"divider"},{label:"Close Ticket",action:"close-ticket",icon:"bi-x-circle",class:"text-danger"}]}});this.addChild(s)}getCurrentUserId(){const e=window.app?.state?.user;return e?.id||null}async onActionRefreshNotes(){await this.chatView.refresh(),this.getApp()?.toast?.success("Notes refreshed")}async onActionAskAi(){await _(this,"incident.Ticket")}async onActionEditTicket(){await s.Dialog.showModelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:a.TicketForms.edit.fields})&&this.render()}async onActionChangeStatus(){const e=await s.Dialog.showForm({title:"Change Status",icon:"bi-tag",size:"sm",fields:[{name:"status",label:"Status",type:"select",options:["new","open","in_progress","pending","resolved","qa","closed","ignored"].map(e=>({value:e,label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())})),value:this.model.get("status"),required:!0}]});e&&(await this.model.save({status:e.status}),this.render())}async onActionSetPriority(){const e=await s.Dialog.showForm({title:"Set Priority",icon:"bi-flag",size:"sm",fields:[{name:"priority",label:"Priority",type:"select",value:this.model.get("priority")||5,options:[{value:10,label:"10 — Critical"},{value:9,label:"9 — Severe"},{value:8,label:"8 — High"},{value:7,label:"7 — Elevated"},{value:5,label:"5 — Normal"},{value:3,label:"3 — Low"},{value:1,label:"1 — Info"}],required:!0}]});e&&(await this.model.save({priority:parseInt(e.priority)}),this.render())}async onActionAssignUser(){const e=await s.Dialog.showForm({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:s.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this.model.save({assignee:e.assignee}),this.getApp()?.toast?.success("Ticket assigned"),this.render())}async onActionCloseTicket(){await s.Dialog.confirm(`Close ticket #${this.model.get("id")}?`,"Close Ticket",{confirmText:"Close",confirmClass:"btn-warning"})&&(await this.model.save({status:"closed"}),this.getApp()?.toast?.success("Ticket closed"),this.render())}}a.Ticket.VIEW_CLASS=TicketView;class TicketTablePage extends a.TablePage{constructor(e={}){super({name:"admin_tickets",pageName:"Tickets",router:"admin/tickets",Collection:a.TicketList,formCreate:a.TicketForms.create,formEdit:a.TicketForms.edit,itemViewClass:TicketView,viewDialogOptions:{header:!1},defaultQuery:{sort:"-priority",status:"open"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"title",label:"Title",sortable:!0},{key:"status",label:"Status",sortable:!0,editable:!0,editableOptions:{type:"select",options:["new","open","paused","resolved","qa","ignored"]},filter:{type:"multiselect",placeHolder:"Select Status",options:["new","open","paused","resolved","qa","ignored"]}},{key:"priority",label:"Priority",sortable:!0},{key:"category",label:"Category",sortable:!0,editable:!0,editableOptions:{type:"select",options:[...Object.keys(a.TicketCategories)]},filter:{type:"multiselect",placeHolder:"Select Category",options:[...Object.keys(a.TicketCategories)]}},{key:"assignee.display_name",label:"Assignee",sortable:!0,formatter:"default('Unassigned')"},{key:"incident.id",label:"Incident ID",sortable:!0},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No tickets found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}}class RuleSetTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_rulesets",pageName:"Rule Engine",router:"admin/rulesets",Collection:a.RuleSetList,itemViewClass:RuleSetView,formCreate:a.RuleSetForms.create,formEdit:a.RuleSetForms.edit,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"priority"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"is_active",label:"Active",width:"70px",sortable:!0,formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Active"},{value:"false",label:"Inactive"}]}},{key:"metadata.delete_on_resolution",label:"Auto-Delete",width:"90px",formatter:"yesnoicon"},{key:"name",label:"Name",sortable:!0},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"text",placeholder:"e.g., auth:failed"}},{key:"priority",label:"Priority",sortable:!0,width:"80px"},{key:"bundle_by",label:"Bundle By",width:"140px",formatter:e=>{const t=a.BundleByOptions.find(t=>t.value===e);return t?t.label:String(e)}},{key:"trigger_count",label:"Trigger",width:"80px",formatter:e=>null!=e?String(e):'<span class="text-muted">—</span>'},{key:"handler",label:"Handler",formatter:"truncate(40)|default('—')"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No rule sets found. Create one to start matching events automatically.",batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-toggle-on",action:"enable"},{label:"Disable",icon:"bi bi-toggle-off",action:"disable"},{label:"Delete",icon:"bi bi-trash",action:"delete",danger:!0}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Enable ${e.length} ruleset(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} ruleset(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Disable ${e.length} ruleset(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} ruleset(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Delete ${e.length} ruleset(s)? This cannot be undone.`)&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} ruleset(s) deleted`),this.tableView.collection.fetch())}}class EmailDomainTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_domains",pageName:"Email Domains",router:"admin/email/domains",Collection:a.EmailDomainList,formCreate:a.EmailDomainForms.create,formEdit:a.EmailDomainForms.edit,columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Domain",sortable:!0},{key:"region",label:"Region",sortable:!0,formatter:"default('—')"},{key:"receiving_enabled",label:"Receiving",formatter:"boolean|badge"},{key:"can_send",label:"Send Verified",formatter:"boolean|badge"},{key:"can_recv",label:"Recv Verified",formatter:"boolean|badge"},{key:"created",label:"Created",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No domains found. Click "Add Domain" to get started.',contextMenu:[{icon:"bi-shield-check",action:"edit-aws-creds",label:"Edit AWS Credentials"},{icon:"bi-rocket-takeoff",action:"onboard",label:"Onboard"},{icon:"bi-shield-check",action:"audit",label:"Audit"},{icon:"bi-arrow-repeat",action:"reconcile",label:"Reconcile"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditAwsCreds(e,t){const i=this.collection.get(t.dataset.id);return await s.Dialog.showModelForm({model:i,formConfig:a.EmailDomainForms.credentials}),!0}async onActionOnboard(e,t){const i=this.collection.get(t.dataset.id),n=new a.EmailDomain({id:i.id}),o=await s.Dialog.showForm(a.EmailDomainForms.onboard);if(o)try{const e=await n.onboard(o);if(!e.success)throw new Error(e.message||"Network error during onboarding");if(!e.data?.status)throw new Error(e.data?.error||"Onboarding failed");this.getApp()?.toast?.success("Domain onboarding completed successfully"),await this.refresh()}catch(l){console.error("Onboard error:",l),this.showError(l.message||"Failed to onboard domain")}}async onActionAudit(e,i){const n=this.collection.get(i.dataset.id),o=new a.EmailDomain({id:n.id});try{const e=await o.audit();if(!e.success)throw new Error(e.message||"Network error during audit");if(!e.data?.status)throw new Error(e.data?.error||"Audit failed");const i=e.data?.data||{};await s.Dialog.showDialog({title:`Audit Report - ${n.name}`,body:new t.View({template:'\n <div>\n <p class="text-muted">Drift report and status:</p>\n <pre class="bg-light p-3 rounded small"><code>{{{data.result|json}}}</code></pre>\n </div>\n ',data:{result:i}}),size:"lg"})}catch(l){console.error("Audit error:",l),this.showError(l.message||"Failed to audit domain")}}async onActionReconcile(e,t){const s=this.collection.get(t.dataset.id),i=new a.EmailDomain({id:s.id});try{const e=await i.reconcile();if(!e.success)throw new Error(e.message||"Network error during reconcile");if(!e.data?.status)throw new Error(e.data?.error||"Reconcile failed");this.getApp()?.toast?.success("Reconcile completed successfully"),await this.refresh()}catch(n){console.error("Reconcile error:",n),this.showError(n.message||"Failed to reconcile domain")}}}class EmailMailboxTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_mailboxes",pageName:"Mailboxes",router:"admin/email/mailboxes",Collection:a.MailboxList,formCreate:a.MailboxForms.create,formEdit:a.MailboxForms.edit,clickAction:"edit",columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"email",label:"Email",sortable:!0},{key:"domain.name",label:"Domain",sortable:!0,formatter:"default('—')"},{key:"allow_inbound",label:"Inbound",formatter:"boolean|badge"},{key:"allow_outbound",label:"Outbound",formatter:"boolean|badge"},{key:"is_system_default",label:"System Default",formatter:"boolean|badge"},{key:"is_domain_default",label:"Domain Default",formatter:"boolean|badge"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No mailboxes found. Click "Add Mailbox" to create one.',contextMenu:[{icon:"bi-envelope",action:"send-email",label:"Send Email"},{icon:"bi-envelope",action:"send-template-email",label:"Send Template Email"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionSendEmail(e,t){const i=this.collection.get(t.dataset.id),n=await s.Dialog.showForm({title:"Send Email",fields:[{name:"to",label:"To",type:"email",required:!0},{name:"subject",label:"Subject",type:"text",required:!0},{name:"body_html",label:"Body",type:"textarea",required:!0}]});n.from_email=i.get("email");const o=await a.Mailbox.sendEmail(n);if(o.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";o.data.details?e=o.data.details:o.data.error&&(e=o.data.error),this.getApp().toast.error(e)}}async onActionSendTemplateEmail(e,t){const i=this.collection.get(t.dataset.id),n=await s.Dialog.showForm({title:"Send Email",fields:[{name:"to",label:"To",type:"email",required:!0},{name:"subject",label:"Subject",type:"text",required:!0},{name:"template_name",label:"Template",type:"text",required:!0},{name:"template_context",label:"Context",type:"textarea",required:!0}]});n.from_email=i.get("email");const o=await a.Mailbox.sendEmail(n);if(o.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";o.data.details?e=o.data.details:o.data.error&&(e=o.data.error),this.getApp().toast.error(e)}}}class EmailHtmlPreviewView extends t.View{constructor(e={}){super({className:"email-html-preview",template:'\n <div class="email-html-preview-container">\n <div class="d-flex justify-content-between align-items-center mb-2">\n <small class="text-muted">HTML Preview (sandboxed)</small>\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="refresh-preview">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n <iframe\n id="email-preview-frame"\n class="border rounded w-100"\n style="height: 500px; background: white;"\n sandbox="allow-same-origin"\n frameborder="0"\n ></iframe>\n </div>\n ',...e})}async onAfterRender(){await super.onAfterRender(),this.renderHtmlInIframe()}renderHtmlInIframe(){const e=this.element.querySelector("#email-preview-frame");if(!e)return;const t=this.model.get("html_template")||"",s=e.contentDocument||e.contentWindow.document;s.open(),s.write(t),s.close()}async onActionRefreshPreview(e,t){this.renderHtmlInIframe()}}class EmailTemplateView extends t.View{constructor(e={}){super({className:"email-template-view",...e}),this.model=e.model||new a.EmailTemplate(e.data||{}),this.hasHtml=!!this.model.get("html_template"),this.hasText=!!this.model.get("text_template"),this.hasMetadata=this.model.get("metadata")&&Object.keys(this.model.get("metadata")).length>0,this.template='\n <div class="email-template-view-container p-3">\n \x3c!-- Header --\x3e\n <div class="template-header border-bottom pb-3 mb-3">\n <h4 class="mb-1">{{model.name}}</h4>\n <div class="text-muted">\n <strong>Subject:</strong> {{model.subject_template|default(\'Not set\')}}\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="template-tabs"></div>\n </div>\n '}async onInit(){const e={};this.hasHtml&&(e["HTML Preview"]=new EmailHtmlPreviewView({model:this.model})),this.hasText&&(e["Text Version"]=new t.View({model:this.model,template:'<pre class="email-text-content p-3 bg-light border rounded" style="white-space: pre-wrap; word-wrap: break-word;">{{model.text_template}}</pre>'})),this.hasMetadata&&(e.Metadata=new t.View({model:this.model,template:'<pre class="email-metadata-content p-3 bg-light border rounded"><code>{{model.metadata|json}}</code></pre>'})),this.tabView=new a.TabView({containerId:"template-tabs",tabs:e,activeTab:Object.keys(e)[0]||""}),this.addChild(this.tabView)}}class EmailTemplateTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_templates",pageName:"Email Templates",router:"admin/email/templates",Collection:a.EmailTemplateList,formCreate:a.EmailTemplateForms.create,formEdit:a.EmailTemplateForms.edit,itemViewClass:EmailTemplateView,clickAction:"edit",viewDialogOptions:{header:!1,size:"xl",scrollable:!0},formDialogConfig:{size:"fullscreen"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"created",label:"Created",formatter:"datetime"},{key:"modified",label:"Modified",formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No email templates found. Click "Add Template" to create your first one.',tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class EmailView extends t.View{constructor(e={}){super({className:"email-view",...e}),this.model=e.model||new a.SentMessage(e.data||{}),this.hasHtml=!!this.model.get("body_html"),this.hasText=!!this.model.get("body_text"),this.hasContext=this.model.get("template_context")&&Object.keys(this.model.get("template_context")).length>0,this.template='\n <div class="email-view-container p-3">\n \x3c!-- Email Header --\x3e\n <div class="email-header border-bottom pb-3 mb-3">\n <h4 class="mb-2">{{model.subject}}</h4>\n <div class="d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center">\n <div class="flex-shrink-0">\n <i class="bi bi-person-circle fs-2 text-secondary"></i>\n </div>\n <div class="ms-3">\n <div class="fw-bold">{{model.mailbox.email}}</div>\n <div class="text-muted small">To: {{model.to_addresses|list}}</div>\n </div>\n </div>\n <div class="text-end">\n <div class="text-muted small">{{model.created|datetime}}</div>\n <span class="badge {{model.status|badge}} mt-1">{{model.status|capitalize}}</span>\n </div>\n </div>\n </div>\n\n \x3c!-- Email Body Tabs --\x3e\n <div data-container="email-tabs"></div>\n </div>\n '}async onInit(){const e={};this.hasHtml&&(e.HTML=new t.View({model:this.model,template:'<div class="email-html-content border rounded p-3" style="height: 500px; overflow-y: auto;">{{{model.body_html}}}</div>'})),this.hasText&&(e.Text=new t.View({model:this.model,template:'<pre class="email-text-content p-3 bg-light border rounded" style="white-space: pre-wrap; word-wrap: break-word;">{{model.body_text}}</pre>'})),this.hasContext&&(e.Context=new t.View({model:this.model,template:'<pre class="email-context-content p-3 bg-light border rounded"><code>{{model.template_context|json}}</code></pre>'})),this.tabView=new a.TabView({containerId:"email-tabs",tabs:e,activeTab:this.hasHtml?"HTML":this.hasText?"Text":"Context"}),this.addChild(this.tabView)}}a.SentMessage.VIEW_CLASS=EmailView;class SentMessageTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_email_sent",pageName:"Sent Messages",router:"admin/email/sent",Collection:a.SentMessageList,itemViewClass:EmailView,viewDialogOptions:{header:!1,size:"xl",scrollable:!0},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"mailbox.email",label:"From",sortable:!0},{key:"to_addresses",label:"To",sortable:!1,formatter:"list"},{key:"subject",label:"Subject",sortable:!0},{key:"status",label:"Status",formatter:"badge"},{key:"status_reason",label:"Reason",formatter:"truncate(80)|default('—')"},{key:"created",label:"Created",formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No sent messages found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class PhoneNumber extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/phonehub/number",...t})}static async normalize(e,s="US"){const i={phone_number:e};s&&(i.country_code=s);const a=await t.rest.POST("/api/phonehub/number/normalize",i),n=a?.data??a;return!0===n?.status||!0===n?.success?{success:!0,phone_number:n?.data?.phone_number??n?.phone_number,data:n?.data??n,response:a}:{success:!1,error:n?.error||"Normalization failed",response:a}}static async lookup(e,s={}){const i=await t.rest.POST("/api/phonehub/number/lookup",{phone_number:e,...s}),a=i?.data??i;if(!0===a?.status||!0===a?.success){const e=a?.data??{};return{success:!0,model:new PhoneNumber(e,{endpoint:"/api/phonehub/number"}),data:e,response:i}}return{success:!1,error:a?.error||"Phone lookup failed",response:i}}}class PhoneNumberList extends t.Collection{constructor(e={}){super({ModelClass:PhoneNumber,endpoint:"/api/phonehub/number",size:10,...e})}}class SMS extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/phonehub/sms",...t})}static async send(e={}){const s=await t.rest.POST("/api/phonehub/sms/send",e),i=s?.data??s;if(!0===i?.status||!0===i?.success){const e=i?.data??{};return{success:!0,model:new SMS(e,{endpoint:"/api/phonehub/sms"}),data:e,response:s}}return{success:!1,error:i?.error||"Failed to send SMS",response:s}}}class SMSList extends t.Collection{constructor(e={}){super({ModelClass:SMS,endpoint:"/api/phonehub/sms",size:10,...e})}}class PhoneNumberView extends t.View{constructor(e={}){super({className:"phone-number-view",...e}),this.model=e.model||new PhoneNumber(e.data||{}),this.template='\n <div class="phone-number-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-telephone"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.phone_number|default(\'Unknown Number\')}}</h3>\n <div class="text-muted small">\n {{model.carrier|default(\'—\')}} {{#model.line_type}}· {{model.line_type|capitalize}}{{/model.line_type}}\n </div>\n <div class="text-muted small mt-1">\n {{#model.country_code}}Country: {{model.country_code}}{{/model.country_code}}\n {{#model.region}} · Region: {{model.region}}{{/model.region}}\n {{#model.state}} · State: {{model.state}}{{/model.state}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div data-container="phone-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="phone-tabs"></div>\n </div>\n '}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"phone_number",label:"Phone Number",cols:6},{name:"country_code",label:"Country Code",cols:6},{name:"region",label:"Region",cols:6},{name:"state",label:"State",cols:6},{name:"registered_owner",label:"Registered Owner",cols:6},{name:"owner_type",label:"Owner Type",formatter:"capitalize",cols:6},{name:"is_valid",label:"Valid",formatter:"yesnoicon",cols:4},{name:"is_mobile",label:"Mobile",formatter:"yesnoicon",cols:4},{name:"is_voip",label:"VOIP",formatter:"yesnoicon",cols:4}]}),this.carrierView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"carrier",label:"Carrier",cols:6},{name:"line_type",label:"Line Type",formatter:"capitalize",cols:6},{name:"lookup_provider",label:"Lookup Provider",formatter:"capitalize",cols:6},{name:"lookup_count",label:"Lookup Count",cols:6},{name:"last_lookup_at",label:"Last Lookup",formatter:"datetime",cols:6},{name:"lookup_expires_at",label:"Cache Expires",formatter:"datetime",cols:6}]}),this.addressView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"address_line1",label:"Address Line 1",cols:12},{name:"address_city",label:"City",cols:4},{name:"address_state",label:"State",cols:4},{name:"address_zip",label:"ZIP",cols:4},{name:"address_country",label:"Country",cols:6}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6}]});const t={Overview:this.overviewView,Carrier:this.carrierView,Address:this.addressView,Metadata:this.metadataView};this.tabView=new a.TabView({containerId:"phone-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"phone-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh Lookup",action:"refresh-lookup",icon:"bi-arrow-repeat"},{type:"divider"},{label:"Delete Record",action:"delete-phone",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionRefreshLookup(){const e=this.model.get("phone_number");if(e)try{this.getApp()?.toast?.info?.("Refreshing lookup...");const t=await PhoneNumber.lookup(e,{force_refresh:!0});if(t.success&&t.data)this.model.set(t.data),await this.render(),this.getApp()?.toast?.success?.("Lookup refreshed");else{const e=t.error||"Lookup failed";this.getApp()?.toast?.error?.(e)}}catch(t){this.getApp()?.toast?.error?.(t.message||"Lookup failed")}else this.getApp()?.toast?.warning?.("No phone number to lookup")}async onActionDeletePhone(){if(await s.Dialog.confirm(`Are you sure you want to delete the record for "${this.model.get("phone_number")||"this number"}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"}))try{const e=await this.model.destroy();e?.success?this.emit("phone:deleted",{model:this.model}):this.getApp()?.toast?.error?.("Delete failed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Delete failed")}}static async show(e){const t=await PhoneNumber.lookup(e);if(t?.model){const e=new PhoneNumberView({model:t.model}),i=new s.Dialog({header:!1,size:"lg",body:e,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]});return await i.render(!0,document.body),i.show(),i}return s.Dialog.alert({message:`Could not find phone data for number: ${e}`,type:"warning"}),null}}PhoneNumberView.MODEL_CLASS=PhoneNumber;class PhoneNumberTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_numbers",pageName:"Phone Numbers",router:"admin/phonehub/numbers",Collection:PhoneNumberList,itemView:PhoneNumberView,viewDialogOptions:{header:!1},columns:[{key:"phone_number",label:"Phone Number",sortable:!0},{key:"carrier",label:"Carrier",sortable:!0,formatter:"default('—')"},{key:"line_type",label:"Line Type",sortable:!0,formatter:"capitalize"},{key:"is_mobile",label:"Mobile",formatter:"yesnoicon"},{key:"is_voip",label:"VOIP",formatter:"yesnoicon"},{key:"is_valid",label:"Valid",formatter:"yesnoicon"},{key:"registered_owner",label:"Owner",sortable:!0,formatter:"default('—')"},{key:"owner_type",label:"Owner Type",formatter:"capitalize"},{key:"last_lookup_at|relative",label:"Last Lookup",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No phone numbers found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},tableViewOptions:{addButtonLabel:"Lookup",addButtonIcon:"bi-search",onAdd:e=>{e.preventDefault(),this.onLookup()}}})}async onLookup(){const e=await this.getApp().showForm({title:"Lookup Phone Number",fields:[{name:"number",type:"text",required:!0}]});if(e&&e.number){const t=await PhoneNumber.lookup(e.number);t.model&&this.tableView._onRowView(t)}}}class SMSView extends t.View{constructor(e={}){super({className:"sms-view",...e}),this.model=e.model||new SMS(e.data||{}),this.template='\n <div class="sms-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Icon & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-chat-dots"></i>\n </div>\n <div>\n <h3 class="mb-1">\n {{#model.direction}}{{model.direction|capitalize}}{{/model.direction}}\n {{^model.direction}}Message{{/model.direction}}\n <small class="text-muted ms-2">\n {{#model.status}}[{{model.status|capitalize}}]{{/model.status}}\n </small>\n </h3>\n <div class="text-muted small">\n {{#model.from_number}}From: {{model.from_number}}{{/model.from_number}}\n {{#model.to_number}} · To: {{model.to_number}}{{/model.to_number}}\n </div>\n <div class="text-muted small mt-1">\n {{#model.provider}}Provider: {{model.provider|capitalize}}{{/model.provider}}\n {{#model.provider_message_id}} · SID: {{model.provider_message_id}}{{/model.provider_message_id}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div data-container="sms-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="sms-tabs"></div>\n </div>\n '}async onInit(){this.messageView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"direction",label:"Direction",formatter:"capitalize",cols:4},{name:"status",label:"Status",formatter:"capitalize",cols:4},{name:"from_number",label:"From",cols:6},{name:"to_number",label:"To",cols:6},{name:"body",label:"Message Body",cols:12}]}),this.deliveryView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"provider",label:"Provider",formatter:"capitalize",cols:6},{name:"provider_message_id",label:"Provider Message ID",cols:6},{name:"sent_at",label:"Sent At",formatter:"datetime",cols:6},{name:"delivered_at",label:"Delivered At",formatter:"datetime",cols:6},{name:"error_code",label:"Error Code",cols:6},{name:"error_message",label:"Error Message",cols:12}]}),this.metadataView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"Record ID",cols:6},{name:"created",label:"Created",formatter:"datetime",cols:6},{name:"modified",label:"Last Modified",formatter:"datetime",cols:6}]});const t={Message:this.messageView,Delivery:this.deliveryView,Metadata:this.metadataView};this.tabView=new a.TabView({containerId:"sms-tabs",tabs:t,activeTab:"Message"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"sms-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh-sms",icon:"bi-arrow-repeat"},{type:"divider"},{label:"Delete Message",action:"delete-sms",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onActionRefreshSms(){try{this.getApp()?.toast?.info?.("Refreshing message..."),await this.model.fetch(),await this.render(),this.getApp()?.toast?.success?.("Message refreshed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Refresh failed")}}async onActionDeleteSms(){if(await s.Dialog.confirm("Are you sure you want to delete this message?","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"}))try{const e=await this.model.destroy();e?.success?this.emit("sms:deleted",{model:this.model}):this.getApp()?.toast?.error?.("Delete failed")}catch(e){this.getApp()?.toast?.error?.(e.message||"Delete failed")}}}SMSView.MODEL_CLASS=SMS;class SMSTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_sms",pageName:"SMS Messages",router:"admin/phonehub/sms",Collection:SMSList,itemView:SMSView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"direction",label:"Direction",sortable:!0},{key:"from_number",label:"From",sortable:!0,formatter:"default('—')"},{key:"to_number",label:"To",sortable:!0,formatter:"default('—')"},{key:"status",label:"Status",sortable:!0},{key:"provider",label:"Provider",sortable:!0,formatter:"default('—')"},{key:"body",label:"Message",formatter:"default('—')"},{key:"sent_at",label:"Sent At",sortable:!0,formatter:"datetime"},{key:"delivered_at",label:"Delivered At",sortable:!0,formatter:"datetime"},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,clickAction:"view",showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No SMS messages found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class PushDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Push Notifications Dashboard",className:"push-dashboard-page"})}async getTemplate(){return'\n <div class="container-fluid">\n <h1 class="h3 mb-4">Push Notifications</h1>\n <div class="row">\n \x3c!-- Stat cards --\x3e\n </div>\n <div class="row">\n <div class="col-xl-8 col-lg-7">\n <div class="card shadow mb-4">\n <div class="card-header">Notifications Over Time</div>\n <div class="card-body" data-container="deliveries-chart"></div>\n </div>\n </div>\n <div class="col-xl-4 col-lg-5">\n <div class="card shadow mb-4">\n <div class="card-header">Delivery Status</div>\n <div class="card-body" data-container="status-chart"></div>\n </div>\n </div>\n </div>\n <div class="row">\n <div class="col-lg-6 mb-4" data-container="recent-deliveries"></div>\n <div class="col-lg-6 mb-4" data-container="failed-deliveries"></div>\n </div>\n </div>\n '}async onInit(){this.deliveriesChart=new i.MetricsChart({containerId:"deliveries-chart",endpoint:"/api/metrics/fetch",slugs:["push_sent","push_failed"],chartType:"line"}),this.addChild(this.deliveriesChart),this.statusChart=new i.PieChart({containerId:"status-chart",endpoint:"/api/account/devices/push/stats"}),this.addChild(this.statusChart),this.recentDeliveries=new n.TableView({containerId:"recent-deliveries",title:"Recent Deliveries",Collection:new a.PushDeliveryList({params:{_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"status",label:"Status",formatter:"badge"}]}),this.addChild(this.recentDeliveries),this.failedDeliveries=new n.TableView({containerId:"failed-deliveries",title:"Failed Deliveries",Collection:new a.PushDeliveryList({params:{status:"failed",_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"error_message",label:"Error"}]}),this.addChild(this.failedDeliveries)}}class PushConfigTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_configs",pageName:"Push Configurations",router:"admin/push/configs",Collection:a.PushConfigList,formCreate:a.PushConfigForms.create,formEdit:a.PushConfigForms.edit,columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"group.name",label:"Group",formatter:"default('Default')"},{key:"fcm_sender_id",label:"Project ID"},{key:"is_active",label:"Active",format:"boolean"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,actions:["edit","delete"],tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No push configurations found.",emptyIcon:"bi-gear"}})}}class PushTemplateTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_templates",pageName:"Push Templates",router:"admin/push/templates",Collection:a.PushTemplateList,formCreate:a.PushTemplateForms.create,formEdit:a.PushTemplateForms.edit,columns:[{key:"id",label:"ID",width:"70px"},{key:"name",label:"Name"},{key:"category",label:"Category"},{key:"group.name",label:"Group",formatter:"default('Default')"},{key:"priority",label:"Priority"},{key:"is_active",label:"Active",format:"boolean"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No push templates found.",emptyIcon:"bi-file-earmark-text",actions:["edit","delete"]}})}}class PushDeliveryView extends t.View{constructor(e={}){super({className:"push-delivery-view",...e}),this.model=e.model}getTemplate(){return'\n <div class="p-3">\n <div class="phone-mockup">\n <div class="phone-screen">\n <div class="notification">\n <div class="notification-header">\n <i class="bi bi-app-indicator"></i>\n <strong>Your App</strong>\n <span class="ms-auto small text-muted">now</span>\n </div>\n <div class="notification-body">\n <div class="fw-bold">{{model.title}}</div>\n <div>{{model.body}}</div>\n </div>\n </div>\n </div>\n </div>\n <div class="mt-3">\n <h5>Delivery Details</h5>\n <p><strong>Status:</strong> <span class="badge {{model.status|badge}}">{{model.status}}</span></p>\n <p><strong>Error:</strong> {{model.error_message|default(\'None\')}}</p>\n </div>\n </div>\n '}}class PushDeliveryTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_deliveries",pageName:"Push Deliveries",router:"admin/push/deliveries",Collection:a.PushDeliveryList,itemViewClass:PushDeliveryView,viewDialogOptions:{header:!1,size:"md"},columns:[{key:"id",label:"ID",width:"70px"},{key:"created",label:"Timestamp",formatter:"datetime"},{key:"user.display_name",label:"User"},{key:"device.device_name",label:"Device"},{key:"title",label:"Title"},{key:"category",label:"Category"},{key:"status",label:"Status",formatter:"badge"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No deliveries found.",emptyIcon:"bi-send",actions:["view"]}})}}class PushDeviceView extends t.View{constructor(e={}){super({className:"push-device-view",...e}),this.model=e.model}getTemplate(){return'\n <div class="p-3">\n <h3>{{model.device_name}}</h3>\n <p class="text-muted">{{model.user.display_name}}</p>\n <div data-container="data-view"></div>\n </div>\n '}onInit(){this.dataView=new r.default({containerId:"data-view",model:this.model,fields:[{name:"platform",label:"Platform",format:"badge"},{name:"push_enabled",label:"Push Enabled",format:"boolean"},{name:"app_version",label:"App Version"},{name:"os_version",label:"OS Version"},{name:"last_seen",label:"Last Seen",format:"datetime"},{name:"push_preferences",label:"Preferences",format:"json"}]}),this.addChild(this.dataView)}}class PushDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_push_devices",pageName:"Registered Devices",router:"admin/push/devices",Collection:a.PushDeviceList,itemViewClass:PushDeviceView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px"},{key:"user.display_name",label:"User"},{key:"device_name",label:"Device Name"},{key:"platform",label:"Platform",formatter:"badge"},{key:"app_version",label:"App Version"},{key:"push_enabled",label:"Push Enabled",format:"boolean"},{key:"last_seen",label:"Last Seen",formatter:"datetime"}],searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No devices found.",emptyIcon:"bi-phone",actions:["view","delete"]}})}}class JobStatsView extends t.View{constructor(e={}){super({className:"job-stats-section",...e}),this.stats={pending:0,running:0,completed:0,failed:0,scheduled:0},this.template='\n <div class="job-stats-header mb-4">\n <div class="row">\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Pending</h6>\n <h3 class="mb-1 fw-bold">{{stats.pending}}</h3>\n <span class="badge bg-primary-subtle text-primary">\n <i class="bi bi-hourglass"></i> Queued\n </span>\n </div>\n <div class="text-primary">\n <i class="bi bi-clock fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Running</h6>\n <h3 class="mb-1 fw-bold">{{stats.running}}</h3>\n <span class="badge bg-success-subtle text-success">\n <i class="bi bi-arrow-repeat"></i> Active\n </span>\n </div>\n <div class="text-success">\n <i class="bi bi-play-circle fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-2 col-lg-4 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Scheduled</h6>\n <h3 class="mb-1 fw-bold">{{stats.scheduled}}</h3>\n <span class="badge bg-warning-subtle text-warning">\n <i class="bi bi-calendar-event"></i> Planned\n </span>\n </div>\n <div class="text-warning">\n <i class="bi bi-calendar3 fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Completed</h6>\n <h3 class="mb-1 fw-bold">{{stats.completed}}</h3>\n <span class="badge bg-info-subtle text-info">\n <i class="bi bi-check-circle"></i> Done\n </span>\n </div>\n <div class="text-info">\n <i class="bi bi-check-square fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class="col-xl-3 col-lg-6 col-md-6 col-12 mb-3">\n <div class="card h-100 border-0 shadow-sm">\n <div class="card-body">\n <div class="d-flex justify-content-between align-items-start">\n <div>\n <h6 class="card-title text-muted mb-2">Failed</h6>\n <h3 class="mb-1 fw-bold">{{stats.failed}}</h3>\n <span class="badge bg-danger-subtle text-danger">\n <i class="bi bi-x-octagon"></i> Errors\n </span>\n </div>\n <div class="text-danger">\n <i class="bi bi-exclamation-triangle fs-2"></i>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n '}_onModelChange(){this.loadStats(),this.isMounted()&&this.render()}async loadStats(){this.stats=this.model.attributes.totals}}class JobHealthView extends t.View{constructor(e={}){super({className:"job-health-section",...e}),this.health={overall_status:"unknown",runners:{active:0,total:0},channels:[]},this.healthStatusClass="text-muted",this.schedulerStatusText="Unknown",this.schedulerStatusClass="text-muted",this.schedulerIcon="bi-question-circle-fill",this.template='\n <div class="job-health-header mb-4">\n <div class="card border-0 shadow-sm">\n <div class="card-body">\n <div class="row align-items-center">\n <div class="col-md-6">\n <div class="d-flex align-items-center">\n <div class="health-indicator me-3">\n <i class="bi bi-circle-fill fs-4 {{healthStatusClass}}"></i>\n </div>\n <div>\n <h5 class="mb-1">Service Health: {{health.overall_status|capitalize}}</h5>\n <small class="text-muted d-block">\n Workers: {{health.runners.active}}/{{health.runners.total}} active\n </small>\n <small class="text-muted d-block">\n Scheduler:\n <span class="{{schedulerStatusClass}} fw-bold">\n <i class="bi {{schedulerIcon}} me-1"></i>{{schedulerStatusText}}\n </span>\n </small>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="d-flex justify-content-end">\n <div class="btn-group">\n <button class="btn btn-sm btn-outline-primary" data-action="refresh-health">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <button class="btn btn-sm btn-outline-secondary" data-action="system-settings">\n <i class="bi bi-gear"></i> Settings\n </button>\n </div>\n </div>\n </div>\n </div>\n {{#health.channelsArray.length}}\n <div class="row mt-3">\n <div class="col-12">\n <small class="text-muted d-block mb-2">Channel Status:</small>\n <div class="d-flex flex-wrap gap-2">\n {{#health.channelsArray}}\n <span class="badge {{statusBadgeClass}} d-flex align-items-center">\n <i class="bi {{statusIcon}} me-1"></i>\n {{channel}} ({{queued}} queued, {{inflight}} inflight)\n </span>\n {{/health.channelsArray}}\n </div>\n </div>\n </div>\n {{/health.channelsArray.length}}\n </div>\n </div>\n </div>\n '}_onModelChange(){this.loadHealth(),this.isMounted()&&this.render()}async loadHealth(){if(!this.model._.totals)return;const e=this.model.attributes;let t="healthy";0===e.totals.runners_active?t="critical":e.scheduler.active||(t="warning"),this.health={overall_status:t,channels:e.channels,runners:{active:e.totals.runners_active,total:e.runners.length},totals:e.totals,scheduler:e.scheduler},this.healthStatusClass=this.getHealthStatusClass(this.health.overall_status),this.setupChannelDisplay(),this.setupSchedulerDisplay()}setupChannelDisplay(){this.health.channels&&(this.health.channelsArray=Object.values(this.health.channels).map(e=>{let t="healthy";const s=(e.queued_count||0)+(e.inflight_count||0);return s>50&&(t="warning"),(s>100||0===e.runners)&&(t="critical"),{...e,status:t,statusBadgeClass:this.getChannelBadgeClass(t),statusIcon:this.getChannelIcon(t),queued:e.queued_count||0,inflight:e.inflight_count||0,pending:e.queued_count||0,running:e.inflight_count||0}}))}setupSchedulerDisplay(){if(!this.health.scheduler)return this.schedulerStatusText="Unknown",this.schedulerStatusClass="text-muted",void(this.schedulerIcon="bi-question-circle-fill");this.health.scheduler.active?(this.schedulerStatusText="Running",this.schedulerStatusClass="text-success",this.schedulerIcon="bi-check-circle-fill"):(this.schedulerStatusText="Stopped",this.schedulerStatusClass="text-danger",this.schedulerIcon="bi-x-octagon-fill")}getHealthStatusClass(e){return{healthy:"text-success",warning:"text-warning",critical:"text-danger",unknown:"text-muted"}[e]||"text-muted"}getChannelBadgeClass(e){return{healthy:"bg-success",warning:"bg-warning",critical:"bg-danger"}[e]||"bg-secondary"}getChannelIcon(e){return{healthy:"bi-check-circle-fill",warning:"bi-exclamation-triangle-fill",critical:"bi-x-octagon-fill"}[e]||"bi-dash-circle-fill"}async onActionRefreshHealth(e,t){try{t.disabled=!0,await this.model.fetch()}catch(s){console.error("Failed to refresh health:",s)}finally{t.disabled=!1}}async onActionSystemSettings(){await s.Dialog.showAlert({title:"System Settings",message:"System settings interface coming soon!",type:"info"})}}class JobOverviewSection extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div class="row mb-4 g-3 align-items-stretch">\n <div class="col-lg-6" data-container="jobs-published-chart"></div>\n <div class="col-lg-6" data-container="jobs-failed-chart"></div>\n </div>\n <div data-container="job-health"></div>\n ',...e})}async onInit(){this.jobsPublishedChart=new i.MetricsMiniChartWidget({containerId:"jobs-published-chart",icon:"bi bi-upload",title:"Jobs Published",subtitle:"{{now_value}} {{now_label}}",granularity:"days",slugs:["jobs.published"],account:"global",chartType:"line",height:90,showSettings:!0,showTrending:!0,showDateRange:!1}),this.addChild(this.jobsPublishedChart),this.jobsFailedChart=new i.MetricsMiniChartWidget({containerId:"jobs-failed-chart",icon:"bi bi-exclamation-octagon",title:"Jobs Failed",subtitle:"{{now_value}} {{now_label}}",granularity:"days",slugs:["jobs.failed"],account:"global",chartType:"line",height:90,showSettings:!0,showTrending:!0,showDateRange:!1}),this.addChild(this.jobsFailedChart),this.jobHealthView=new JobHealthView({containerId:"job-health",model:this.options.model}),this.addChild(this.jobHealthView)}}class JobOperationsSection extends t.View{constructor(e={}){super({className:"job-operations-section",template:'\n <div class="card shadow-sm">\n <div class="card-header d-flex justify-content-between align-items-center">\n <h5 class="mb-0"><i class="bi bi-tools me-2"></i>Operations</h5>\n </div>\n <div class="card-body">\n <div class="d-flex flex-wrap gap-2">\n <button class="btn btn-outline-primary" data-action="run-simple-job">\n <i class="bi bi-play-circle me-2"></i>Run Simple Job\n </button>\n <button class="btn btn-outline-primary" data-action="run-test-jobs">\n <i class="bi bi-robot me-2"></i>Run Test Jobs\n </button>\n <button class="btn btn-outline-warning" data-action="clear-stuck">\n <i class="bi bi-wrench me-2"></i>Clear Stuck\n </button>\n <button class="btn btn-outline-warning" data-action="clear-channel">\n <i class="bi bi-eraser me-2"></i>Clear Channel\n </button>\n <button class="btn btn-outline-danger" data-action="purge-jobs">\n <i class="bi bi-trash me-2"></i>Purge Jobs\n </button>\n <button class="btn btn-outline-info" data-action="cleanup-consumers">\n <i class="bi bi-people me-2"></i>Cleanup Consumers\n </button>\n <button class="btn btn-outline-secondary" data-action="runner-broadcast">\n <i class="bi bi-wifi me-2"></i>Broadcast Command\n </button>\n </div>\n </div>\n </div>\n ',...e})}async onActionRunSimpleJob(e,t){await s.Dialog.showConfirm({title:"Run Simple Job",message:"This will run a simple test job to verify the job system is working correctly.",confirmText:"Run Test",confirmClass:"btn-success"})&&await this.executeJobAction(t,()=>a.Job.test(),"Test job started successfully")}async onActionRunTestJobs(e,t){await s.Dialog.showConfirm({title:"Run Test Jobs",message:"This will run a suite of test jobs to verify all job functionalities.",confirmText:"Run Tests",confirmClass:"btn-success"})&&await this.executeJobAction(t,()=>a.Job.tests(),"Test suite started successfully")}async onActionClearStuck(e,t){const i=[{value:"",label:"All Channels"},...(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel}))],n=await s.Dialog.showForm({title:"Clear Stuck Jobs",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,value:"",help:"Select specific channel or leave empty for all channels"}]}});n&&await this.executeJobAction(t,()=>a.Job.clearStuck(n.channel||null),e=>{const t=e.data.count||0;return`Cleared ${t} stuck job${1!==t?"s":""}${n.channel?` from channel "${n.channel}"`:""}`})}async onActionClearChannel(e,t){const i=(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel})),n=await s.Dialog.showForm({title:"Clear Channel",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,required:!0,help:"Select the channel to clear."}]}});n&&await this.executeJobAction(t,()=>a.Job.clearChannel(n.channel),`Channel "${n.channel}" cleared successfully.`)}async onActionPurgeJobs(e,t){const i=await s.Dialog.showForm({title:"Purge Old Jobs",formConfig:{fields:[{name:"days_old",type:"number",label:"Days Old",value:30,required:!0,help:"Delete jobs older than this many days."}]}});i&&await this.executeJobAction(t,()=>a.Job.purgeJobs(i.days_old),e=>`Purged ${e.data.count||0} old job(s).`)}async onActionCleanupConsumers(e,t){await s.Dialog.showConfirm({title:"Cleanup Consumers",message:"This will remove stale consumer records from the system. This is generally safe.",confirmText:"Cleanup",confirmClass:"btn-warning"})&&await this.executeJobAction(t,()=>a.Job.cleanConsumers(),e=>`Cleaned up ${e.data.count||0} consumer(s).`)}async onActionRunnerBroadcast(){const e=await s.Dialog.showForm({title:"Broadcast Command to All Runners",formConfig:a.JobRunnerForms.broadcast});if(e)try{const t=await a.JobRunner.broadcast(e.command,{},e.timeout);t.success?this.getApp().toast.success(`Broadcast command "${e.command}" sent successfully`):this.getApp().toast.error(t.data?.error||"Failed to broadcast command")}catch(t){console.error("Failed to broadcast command:",t),this.getApp().toast.error("Error broadcasting command: "+t.message)}}async executeJobAction(e,t,s){try{e.disabled=!0;const i=e.querySelector("i");i?.classList.add("spinning");const a=await t();if(a.success&&a.data?.status){const e="function"==typeof s?s(a):s;this.getApp().toast.success(e)}else this.getApp().toast.error(a.data?.error||"Operation failed")}catch(i){console.error("Job action failed:",i),this.getApp().toast.error("Error: "+i.message)}finally{e.disabled=!1;const t=e.querySelector("i");t?.classList.remove("spinning")}}}class JobDashboardPage extends e.Page{constructor(e={}){super({title:"Job Engine Dashboard",pageName:"Job Dashboard",className:"job-dashboard-page",...e}),this.pageSubtitle="Async job monitoring and runner management",this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.autoRefreshInterval=null,this.refreshRate=3e4,this.template='\n <div class="job-dashboard-container">\n \x3c!-- Page Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n <small class="text-info">\n <i class="bi bi-arrow-clockwise me-1"></i>\n Auto-refresh: {{refreshRateLabel}} | Last updated: {{lastUpdated}}\n </small>\n </div>\n <div class="btn-group" role="group">\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all" title="Refresh All Data">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n <div class="dropdown">\n <button class="btn btn-outline-secondary btn-sm dropdown-toggle"\n type="button" data-bs-toggle="dropdown">\n <i class="bi bi-gear"></i> Settings\n </button>\n <ul class="dropdown-menu dropdown-menu-end">\n <li><h6 class="dropdown-header">Auto Refresh</h6></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="5">5 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="10">10 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="30">30 seconds</button></li>\n <li><button class="dropdown-item" data-action="set-refresh-rate" data-rate="0">Off</button></li>\n </ul>\n </div>\n </div>\n </div>\n\n \x3c!-- Stats Cards --\x3e\n <div data-container="job-stats"></div>\n\n \x3c!-- Charts + Health --\x3e\n <div data-container="job-overview"></div>\n\n \x3c!-- Operations --\x3e\n <div class="mt-4" data-container="job-operations"></div>\n </div>\n '}async onInit(){this.getApp()?.showLoading("Loading Job Engine...");try{this.jobStats=new a.JobsEngineStats,this.jobStatsView=new JobStatsView({containerId:"job-stats",model:this.jobStats}),this.addChild(this.jobStatsView),this.overviewSection=new JobOverviewSection({containerId:"job-overview",model:this.jobStats}),this.addChild(this.overviewSection),this.operationsSection=new JobOperationsSection({containerId:"job-operations",getChannels:()=>{const e=this.jobStats?.attributes;return e?.channels?Object.values(e.channels):[]}}),this.addChild(this.operationsSection),await this.jobStats.fetch()}finally{this.getApp()?.hideLoading()}}startAutoRefresh(){this.autoRefreshInterval&&clearInterval(this.autoRefreshInterval),this.refreshRate>0&&(this.autoRefreshInterval=setInterval(()=>this.refreshData(),this.refreshRate))}async refreshData(){try{await this.jobStats.fetch(),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString(),this.updateHeaderTimestamp()}catch(e){console.error("Failed to refresh jobs dashboard:",e)}}updateHeaderTimestamp(){const e=this.element?.querySelector(".text-info");e&&(e.innerHTML=`\n <i class="bi bi-arrow-clockwise me-1"></i>\n Auto-refresh: ${this.refreshRateLabel} | Last updated: ${this.lastUpdated}\n `)}get refreshRateLabel(){return 0===this.refreshRate?"Off":this.refreshRate/1e3+"s"}async onActionRefreshAll(e,t){try{const e=t.querySelector("i");e?.classList.add("spinning"),t.disabled=!0,await this.refreshData()}finally{const e=t.querySelector("i");e?.classList.remove("spinning"),t.disabled=!1}}async onActionSetRefreshRate(e,t){const s=1e3*parseInt(t.getAttribute("data-rate"));this.refreshRate=s,this.startAutoRefresh(),this.updateHeaderTimestamp();const i=0===s?"Off":s/1e3+"s";this.getApp().toast.success(`Auto-refresh set to ${i}`)}async onEnter(){this.startAutoRefresh()}async onExit(){this.autoRefreshInterval&&(clearInterval(this.autoRefreshInterval),this.autoRefreshInterval=null)}}function D(e){return null==e?"N/A":e>=1e9?(e/1e9).toFixed(2)+" GB":e>=1e6?(e/1e6).toFixed(2)+" MB":e>=1e3?(e/1e3).toFixed(2)+" KB":e+" B"}function V(e){return e>=80?"bg-danger-subtle text-danger":e>=60?"bg-warning-subtle text-warning":"bg-success-subtle text-success"}function M(e){return e>=80?"bg-danger":e>=60?"bg-warning":"bg-success"}class RunnerOverviewTab extends t.View{constructor(e={}){super({className:"runner-overview-tab",...e}),this.model=e.model||null}async onBeforeRender(){const e=this.model;if(!e)return;this.aliveBadgeClass=e.get("alive")?"bg-success-subtle text-success":"bg-danger-subtle text-danger",this.aliveIcon=e.get("alive")?"bi-check-circle-fill":"bi-x-circle-fill",this.aliveText=e.get("alive")?"Alive":"Dead";const t=e.get("started");if(this.startedText=t?new Date(t).toLocaleString():"N/A",t){const e=(Date.now()-new Date(t).getTime())/1e3;this.uptimeText=function(e){const t=Math.floor(e/86400),s=Math.floor(e%86400/3600),i=Math.floor(e%3600/60);return t>0?`${t}d ${s}h ${i}m`:s>0?`${s}h ${i}m`:`${i}m`}(e)}else this.uptimeText="N/A";const s=(i=e.get("last_heartbeat"))?(Date.now()-new Date(i).getTime())/1e3:null;var i;null!==s?(this.heartbeatText=new Date(e.get("last_heartbeat")).toLocaleString(),this.heartbeatAgeText=`${Math.round(s)}s ago`,this.heartbeatClass=s<30?"text-success":s<120?"text-warning":"text-danger"):(this.heartbeatText="N/A",this.heartbeatAgeText="",this.heartbeatClass="text-muted");const a=e.get("jobs_processed")||0,n=e.get("jobs_failed")||0;this.errorRate=a>0?(n/a*100).toFixed(2)+"%":"0.00%"}async getTemplate(){return'\n {{^model}}\n <div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>No runner data available.</div>\n {{/model}}\n\n {{#model}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold">\n <i class="bi bi-info-circle text-primary me-2"></i>Identity\n </h6>\n <span class="badge {{aliveBadgeClass}}">\n <i class="bi {{aliveIcon}} me-1"></i>{{aliveText}}\n </span>\n </div>\n <div class="card-body">\n <div class="row g-3">\n <div class="col-md-6">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase pe-3" style="width:38%;white-space:nowrap;font-size:0.72rem;">Runner ID</td>\n <td class="font-monospace small">{{model.runner_id}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Started</td>\n <td class="small">{{startedText}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Uptime</td>\n <td class="small fw-semibold">{{uptimeText}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Heartbeat</td>\n <td class="small {{heartbeatClass}}">\n {{heartbeatText}}\n {{#heartbeatAgeText}}\n <span class="text-muted">({{heartbeatAgeText}})</span>\n {{/heartbeatAgeText}}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n <div class="col-md-6">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase pe-3" style="width:38%;white-space:nowrap;font-size:0.72rem;">Jobs Done</td>\n <td class="small fw-bold text-success">{{model.jobs_processed|number}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Jobs Failed</td>\n <td class="small fw-bold text-danger">{{model.jobs_failed|number}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase" style="font-size:0.72rem;">Error Rate</td>\n <td class="small">{{errorRate}}</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n {{#model.channels.length}}\n <div class="mt-3 pt-3 border-top">\n <div class="text-muted small fw-semibold text-uppercase mb-2" style="font-size:0.72rem;">Assigned Channels</div>\n <div class="d-flex flex-wrap gap-2">\n {{#model.channels}}\n <span class="badge bg-primary-subtle text-primary px-3 py-2">\n <i class="bi bi-circle-fill me-1" style="font-size:0.4rem;vertical-align:middle;"></i>{{.}}\n </span>\n {{/model.channels}}\n </div>\n </div>\n {{/model.channels.length}}\n </div>\n </div>\n\n <div class="alert alert-light border d-flex align-items-center gap-2 mb-0" style="font-size:0.83rem;">\n <i class="bi bi-cpu text-primary flex-shrink-0"></i>\n <span>CPU, memory, disk, and network detail is in the <strong>System Info</strong> tab.</span>\n </div>\n {{/model}}\n '}}class RunnerSysinfoTab extends t.View{constructor(e={}){super({className:"runner-sysinfo-tab",...e}),this.model=e.model||null,this.sysinfo=null,this.sysinfoError=null,this.loading=!1,this.loaded=!1}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,this.sysinfoError=null,await this.render(),await this.loadSysinfo(),this.loading=!1,await this.render())}async loadSysinfo(){try{const e=await this.getApp().rest.GET(`/api/jobs/runners/sysinfo/${this.model.get("runner_id")}`);if(e.success&&e.data){const t=e.data.data||e.data;if(t&&"error"===t.status)return void(this.sysinfoError=t.error||"Runner reported an error collecting sysinfo.");if(!e.data.status)return void(this.sysinfoError=e.data.error||"Could not load system info.");this.sysinfo=t.result||t,this.enrichSysinfo()}else this.sysinfoError="Could not load system info."}catch(e){this.sysinfoError=e.message||"Request failed."}}enrichSysinfo(){const e=this.sysinfo;if(!e)return;e.memory&&(e.memory.totalFmt=D(e.memory.total),e.memory.usedFmt=D(e.memory.used),e.memory.availableFmt=D(e.memory.available),e.memory.barClass=M(e.memory.percent),e.memory.badgeClass=V(e.memory.percent)),e.disk&&(e.disk.totalFmt=D(e.disk.total),e.disk.usedFmt=D(e.disk.used),e.disk.freeFmt=D(e.disk.free),e.disk.barClass=M(e.disk.percent),e.disk.badgeClass=V(e.disk.percent)),e.network&&(e.network.bytesRecvFmt=D(e.network.bytes_recv),e.network.bytesSentFmt=D(e.network.bytes_sent),e.network.errClass=e.network.errin>0||e.network.errout>0?"text-danger fw-bold":"text-success",e.network.dropClass=e.network.dropin>0||e.network.dropout>0?"text-warning fw-bold":"text-success");const t=e.cpu_load||0;e.cpuLoadBarClass=M(t),e.cpuLoadBadgeClass=V(t),e.cpu&&e.cpu.freq?e.cpu.freqText=`${Math.round(e.cpu.freq.current).toLocaleString()} MHz current · ${Math.round(e.cpu.freq.max).toLocaleString()} MHz max`:e.cpu&&(e.cpu.freqText=null),e.cpus_load&&e.cpus_load.length?e.cpuCores=e.cpus_load.map((e,t)=>({index:t,pct:e.toFixed(1),barClass:M(e)})):e.cpuCores=[],e.bootDatetime=e.boot_time?new Date(1e3*e.boot_time).toLocaleString():null,e.collectedText=e.datetime||null}async onActionRefreshSysinfo(){this.loaded=!1,this.sysinfo=null,this.sysinfoError=null,await this.onTabActivated()}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading system info…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n\n {{#sysinfoError|bool}}\n <div class="alert alert-warning d-flex align-items-start gap-2">\n <i class="bi bi-exclamation-triangle flex-shrink-0 mt-1"></i>\n <div>\n <strong>Could not load system info</strong><br>\n <span class="small">{{sysinfoError}}</span><br>\n <button class="btn btn-sm btn-outline-warning mt-2" data-action="refresh-sysinfo">\n <i class="bi bi-arrow-clockwise me-1"></i>Retry\n </button>\n </div>\n </div>\n {{/sysinfoError|bool}}\n\n {{#sysinfo|bool}}\n\n <div class="d-flex justify-content-end align-items-center gap-3 mb-3">\n {{#sysinfo.collectedText}}\n <small class="text-muted">Collected {{sysinfo.collectedText}}</small>\n {{/sysinfo.collectedText}}\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-sysinfo">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n \x3c!-- OS --\x3e\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-hdd-rack text-primary me-2"></i>Operating System</h6>\n </div>\n <div class="card-body p-0">\n <table class="table table-sm table-borderless mb-0">\n <tbody>\n {{#sysinfo.os}}\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="width:20%;font-size:0.72rem;white-space:nowrap;">Hostname</td>\n <td class="font-monospace small">{{.hostname}}</td>\n <td class="text-muted small fw-semibold text-uppercase pe-2" style="width:15%;font-size:0.72rem;white-space:nowrap;">OS</td>\n <td class="small">{{.system}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Release</td>\n <td class="font-monospace small">{{.release}}</td>\n <td class="text-muted small fw-semibold text-uppercase pe-2" style="font-size:0.72rem;">Machine</td>\n <td class="font-monospace small">{{.machine}}</td>\n </tr>\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Version</td>\n <td colspan="3" class="font-monospace small text-muted" style="font-size:0.76rem;">{{.version}}</td>\n </tr>\n {{/sysinfo.os}}\n {{#sysinfo.bootDatetime}}\n <tr>\n <td class="text-muted small fw-semibold text-uppercase ps-3 pe-2" style="font-size:0.72rem;">Boot Time</td>\n <td colspan="3" class="small">{{sysinfo.bootDatetime}}</td>\n </tr>\n {{/sysinfo.bootDatetime}}\n </tbody>\n </table>\n </div>\n </div>\n\n \x3c!-- CPU --\x3e\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-cpu text-primary me-2"></i>CPU</h6>\n <span class="badge {{sysinfo.cpuLoadBadgeClass}}">{{sysinfo.cpu_load}}% overall</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">Overall Load</small>\n <small class="fw-bold">{{sysinfo.cpu_load}}%</small>\n </div>\n <div class="progress mb-2" style="height:8px;">\n <div class="progress-bar {{sysinfo.cpuLoadBarClass}}" style="width:{{sysinfo.cpu_load}}%;"></div>\n </div>\n {{#sysinfo.cpu}}\n <div class="text-muted small mb-3">\n {{.count}} logical cores\n {{#.freqText}} · {{.freqText}}{{/.freqText}}\n </div>\n {{/sysinfo.cpu}}\n\n {{#sysinfo.cpuCores.length}}\n <div class="row g-2">\n {{#sysinfo.cpuCores}}\n <div class="col-6 col-md-3">\n <div class="border rounded p-2 bg-light text-center">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.65rem;">Core {{.index}}</div>\n <div class="fw-bold small">{{.pct}}%</div>\n <div class="progress mt-1" style="height:4px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.pct}}%;"></div>\n </div>\n </div>\n </div>\n {{/sysinfo.cpuCores}}\n </div>\n {{/sysinfo.cpuCores.length}}\n </div>\n </div>\n\n \x3c!-- Memory --\x3e\n {{#sysinfo.memory}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-memory text-primary me-2"></i>Memory</h6>\n <span class="badge {{.badgeClass}}">{{.percent}}% used</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">RAM Usage</small>\n <small class="fw-bold">{{.usedFmt}} / {{.totalFmt}}</small>\n </div>\n <div class="progress mb-3" style="height:8px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.percent}}%;"></div>\n </div>\n <div class="row g-2 text-center">\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Total</div>\n <div class="fw-semibold small">{{.totalFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Used</div>\n <div class="fw-semibold small">{{.usedFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Available</div>\n <div class="fw-semibold small text-success">{{.availableFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Percent</div>\n <div class="fw-semibold small">{{.percent}}%</div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.memory}}\n\n \x3c!-- Disk --\x3e\n {{#sysinfo.disk}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-hdd text-primary me-2"></i>Disk (Root)</h6>\n <span class="badge {{.badgeClass}}">{{.percent}}% used</span>\n </div>\n <div class="card-body">\n <div class="d-flex justify-content-between mb-1">\n <small class="fw-semibold text-muted">Disk Usage</small>\n <small class="fw-bold">{{.usedFmt}} / {{.totalFmt}}</small>\n </div>\n <div class="progress mb-3" style="height:8px;">\n <div class="progress-bar {{.barClass}}" style="width:{{.percent}}%;"></div>\n </div>\n <div class="row g-2 text-center">\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Total</div>\n <div class="fw-semibold small">{{.totalFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Used</div>\n <div class="fw-semibold small">{{.usedFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Free</div>\n <div class="fw-semibold small text-success">{{.freeFmt}}</div>\n </div>\n <div class="col-6 col-md-3">\n <div class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;">Percent</div>\n <div class="fw-semibold small">{{.percent}}%</div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.disk}}\n\n \x3c!-- Network --\x3e\n {{#sysinfo.network}}\n <div class="card border-0 shadow-sm mb-3">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-diagram-3 text-primary me-2"></i>Network</h6>\n </div>\n <div class="card-body">\n <div class="row g-2">\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-down text-primary me-1"></i>Bytes Recv</div>\n <div class="fw-bold font-monospace small">{{.bytesRecvFmt}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-up text-primary me-1"></i>Bytes Sent</div>\n <div class="fw-bold font-monospace small">{{.bytesSentFmt}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-share text-primary me-1"></i>TCP Connections</div>\n <div class="fw-bold font-monospace small">{{.tcp_cons|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-down me-1"></i>Packets Recv</div>\n <div class="fw-bold font-monospace small">{{.packets_recv|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-arrow-up me-1"></i>Packets Sent</div>\n <div class="fw-bold font-monospace small">{{.packets_sent|number}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-exclamation-triangle me-1"></i>Errors In / Out</div>\n <div class="fw-bold font-monospace small {{.errClass}}">{{.errin}} / {{.errout}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-x-circle me-1"></i>Drops In</div>\n <div class="fw-bold font-monospace small {{.dropClass}}">{{.dropin}}</div>\n </div>\n </div>\n <div class="col-6 col-md-4">\n <div class="border rounded p-2 bg-light">\n <div class="text-muted fw-semibold text-uppercase mb-1" style="font-size:0.67rem;"><i class="bi bi-x-circle me-1"></i>Drops Out</div>\n <div class="fw-bold font-monospace small {{.dropClass}}">{{.dropout}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n {{/sysinfo.network}}\n\n \x3c!-- Logged-in Users --\x3e\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-white border-bottom py-2">\n <h6 class="mb-0 fw-semibold"><i class="bi bi-person-badge text-primary me-2"></i>Logged-in Users</h6>\n </div>\n {{#sysinfo.users.length}}\n <ul class="list-group list-group-flush">\n {{#sysinfo.users}}\n <li class="list-group-item font-monospace small">{{.name|default:\'unknown\'}}\n {{#.terminal}}<span class="text-muted ms-2">{{.terminal}}</span>{{/.terminal}}\n </li>\n {{/sysinfo.users}}\n </ul>\n {{/sysinfo.users.length}}\n {{^sysinfo.users.length}}\n <div class="card-body text-center text-muted py-4">\n <i class="bi bi-person-x fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No users currently logged in</div>\n </div>\n {{/sysinfo.users.length}}\n </div>\n\n {{/sysinfo|bool}}\n {{/loading|bool}}\n '}}class RunnerJobsTab extends t.View{constructor(e={}){super({className:"runner-jobs-tab",...e}),this.model=e.model||null,this.jobs=[],this.loading=!1,this.loaded=!1}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,await this.render(),await this.loadJobs(),this.loading=!1,await this.render())}async loadJobs(){try{const e=await this.getApp().rest.GET(`/api/jobs/job?runner_id=${this.model.get("runner_id")}&status=running&size=50`);if(e.success&&e.data&&e.data.status){const t=Date.now()/1e3;this.jobs=(e.data.data||[]).map(e=>{return{...e,durationText:e.started_at?(s=t-new Date(e.started_at).getTime()/1e3,s<60?`${Math.round(s)}s`:s<3600?`${Math.round(s/60)}m ${Math.round(s%60)}s`:`${Math.round(s/3600)}h ${Math.round(s%3600/60)}m`):"N/A",startedText:e.started_at?new Date(e.started_at).toLocaleTimeString():"N/A",attemptBadgeClass:e.attempt>1?"bg-danger-subtle text-danger":"bg-warning-subtle text-warning"};var s})}else this.jobs=[]}catch(e){this.jobs=[],this.showError("Could not load running jobs: "+e.message)}}async onActionRefreshJobs(){this.loaded=!1,this.jobs=[],await this.onTabActivated()}async onActionViewJob(e,t){const s=t.dataset.jobId;this.emit("job:view",{jobId:s,runner:this.model})}async onActionCancelJob(e,t){const i=t.dataset.jobId;if(await s.Dialog.confirm("Cancel this job? The runner will receive a cooperative cancel signal.","Cancel Job",{confirmText:"Cancel Job",confirmClass:"btn-warning"}))try{const e=await this.getApp().rest.POST(`/api/jobs/job/${i}`,{cancel_request:!0});e.success&&e.data&&e.data.status?(this.showSuccess("Cancel signal sent."),this.loaded=!1,await this.onTabActivated()):this.showError(e.data&&e.data.error||"Could not cancel job.")}catch(a){this.showError("Could not cancel job: "+a.message)}}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading running jobs…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n <div class="d-flex justify-content-between align-items-center mb-3">\n <small class="text-muted">{{jobs.length}} job(s) currently executing on this runner</small>\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-jobs">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n {{#jobs.length}}\n <div class="card border-0 shadow-sm">\n <div class="table-responsive">\n <table class="table table-sm table-hover align-middle mb-0">\n <thead class="table-light">\n <tr>\n <th class="ps-3 border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Job ID</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Function</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Channel</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Started</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Duration</th>\n <th class="border-0 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Attempt</th>\n <th class="border-0 text-end pe-3 text-muted fw-semibold text-uppercase" style="font-size:0.72rem;letter-spacing:0.04em;">Actions</th>\n </tr>\n </thead>\n <tbody>\n {{#jobs}}\n <tr>\n <td class="ps-3">\n <span class="font-monospace text-primary small" title="{{.id}}">{{.id|truncate:12}}</span>\n </td>\n <td>\n <span class="font-monospace text-muted small" title="{{.func}}">{{.func|truncate:42}}</span>\n </td>\n <td>\n <span class="badge bg-primary-subtle text-primary">{{.channel}}</span>\n </td>\n <td><small class="text-muted">{{.startedText}}</small></td>\n <td><span class="badge bg-light text-secondary border">{{.durationText}}</span></td>\n <td><span class="badge {{.attemptBadgeClass}}">{{.attempt}}</span></td>\n <td class="text-end pe-3">\n <div class="btn-group btn-group-sm">\n <button class="btn btn-outline-primary btn-sm" data-action="view-job"\n data-job-id="{{.id}}" title="View job details">\n <i class="bi bi-eye"></i>\n </button>\n <button class="btn btn-outline-warning btn-sm" data-action="cancel-job"\n data-job-id="{{.id}}" title="Cancel job">\n <i class="bi bi-x-circle"></i>\n </button>\n </div>\n </td>\n </tr>\n {{/jobs}}\n </tbody>\n </table>\n </div>\n </div>\n {{/jobs.length}}\n\n {{^jobs.length}}\n <div class="text-center text-muted py-5">\n <i class="bi bi-list-task fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No jobs currently executing on this runner</div>\n </div>\n {{/jobs.length}}\n {{/loading|bool}}\n '}}class RunnerLogsTab extends t.View{constructor(e={}){super({className:"runner-logs-tab",...e}),this.model=e.model||null,this.logs=[],this.filteredLogs=[],this.logFilter="all",this.loading=!1,this.loaded=!1,this.filterAllClass="btn-primary",this.filterDebugClass="btn-outline-secondary",this.filterInfoClass="btn-outline-primary",this.filterWarnClass="btn-outline-warning",this.filterErrorClass="btn-outline-danger"}async onTabActivated(){this.loaded||(this.loaded=!0,this.loading=!0,await this.render(),await this.loadLogs(),this.loading=!1,await this.render())}async loadLogs(){try{const e=await this.getApp().rest.GET(`/api/jobs/job?runner_id=${this.model.get("runner_id")}&status=running&size=50`),t=[];if(e.success&&e.data&&e.data.status&&(e.data.data||[]).forEach(e=>t.push(e.id)),!t.length)return void(this.logs=[]);const s=t.slice(0,5).map(e=>this.getApp().rest.GET(`/api/jobs/logs?job_id=${e}&sort=-created&size=20`).then(e=>e.success&&e.data&&e.data.status&&e.data.data||[]).catch(()=>[])),i=await Promise.all(s),a=[].concat(...i);a.sort((e,t)=>new Date(t.created)-new Date(e.created)),this.logs=a.slice(0,50).map(e=>({...e,levelBadgeClass:this.getLogLevelClass(e.kind),kindDisplay:(e.kind||"info").toUpperCase(),createdText:new Date(e.created).toLocaleTimeString()}))}catch(e){this.logs=[],this.showError("Could not load logs: "+e.message)}}getLogLevelClass(e){return{debug:"bg-secondary-subtle text-secondary",info:"bg-primary-subtle text-primary",warn:"bg-warning-subtle text-warning",error:"bg-danger-subtle text-danger"}[e]||"bg-secondary-subtle text-secondary"}async onBeforeRender(){this.filteredLogs="all"===this.logFilter?this.logs:this.logs.filter(e=>e.kind===this.logFilter),this.filterAllClass="all"===this.logFilter?"btn-primary":"btn-outline-secondary",this.filterDebugClass="debug"===this.logFilter?"btn-secondary":"btn-outline-secondary",this.filterInfoClass="info"===this.logFilter?"btn-primary":"btn-outline-primary",this.filterWarnClass="warn"===this.logFilter?"btn-warning":"btn-outline-warning",this.filterErrorClass="error"===this.logFilter?"btn-danger":"btn-outline-danger"}async onActionFilterLogs(e,t){this.logFilter=t.dataset.kind||"all",await this.render()}async onActionRefreshLogs(){this.loaded=!1,this.logs=[],this.logFilter="all",await this.onTabActivated()}async getTemplate(){return'\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border text-primary" role="status"></div>\n <div class="mt-2 text-muted small">Loading logs…</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-white border-bottom py-2 d-flex align-items-center gap-2 flex-wrap">\n <small class="text-muted fw-semibold me-1">Filter:</small>\n <button class="btn btn-sm {{filterAllClass}}" data-action="filter-logs" data-kind="all">All</button>\n <button class="btn btn-sm {{filterDebugClass}}" data-action="filter-logs" data-kind="debug">Debug</button>\n <button class="btn btn-sm {{filterInfoClass}}" data-action="filter-logs" data-kind="info">Info</button>\n <button class="btn btn-sm {{filterWarnClass}}" data-action="filter-logs" data-kind="warn">Warning</button>\n <button class="btn btn-sm {{filterErrorClass}}" data-action="filter-logs" data-kind="error">Error</button>\n <div class="ms-auto d-flex align-items-center gap-2">\n <small class="text-muted">{{filteredLogs.length}} entries</small>\n <button class="btn btn-sm btn-outline-secondary" data-action="refresh-logs">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n </div>\n\n <div style="max-height:420px;overflow-y:auto;">\n {{#filteredLogs.length}}\n {{#filteredLogs}}\n <div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom font-monospace" style="font-size:0.78rem;">\n <span class="text-muted flex-shrink-0 pt-1" style="min-width:65px;">{{.createdText}}</span>\n <span class="badge {{.levelBadgeClass}} flex-shrink-0" style="margin-top:1px;">{{.kindDisplay}}</span>\n <span class="flex-grow-1 text-break">{{.message}}</span>\n </div>\n {{/filteredLogs}}\n {{/filteredLogs.length}}\n\n {{^filteredLogs.length}}\n <div class="text-center text-muted py-5">\n <i class="bi bi-journal fs-2 d-block mb-2 opacity-50"></i>\n <div class="small">No log entries</div>\n </div>\n {{/filteredLogs.length}}\n </div>\n </div>\n {{/loading|bool}}\n '}}class RunnerActionsTab extends t.View{constructor(e={}){super({className:"runner-actions-tab",...e}),this.model=e.model||null,this.pingResult=null}async onActionPing(){this.pingResult=null,await this.render();try{const e=await this.getApp().rest.POST("/api/jobs/runners/ping",{runner_id:this.model.get("runner_id"),timeout:2});e.success&&e.data?this.pingResult=e.data.responsive?'<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Runner is responsive</span>':'<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>Runner did not respond within 2s</span>':this.pingResult='<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Ping request failed</span>'}catch(e){this.pingResult=`<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`}await this.render()}async onActionShutdown(){if(await s.Dialog.confirm(`Send a graceful shutdown to <strong class="font-monospace">${this.model.get("runner_id")}</strong>?<br><br>The runner will finish its current job then exit. This is fire-and-forget.`,"Shutdown Runner",{confirmText:"Shutdown",confirmClass:"btn-danger"}))try{const e=await this.getApp().rest.POST("/api/jobs/runners/shutdown",{runner_id:this.model.get("runner_id"),graceful:!0});e.success&&e.data&&e.data.status?(this.showSuccess("Shutdown command sent to runner."),this.emit("runner:shutdown",{runner:this.model})):this.showError(e.data&&e.data.error||"Shutdown command failed.")}catch(e){this.showError("Shutdown failed: "+e.message)}}async onActionBroadcast(){const e=this.element&&this.element.querySelector('[data-field="broadcast-command"]'),t=this.element&&this.element.querySelector('[data-field="broadcast-timeout"]'),i=e?e.value:"status",a=t&&parseFloat(t.value)||2;s.Dialog.showBusy({message:`Broadcasting "${i}" to all runners…`});try{const e=await this.getApp().rest.POST("/api/jobs/runners/broadcast",{command:i,timeout:a});s.Dialog.hideBusy(),e.success&&e.data?await s.Dialog.showCode(JSON.stringify(e.data,null,2),"json",{title:`Broadcast Response — ${i}`,size:"lg"}):this.showError(e.data&&e.data.error||"Broadcast failed.")}catch(n){s.Dialog.hideBusy(),this.showError("Broadcast failed: "+n.message)}}async onActionExport(){try{const e={runner:this.model.toJSON?this.model.toJSON():this.model,exported_at:/* @__PURE__ */(new Date).toISOString()},t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),s=URL.createObjectURL(t),i=Object.assign(document.createElement("a"),{href:s,download:`runner-${this.model.get("runner_id")}-${Date.now()}.json`});document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(s),this.showSuccess("Runner data exported.")}catch(e){this.showError("Export failed: "+e.message)}}async getTemplate(){return'\n <p class="text-muted small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Actions operate on runner <strong class="font-monospace">{{model.runner_id}}</strong> unless otherwise noted.\n </p>\n\n <div class="d-flex align-items-center gap-2 mb-3">\n <span class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;letter-spacing:0.09em;white-space:nowrap;">Runner Control</span>\n <hr class="flex-grow-1 my-0">\n </div>\n\n <div class="row g-3 mb-4">\n\n \x3c!-- Ping --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-success-subtle text-success flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-broadcast-pin"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Ping Runner</div>\n <div class="text-muted small">Verify this runner is truly responsive, not just alive on paper.</div>\n </div>\n </div>\n {{#pingResult|bool}}\n <div class="small">{{{pingResult}}}</div>\n {{/pingResult|bool}}\n <button class="btn btn-sm btn-outline-success mt-auto" data-action="ping">\n <i class="bi bi-broadcast-pin me-1"></i>Ping Now\n </button>\n </div>\n </div>\n </div>\n\n \x3c!-- Shutdown --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-danger-subtle text-danger flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-power"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Graceful Shutdown</div>\n <div class="text-muted small">Runner finishes its current job then exits. Fire-and-forget.</div>\n </div>\n </div>\n <button class="btn btn-sm btn-outline-danger mt-auto" data-action="shutdown">\n <i class="bi bi-power me-1"></i>Shutdown\n </button>\n </div>\n </div>\n </div>\n\n \x3c!-- Export --\x3e\n <div class="col-md-4">\n <div class="card border-0 shadow-sm h-100">\n <div class="card-body d-flex flex-column gap-3">\n <div class="d-flex gap-3 align-items-start">\n <div class="d-flex align-items-center justify-content-center rounded bg-secondary-subtle text-secondary flex-shrink-0"\n style="width:40px;height:40px;font-size:1.1rem;">\n <i class="bi bi-download"></i>\n </div>\n <div>\n <div class="fw-semibold mb-1">Export Snapshot</div>\n <div class="text-muted small">Download runner identity data as a JSON file.</div>\n </div>\n </div>\n <button class="btn btn-sm btn-outline-secondary mt-auto" data-action="export">\n <i class="bi bi-download me-1"></i>Export JSON\n </button>\n </div>\n </div>\n </div>\n\n </div>\n\n <div class="d-flex align-items-center gap-2 mb-3">\n <span class="text-muted fw-semibold text-uppercase" style="font-size:0.7rem;letter-spacing:0.09em;white-space:nowrap;">Broadcast Command</span>\n <hr class="flex-grow-1 my-0">\n </div>\n\n <div class="card border-0 shadow-sm">\n <div class="card-body">\n <p class="text-muted small mb-3">\n Send a command to <strong>all active runners</strong> simultaneously and collect replies within the timeout window.\n </p>\n <div class="row g-2 align-items-end">\n <div class="col-md-4">\n <label class="form-label fw-semibold small text-muted mb-1">Command</label>\n <select class="form-select form-select-sm" data-field="broadcast-command">\n <option value="status">status</option>\n <option value="pause">pause</option>\n <option value="resume">resume</option>\n <option value="reload">reload</option>\n <option value="shutdown">shutdown</option>\n </select>\n </div>\n <div class="col-md-3">\n <label class="form-label fw-semibold small text-muted mb-1">Timeout (s)</label>\n <input type="number" class="form-control form-control-sm"\n data-field="broadcast-timeout" value="2.0" min="0.5" step="0.5" />\n </div>\n <div class="col-md-5">\n <button class="btn btn-primary btn-sm w-100" data-action="broadcast">\n <i class="bi bi-megaphone me-1"></i>Broadcast to All Runners\n </button>\n </div>\n </div>\n </div>\n </div>\n '}}class RunnerDetailsView extends t.View{constructor(e={}){super({className:"runner-details-view",...e}),this.model=e.model instanceof a.JobRunner?e.model:new a.JobRunner(e.model||e.data||{})}async onInit(){if(!this.model)return;const e=new a.TabView({containerId:"runner-tabs",tabs:{Overview:new RunnerOverviewTab({model:this.model}),"System Info":new RunnerSysinfoTab({model:this.model}),"Running Jobs":new RunnerJobsTab({model:this.model}),Logs:new RunnerLogsTab({model:this.model}),Actions:new RunnerActionsTab({model:this.model})}});this.addChild(e)}async getTemplate(){return'<div data-container="runner-tabs"></div>'}static async show(e,t={}){const i=e instanceof a.JobRunner?e:new a.JobRunner(e),n=new RunnerDetailsView({model:i});return await s.Dialog.showDialog({title:`<i class="bi bi-cpu me-2"></i><span class="font-monospace">${i.get("runner_id")}</span>`,body:n,size:"xl",scrollable:!0,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}],...t})}}a.JobRunner.VIEW_CLASS=RunnerDetailsView;class JobRunnersSection extends t.View{constructor(e={}){super({className:"job-runners-section",template:'\n <div class="card shadow-sm h-100">\n <div class="card-header d-flex justify-content-between align-items-center">\n <h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Job Runners</h5>\n <small class="text-muted">Heartbeat & status</small>\n </div>\n <div class="card-body p-0" data-container="runner-table"></div>\n </div>\n ',...e})}async onInit(){this.runnersTable=new n.TableView({containerId:"runner-table",Collection:a.JobRunnerList,searchable:!0,filterable:!1,paginated:!0,itemView:RunnerDetailsView,viewDialogOptions:{title:"Runner Details",size:"xl",scrollable:!0},tableOptions:{striped:!1,hover:!0,size:"sm"},columns:[{key:"runner_id",label:"Runner",formatter:"truncate_middle(16)"},{key:"alive",label:"Status",formatter:e=>{const t=!0===e;return`<span class="badge ${t?"bg-success":"bg-danger"}"><i class="${t?"bi-check-circle-fill":"bi-x-octagon-fill"} me-1"></i>${t?"ALIVE":"DEAD"}</span>`}},{key:"last_heartbeat",label:"Heartbeat",formatter:e=>{if(!e)return"Never";const t=new Date(e),s=/* @__PURE__ */new Date-t,i=Math.floor(s/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:`${Math.floor(i/3600)}h ago`}}]}),this.addChild(this.runnersTable)}}class JobRunnersPage extends e.Page{constructor(e={}){super({title:"Job Runners",pageName:"Job Runners",className:"job-runners-page",...e}),this.template='\n <div class="job-runners-container">\n <p class="text-muted mb-3">Registered job runners and their heartbeat status</p>\n <div data-container="runners-section"></div>\n </div>\n '}async onInit(){this.runnersSection=new JobRunnersSection({containerId:"runners-section"}),this.addChild(this.runnersSection)}}class JobDetailsView extends t.View{constructor(e={}){super({className:"job-details-view",...e}),this.model=e.model||new a.Job(e.data||{}),this.tabView=null,this.overviewView=null,this.payloadView=null,this.eventsView=null,this.logsView=null,this.autoRefreshInterval=null,this.template='\n <div class="job-details-container">\n \x3c!-- Job Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left Side: Primary Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">\n <i class="bi {{model.statusIcon}} text-secondary" style="font-size: 40px;"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.func|truncate(32)|default(\'Unknown Function\')}}</h3>\n <div class="text-muted small">\n <span>ID: {{model.id}}</span>\n <span class="mx-2">|</span>\n <span>Channel: <span class="badge bg-primary">{{model.channel}}</span></span>\n {{#model.runner_id}}\n <span class="mx-2">|</span>\n <span>Runner: {{model.runner_id|truncate(16)}}</span>\n {{/model.runner_id}}\n </div>\n <div class="text-muted small mt-2">\n <div>Created: {{model.created|datetime}}</div>\n {{#model.started_at}}\n <div>Started: {{model.started_at|datetime}}</div>\n {{/model.started_at}}\n {{#model.finished_at}}\n <div>Finished: {{model.finished_at|datetime}}</div>\n {{/model.finished_at}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center gap-2">\n <span class="badge {{model.statusBadgeClass}} fs-6">\n <i class="bi {{model.statusIcon}}"></i> {{model.status|uppercase}}\n </span>\n {{#model.cancel_requested}}\n <span class="badge bg-warning ms-1">\n <i class="bi bi-exclamation-triangle"></i> Cancel Requested\n </span>\n {{/model.cancel_requested}}\n </div>\n {{#model.formattedDuration}}\n <div class="text-muted small mt-1">Duration: {{model.formattedDuration}}</div>\n {{/model.formattedDuration}}\n </div>\n <div data-container="job-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tab Container --\x3e\n <div data-container="job-details-tabs"></div>\n </div>\n '}async onInit(){this.overviewView=new t.View({template:'\n <div class="job-overview-tab">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <div class="row">\n <div class="col-md-6">\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Job ID</label>\n <div class="font-monospace">{{model.id}}</div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Function</label>\n <div class="font-monospace">{{model.func}}</div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Channel</label>\n <div>\n <span class="badge bg-primary">{{model.channel}}</span>\n </div>\n </div>\n {{#model.runner_id}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Runner</label>\n <div class="font-monospace small">{{model.runner_id}}</div>\n </div>\n {{/model.runner_id}}\n </div>\n <div class="col-md-6">\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Status</label>\n <div>\n <span class="badge {{model.statusBadgeClass}} fs-6">\n <i class="bi {{model.statusIcon}}"></i> {{model.status|uppercase}}\n </span>\n {{#model.cancel_requested}}\n <span class="badge bg-warning ms-1">\n <i class="bi bi-exclamation-triangle"></i> Cancel Requested\n </span>\n {{/model.cancel_requested}}\n </div>\n </div>\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Created</label>\n <div>{{model.created|datetime}}</div>\n </div>\n {{#model.started_at}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Started</label>\n <div>{{model.started_at|datetime}}</div>\n </div>\n {{/model.started_at}}\n {{#model.finished_at}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Finished</label>\n <div>{{model.finished_at|datetime}}</div>\n </div>\n {{/model.finished_at}}\n {{#model.duration_ms}}\n <div class="mb-3">\n <label class="form-label fw-bold text-muted small">Duration</label>\n <div>{{model.formattedDuration}}</div>\n </div>\n {{/model.duration_ms}}\n </div>\n </div>\n </div>\n </div>\n </div>\n ',model:this.model}),this.payloadView=new t.View({template:'\n <div class="job-payload-tab">\n <pre class="bg-light p-3 rounded"><code>{{{model.payload|json}}}</code></pre>\n </div>\n ',model:this.model});const s=new a.JobEventList({params:{job:this.model.get("id"),size:10}});this.eventsView=new n.TableView({collection:s,hideActivePillNames:["job"],columns:[{key:"at",label:"Timestamp",formatter:"datetime",sortable:!0},{key:"event",label:"Event",formatter:"badge"},{key:"details|json",label:"Details"}]});const i=new a.JobLogList({params:{job:this.model.get("id"),size:10}});this.logsView=new n.TableView({collection:i,hideActivePillNames:["job"],columns:[{key:"created|datetime",label:"Created",sortable:!0},{key:"kind",label:"Kind",formatter:"badge"},{key:"message",label:"Message"}]}),this.tabView=new a.TabView({tabs:{Overview:this.overviewView,Payload:this.payloadView,Events:this.eventsView,Logs:this.logsView},activeTab:"Overview",containerId:"job-details-tabs"}),this.addChild(this.tabView);const o=[{label:"Refresh",action:"refresh-job",icon:"bi-arrow-clockwise"}];this.model.canCancel&&this.model.canCancel()&&o.push({label:"Cancel Job",action:"cancel-job",icon:"bi-x-circle",class:"text-danger"}),this.model.canRetry&&this.model.canRetry()&&o.push({label:"Retry Job",action:"retry-job",icon:"bi-arrow-repeat",class:"text-primary"});const l=new e.ContextMenu({containerId:"job-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:o}});this.addChild(l),await this.model.fetch({params:{graph:"detail"}})}async onBeforeRender(){await this.prepareJobData()}async prepareJobData(){this.model&&(this.model._.statusBadgeClass=this.model.getStatusBadgeClass?this.model.getStatusBadgeClass():"bg-secondary",this.model._.statusIcon=this.model.getStatusIcon?this.model.getStatusIcon():"bi-question-circle",this.model._.formattedDuration=this.model.getFormattedDuration?this.model.getFormattedDuration():"N/A")}async loadJobDetails(){if(this.model?.get("id"))try{this.model.getDetailedStatus&&await this.model.getDetailedStatus(),await this.prepareJobData()}catch(e){console.error("Failed to load job details:",e)}}async onActionRefreshJob(){await this.model.fetch({params:{graph:"detail"}})}async onActionCancelJob(){if(confirm("Are you sure you want to cancel this job?"))try{const e=await this.model.cancel();e.success?(await this.loadJobDetails(),await this.render(),this.emit("job-cancelled",{job:this.model})):alert("Failed to cancel job: "+(e.data?.error||"Unknown error"))}catch(e){console.error("Failed to cancel job:",e),alert("Failed to cancel job: "+e.message)}}async onActionRetryJob(){const e=await s.Dialog.showForm({title:"Retry Job",formConfig:a.JobForms.retry});if(e)try{const t=await this.model.retry(e.delay||0);t.success?this.emit("job-retried",{job:this.model,newJobId:t.newJobId}):alert("Failed to retry job: "+(t.data?.error||"Unknown error"))}catch(t){console.error("Failed to retry job:",t),alert("Failed to retry job: "+t.message)}}startAutoRefresh(){this.autoRefreshInterval&&clearInterval(this.autoRefreshInterval),this.model?.isActive&&this.model.isActive()&&(this.autoRefreshInterval=setInterval(async()=>{try{await this.loadJobDetails(),this.isMounted()&&await this.render()}catch(e){console.error("Auto-refresh failed:",e)}},5e3))}stopAutoRefresh(){this.autoRefreshInterval&&(clearInterval(this.autoRefreshInterval),this.autoRefreshInterval=null)}async onDestroy(){this.stopAutoRefresh(),await super.onDestroy()}static async show(e,t={}){const i=new JobDetailsView({model:e});return await s.Dialog.showDialog({title:`<i class="bi bi-info-circle me-2"></i>Job Details - ${e.get("id")}`,body:i,size:"xl",scrollable:!0,buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}],onHide:()=>i.stopAutoRefresh(),...t})}}a.Job.VIEW_CLASS=JobDetailsView;const R={running:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"runner_id",label:"Runner",template:"\n <span class=\"font-monospace\">{{model.runner_id|truncate_middle(12)|default('n/a')}}</span>\n "},{key:"status",label:"State",formatter:(e,t)=>{const s=t.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${e.toUpperCase()}</span>`}},{key:"created",label:"Started",formatter:"datetime"}],pending:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"priority",label:"Priority",formatter:(e=0)=>`<span class="badge ${e>=8?"bg-danger":e>=5?"bg-warning":"bg-secondary"}">${e}</span>`},{key:"modified",label:"Queued",formatter:"relative"}],scheduled:[{key:"id",label:"Job",formatter:"truncate_middle(12)"},{key:"run_at",label:"Scheduled For",formatter:"datetime"},{key:"channel",label:"Channel",formatter:"badge"}],failed:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id|truncate_middle(12)}}</div>\n <div class="text-muted small">{{model.channel}} · {{model.func|truncate_middle(28)|default(\'n/a\')}}</div>\n '},{key:"last_error",label:"Error",template:"\n <div class=\"text-danger small\">{{model.last_error|truncate(80)|default('Unknown error')}}</div>\n "},{key:"modified",label:"Failed",formatter:"relative"}],all:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace">{{model.id}}</div>\n <div class="text-muted small">{{model.func|default(\'Unknown\')}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge"},{key:"status",label:"Status",formatter:(e,t)=>{const s=t.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${e?.toUpperCase()||"UNKNOWN"}</span>`}},{key:"created",label:"Created",formatter:"datetime"},{key:"finished_at",label:"Finished",formatter:"datetime"},{key:"duration_ms",label:"Duration",formatter:"duration"}]},L=[{key:"status",label:"Status",type:"select",options:[{label:"Pending",value:"pending"},{label:"Running",value:"running"},{label:"Completed",value:"completed"},{label:"Failed",value:"failed"},{label:"Canceled",value:"canceled"},{label:"Expired",value:"expired"}]},{key:"channel",label:"Channel",type:"text"},{key:"func__icontains",label:"Function",type:"text"}],E=[{icon:"bi-x-circle-fill",label:"Cancel Jobs",action:"cancel-jobs"}];class JobTableSection extends t.View{constructor(e={}){const{status:t,sort:s="-created",extraParams:i={},columns:a,title:n,selectable:o=!1,batchActions:l,...r}=e;super({className:"job-table-section",template:'<div data-container="job-table"></div>',...r}),this.status=t,this.sort=s,this.extraParams=i,this.columnConfig=a,this.sectionTitle=n,this.selectable=o,this.batchActionConfig=l}async onInit(){const e={size:25,sort:this.sort,...this.extraParams};this.status&&(e.status=this.status);const t=("string"==typeof this.columnConfig?R[this.columnConfig]:this.columnConfig)||R[this.status]||R.all,s=this.selectable,i=this.batchActionConfig||(s?E:void 0),o=!this.status,l={containerId:"job-table",Collection:a.JobList,collectionParams:e,columns:t,searchable:!0,filterable:o,paginated:!0,itemView:JobDetailsView,hideActivePills:this.status?["status"]:[],viewDialogOptions:{title:"Job Details",size:"xl",scrollable:!0},tableOptions:{striped:!1,hover:!0,size:"sm"}};s&&(l.selectable=!0,l.batchBarLocation="top",l.batchActions=i),o&&(l.filters=L,l.tableOptions.striped=!0,l.tableOptions.responsive=!0),this.tableView=new n.TableView(l),s&&this.tableView.on("action:batch-cancel-jobs",async(e,t,s)=>{const i=this.tableView.getSelectedItems();await Promise.all(i.map(e=>e.model.cancel())),this.getApp().toast.success("Jobs cancelled successfully"),this.tableView.collection.fetch()}),this.addChild(this.tableView)}}class JobsTablePage extends e.Page{constructor(e={}){super({title:"Jobs",pageName:"Jobs",className:"jobs-table-page",...e}),this.template='\n <div class="jobs-table-container">\n <p class="text-muted mb-3">All jobs across all channels and statuses</p>\n <div data-container="jobs-section"></div>\n </div>\n '}async onInit(){this.jobTableSection=new JobTableSection({containerId:"jobs-section",sort:"-created",title:"All Jobs"}),this.addChild(this.jobTableSection)}}class ScheduledTask extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/jobs/scheduled_task",...t})}}class ScheduledTaskList extends t.Collection{constructor(e={}){super({ModelClass:ScheduledTask,endpoint:"/api/jobs/scheduled_task",size:25,...e})}}class TaskResult extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/jobs/task_result",...t})}}class TaskResultList extends t.Collection{constructor(e={}){super({ModelClass:TaskResult,endpoint:"/api/jobs/task_result",size:25,...e})}}const N=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],F={create:{title:"Create Scheduled Task",fields:[{name:"name",type:"text",label:"Name",placeholder:"Daily report",required:!0,columns:12},{name:"description",type:"textarea",label:"Description",placeholder:"What this task does...",columns:12},{name:"task_type",type:"select",label:"Task Type",required:!0,columns:6,options:[{value:"llm",label:"LLM Prompt"},{value:"job",label:"Backend Job"},{value:"webhook",label:"Webhook"}]},{name:"enabled",type:"switch",label:"Enabled",columns:6,value:!0},{name:"run_times",type:"text",label:"Run Times (HH:MM)",placeholder:"09:00",required:!0,columns:6,help:'Comma-separated 24h times, max 2. e.g. "09:00, 17:00"'},{name:"run_days",type:"text",label:"Run Days",placeholder:"0,1,2,3,4",columns:6,help:"Comma-separated day numbers (Mon=0). Leave empty for every day."},{name:"run_once",type:"switch",label:"Run Once",columns:6,help:"Task runs once then disables itself."},{name:"max_retries",type:"number",label:"Max Retries",columns:6,value:0},{name:"notify",type:"text",label:"Notify",placeholder:"in_app, email",columns:12,help:"Comma-separated: email, in_app, sms, push"}]},edit:{title:"Edit Scheduled Task",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"description",type:"textarea",label:"Description",columns:12},{name:"enabled",type:"switch",label:"Enabled",columns:6},{name:"run_times",type:"text",label:"Run Times (HH:MM)",required:!0,columns:6,help:"Comma-separated 24h times, max 2."},{name:"run_days",type:"text",label:"Run Days",columns:6,help:"Comma-separated day numbers (Mon=0). Leave empty for every day."},{name:"run_once",type:"switch",label:"Run Once",columns:6},{name:"max_retries",type:"number",label:"Max Retries",columns:6},{name:"notify",type:"text",label:"Notify",placeholder:"in_app, email",columns:12,help:"Comma-separated: email, in_app, sms, push"}]}};class ScheduledTaskView extends t.View{constructor(e={}){super({className:"scheduled-task-view",...e}),this.model=e.model||new ScheduledTask(e.data||{})}getTemplate(){const e=this.model.get("run_days")||[];this.dayDisplay=0===e.length||7===e.length?"Every day":e.map(e=>N[e]||e).join(", ");const t=this.model.get("run_times")||[];this.timeDisplay=t.join(", ")||"—";const s=this.model.get("notify")||[];return this.notifyDisplay=s.length>0?s.join(", "):"None",'\n <div class="scheduled-task-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-clock-history"></i>\n </div>\n <div>\n <h3 class="mb-1">{{model.name}}</h3>\n <div class="text-muted small">\n {{model.task_type|uppercase}} task\n {{#model.run_once}}\n <span class="mx-1">·</span> <span class="badge bg-info">Run Once</span>\n {{/model.run_once}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.enabled|boolean(\'bg-success\',\'bg-secondary\')}}">\n {{model.enabled|boolean(\'Enabled\',\'Disabled\')}}\n </span>\n </div>\n </div>\n </div>\n\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|relative}}</div>\n </div>\n <div data-container="task-context-menu"></div>\n </div>\n </div>\n\n {{#model.description}}\n <p class="text-muted mb-3">{{model.description}}</p>\n {{/model.description}}\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Schedule</h6>\n <p class="mb-0 small">{{timeDisplay}} · {{dayDisplay}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Notifications</h6>\n <p class="mb-0 small">{{notifyDisplay}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Execution</h6>\n <p class="mb-0 small">\n Runs: {{model.run_count|default(\'0\')}}\n <span class="mx-2">|</span>\n Last run: {{model.last_run|relative|default(\'Never\')}}\n {{#model.max_retries}}\n <span class="mx-2">|</span>\n Max retries: {{model.max_retries}}\n {{/model.max_retries}}\n </p>\n </div>\n {{#model.last_error}}\n <div class="list-group-item list-group-item-danger">\n <h6 class="mb-1 text-muted">Last Error</h6>\n <p class="mb-0 small font-monospace">{{model.last_error}}</p>\n </div>\n {{/model.last_error}}\n {{#model.job_config}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Configuration</h6>\n <pre class="mb-0 small">{{model.job_config|json}}</pre>\n </div>\n {{/model.job_config}}\n </div>\n\n \x3c!-- Recent Results --\x3e\n <h6 class="text-muted mb-2">Recent Results</h6>\n <div data-ref="results-container">\n <div class="text-center text-muted small py-3">Loading...</div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("enabled"),s=new e.ContextMenu({containerId:"task-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-task",icon:"bi-pencil"},t?{label:"Disable",action:"disable-task",icon:"bi-pause-circle"}:{label:"Enable",action:"enable-task",icon:"bi-play-circle"},{type:"divider"},{label:"Delete",action:"delete-task",icon:"bi-trash",danger:!0}]}});this.addChild(s)}async onAfterRender(){await super.onAfterRender(),await this._loadResults()}async _loadResults(){const e=this.element?.querySelector('[data-ref="results-container"]');if(e)try{const t=new TaskResultList({params:{task:this.model.get("id"),sort:"-created",size:10}});await t.fetch();const s=t.models||[];if(0===s.length)return void(e.innerHTML='<div class="text-center text-muted small py-3">No results yet.</div>');let i='<div class="list-group">';s.forEach(e=>{const t=e.get("status"),s=e.get("created");i+=`\n <div class="list-group-item d-flex justify-content-between align-items-center py-2">\n <div class="d-flex align-items-center gap-2">\n <i class="bi ${"success"===t?"bi-check-circle-fill":"bi-x-circle-fill"} ${"success"===t?"text-success":"text-danger"}"></i>\n <span class="small">${this._escapeHtml(t)}</span>\n </div>\n <span class="text-muted small">${this._escapeHtml(s||"")}</span>\n </div>`}),i+="</div>",e.innerHTML=i}catch(t){e.innerHTML='<div class="text-center text-muted small py-3">Failed to load results.</div>'}}async onActionEditTask(){const e=this.getApp();await e.showModelForm({title:`Edit Task — ${this.model.get("name")}`,model:this.model,formConfig:F.edit})&&this.render()}async onActionDisableTask(){const e=this.getApp();e.showLoading();const t=await this.model.save({enabled:!1});e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task disabled"),this.render()):e.toast.error("Failed to disable task")}async onActionEnableTask(){const e=this.getApp();e.showLoading();const t=await this.model.save({enabled:!0});e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task enabled"),this.render()):e.toast.error("Failed to enable task")}async onActionDeleteTask(){const e=this.getApp();if(!(await e.confirm({title:"Delete Scheduled Task",message:`Permanently delete "${this.model.get("name")}" and all its results? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("Task deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete task")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}}ScheduledTask.VIEW_CLASS=ScheduledTaskView,ScheduledTask.ADD_FORM=F.create,ScheduledTask.EDIT_FORM=F.edit;class ScheduledTaskTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_scheduled_tasks",pageName:"Scheduled Tasks",router:"admin/scheduled-tasks",Collection:ScheduledTaskList,itemViewClass:ScheduledTaskView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"name",label:"Name",sortable:!0},{key:"task_type",label:"Type",width:"90px",formatter:"uppercase|badge"},{key:"enabled",label:"Status",width:"100px",formatter:"boolean('Enabled|bg-success','Disabled|bg-secondary')|badge"},{key:"run_times",label:"Schedule",render(e,t){const s=e||[],i=t.get("run_days")||[];return`${s.join(", ")||"—"} · ${0===i.length||7===i.length?"Every day":i.map(e=>N[e]||e).join(", ")}`}},{key:"run_count",label:"Runs",width:"70px",sortable:!0},{key:"last_run",label:"Last Run",formatter:"relative",sortable:!0},{key:"created",label:"Created",formatter:"relative",sortable:!0}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New Task",filters:[{key:"enabled",label:"Status",type:"select",options:[{value:"",label:"All"},{value:"true",label:"Enabled"},{value:"false",label:"Disabled"}]},{key:"task_type",label:"Type",type:"select",options:[{value:"",label:"All"},{value:"llm",label:"LLM"},{value:"job",label:"Job"},{value:"webhook",label:"Webhook"}]}],emptyMessage:"No scheduled tasks found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BlockedIPsTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_blocked_ips",pageName:"Blocked IPs",router:"admin/security/blocked-ips",Collection:a.GeoLocatedIPList,itemViewClass:GeoIPView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-modified",is_blocked:!0},columns:[{key:"ip_address",label:"IP Address",sortable:!0,template:"<code>{{model.ip_address}}</code>"},{key:"threat_level",label:"Threat Level",sortable:!0,filter:{type:"select",options:["none","low","medium","high","critical"]}},{key:"country_code",label:"Country",sortable:!0,filter:{type:"text"}},{key:"city",label:"City",sortable:!0},{key:"blocked_reason",label:"Reason",formatter:"truncate(40)"},{key:"blocked_at",label:"Blocked At",formatter:"datetime",sortable:!0},{key:"blocked_until",label:"Expires",formatter:"datetime",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,selectable:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No blocked IPs. The firewall has no active IP blocks.",batchBarLocation:"top",batchActions:[{label:"Unblock",icon:"bi bi-unlock",action:"unblock"},{label:"Whitelist",icon:"bi bi-check-circle",action:"whitelist"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchUnblock(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Unblock ${e.length} IP${e.length>1?"s":""}?`)&&(await Promise.all(e.map(e=>e.model.save({unblock:"Bulk unblock from admin"}))),this.getApp().toast.success(`${e.length} IP(s) unblocked`),this.tableView.collection.fetch())}async onActionBatchWhitelist(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Whitelist ${e.length} IP${e.length>1?"s":""}?`)&&(await Promise.all(e.map(e=>e.model.save({whitelist:"Bulk whitelist from admin"}))),this.getApp().toast.success(`${e.length} IP(s) whitelisted`),this.tableView.collection.fetch())}}class LogView extends t.View{constructor(e={}){super({className:"log-view",...e}),this.model=e.model||new n.Log(e.data||{}),this.logIcon=this.getIconForLog(this.model.get("level")),this.template='\n <div class="log-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 {{logIcon.color}}">\n <i class="bi {{logIcon.icon}}"></i>\n </div>\n <div>\n <h4 class="mb-1">\n <span class="badge bg-secondary">{{model.method}}</span> {{model.path}}\n </h4>\n <div class="text-muted small">\n {{model.created|datetime}}\n </div>\n </div>\n </div>\n <div data-container="log-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="log-tabs"></div>\n </div>\n '}getIconForLog(e){const t=e?.toLowerCase();return"error"===t||"critical"===t?{icon:"bi-x-octagon-fill",color:"text-danger"}:"warning"===t?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:"info"===t?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-journal-text",color:"text-secondary"}}async onInit(){this.overviewView=new r.default({model:this.model,className:"p-3",columns:2,fields:[{name:"id",label:"Log ID"},{name:"level",label:"Level",format:"badge"},{name:"kind",label:"Kind"},{name:"ip",label:"IP Address",template:'<a href="#" data-action="view-ip">{{model.ip}}</a>'},{name:"uid",label:"User ID"},{name:"username",label:"Username"},{name:"duid",label:"Device ID",template:'<a href="#" data-action="view-device">{{model.duid|truncate_middle(32)}}</a>'},{name:"model_name",label:"Related Model"},{name:"model_id",label:"Related Model ID"},{name:"user_agent",label:"User Agent",columns:12}]});const s=this.model.get("log");let i=s;try{const e=JSON.parse(s);i=JSON.stringify(e,null,2)}catch(o){}this.logContentView=new t.View({template:`\n <div class="position-relative">\n <button class="btn btn-sm btn-outline-secondary position-absolute top-0 end-0 mt-2 me-2" data-action="copy-log">\n <i class="bi bi-clipboard"></i> Copy\n </button>\n <pre class="bg-light p-3 border rounded" style="max-height: 600px; overflow-y: auto;"><code>${i}</code></pre>\n </div>\n `,onActionCopyLog:()=>{navigator.clipboard.writeText(i),this.getApp()?.toast?.success("Log content copied to clipboard.")}}),this.tabView=new a.TabView({containerId:"log-tabs",tabs:{Log:this.logContentView,Details:this.overviewView},activeTab:"Log"}),this.addChild(this.tabView);const n=new e.ContextMenu({containerId:"log-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View User",action:"view-user",icon:"bi-person",disabled:!this.model.get("uid")},{label:"View Device",action:"view-device",icon:"bi-phone",disabled:!this.model.get("duid")},{type:"divider"},{label:"Delete Log",action:"delete-log",icon:"bi-trash",danger:!0}]}});this.addChild(n)}async onActionViewIp(e){e.preventDefault();const t=this.model.get("ip");t&&GeoIPView.show(t)}async onActionViewDevice(e){e.preventDefault();const t=this.model.get("duid");t&&DeviceView.show(t)}async onActionViewUser(){this.model.get("uid")}async onActionDeleteLog(){await s.Dialog.confirm("Are you sure you want to delete this log entry? This action cannot be undone.","Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("log:deleted",{model:this.model})}}n.Log.VIEW_CLASS=LogView;class FirewallLogTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_firewall_log",pageName:"Firewall Log",router:"admin/security/firewall-log",Collection:n.LogList,itemViewClass:LogView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-created",kind__startswith:"firewall:"},columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,filter:{type:"daterange"}},{key:"kind",label:"Action",sortable:!0,filter:{type:"text"}},{key:"message",label:"Details",formatter:"truncate(80)"},{key:"path",label:"IP / Path",formatter:"truncate(40)"},{key:"user",label:"Admin",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No firewall log entries found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BouncerDevice extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/device",...t})}}class BouncerDeviceList extends t.Collection{constructor(e={}){super({ModelClass:BouncerDevice,endpoint:"/api/account/bouncer/device",size:25,...e})}}class BouncerSignal extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/signal",...t})}}class BouncerSignalList extends t.Collection{constructor(e={}){super({ModelClass:BouncerSignal,endpoint:"/api/account/bouncer/signal",size:25,...e})}}class BouncerSignature extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/account/bouncer/signature",...t})}}class BouncerSignatureList extends t.Collection{constructor(e={}){super({ModelClass:BouncerSignature,endpoint:"/api/account/bouncer/signature",size:25,...e})}}const $={create:{title:"Create Signature",fields:[{name:"sig_type",type:"select",label:"Signature Type",required:!0,columns:6,options:[{value:"user_agent",label:"User Agent"},{value:"ip_pattern",label:"IP Pattern"},{value:"fingerprint",label:"Fingerprint"},{value:"behavior",label:"Behavior"},{value:"header",label:"Header"},{value:"cookie",label:"Cookie"}]},{name:"value",type:"text",label:"Value",required:!0,columns:6,help:"The pattern or value to match against."},{name:"confidence",type:"number",label:"Confidence",columns:6,default:80,min:0,max:100,help:"Confidence level from 0 to 100."},{name:"notes",type:"textarea",label:"Notes",columns:12,help:"Optional notes about this signature."},{name:"is_active",type:"switch",label:"Active",columns:6,default:!0}]},edit:{title:"Edit Signature",fields:[{name:"sig_type",type:"select",label:"Signature Type",required:!0,columns:6,options:[{value:"user_agent",label:"User Agent"},{value:"ip_pattern",label:"IP Pattern"},{value:"fingerprint",label:"Fingerprint"},{value:"behavior",label:"Behavior"},{value:"header",label:"Header"},{value:"cookie",label:"Cookie"}]},{name:"value",type:"text",label:"Value",required:!0,columns:6,help:"The pattern or value to match against."},{name:"confidence",type:"number",label:"Confidence",columns:6,default:80,min:0,max:100,help:"Confidence level from 0 to 100."},{name:"notes",type:"textarea",label:"Notes",columns:12,help:"Optional notes about this signature."},{name:"is_active",type:"switch",label:"Active",columns:6,default:!0}]}};class BouncerSignalView extends t.View{constructor(e={}){super({className:"bouncer-signal-view",...e}),this.template='\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 72px; height: 72px;">\n <i class="bi bi-activity text-secondary" style="font-size: 36px;"></i>\n </div>\n <div>\n <h3 class="mb-1">Signal Assessment</h3>\n <div class="text-muted small">\n <span>IP: <code>{{model.ip_address}}</code></span>\n <span class="mx-2">|</span>\n <span>Stage: {{model.stage}}</span>\n <span class="mx-2">|</span>\n <span>Page: {{model.page_type|default(\'—\')}}</span>\n </div>\n <div class="mt-1">\n <span class="badge {{decisionBadge}} me-1">{{model.decision|uppercase}}</span>\n <span class="text-muted small">Risk Score: <strong>{{model.risk_score}}</strong></span>\n </div>\n </div>\n </div>\n <div data-container="signal-context-menu"></div>\n </div>\n <div data-container="signal-tabs"></div>\n '}get decisionBadge(){return{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[this.model?.get("decision")]||"bg-secondary"}async onInit(){await this.model.fetch({params:{graph:"detail"}});const s=new t.View({model:this.model,template:'\n <div class="row">\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Assessment</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Decision</label><div><span class="badge {{decisionBadge}}">{{model.decision|uppercase}}</span></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Risk Score</label><div>{{model.risk_score}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Stage</label><div>{{model.stage}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Page Type</label><div>{{model.page_type|default(\'—\')}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">IP Address</label><div><code>{{model.ip_address}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">MUID</label><div><code>{{model.muid|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Created</label><div>{{model.created|datetime}}</div></div>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Triggered Signals</h6>\n {{#model.triggered_signals.length}}\n <div class="d-flex flex-wrap gap-1">\n {{#model.triggered_signals}}\n <span class="badge bg-warning text-dark">{{.}}</span>\n {{/model.triggered_signals}}\n </div>\n {{/model.triggered_signals.length}}\n {{^model.triggered_signals.length}}\n <span class="text-muted">No signals triggered</span>\n {{/model.triggered_signals.length}}\n </div>\n </div>\n </div>\n </div>\n '}),i=new t.View({model:this.model,template:'\n <div class="card border-0 bg-light">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Raw Signals (Client)</h6>\n <pre class="bg-white p-3 rounded border"><code>{{{model.raw_signals|json}}}</code></pre>\n </div>\n </div>\n '}),n=new t.View({model:this.model,template:'\n <div class="card border-0 bg-light">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Server Signals</h6>\n <pre class="bg-white p-3 rounded border"><code>{{{model.server_signals|json}}}</code></pre>\n </div>\n </div>\n '});this.tabView=new a.TabView({tabs:{Overview:s,"Raw Signals":i,"Server Signals":n},activeTab:"Overview",containerId:"signal-tabs"}),this.addChild(this.tabView);const o=new e.ContextMenu({containerId:"signal-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh",icon:"bi-arrow-clockwise"}]}});this.addChild(o)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}class BouncerSignalTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_signals",pageName:"Bouncer Signals",router:"admin/security/bouncer-signals",Collection:BouncerSignalList,itemViewClass:BouncerSignalView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-created"},columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,filter:{type:"daterange"}},{key:"ip_address",label:"IP",template:"<code>{{model.ip_address}}</code>",filter:{type:"text"}},{key:"decision",label:"Decision",formatter:e=>`<span class="badge ${{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[e]||"bg-secondary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["allow","monitor","block"]}},{key:"risk_score",label:"Risk",sortable:!0,formatter:e=>{const t=e||0;return`<span class="text-${t>=80?"danger":t>=50?"warning":t>=20?"info":"success"} fw-semibold">${t}</span>`}},{key:"page_type",label:"Page",filter:{type:"text"}},{key:"stage",label:"Stage",filter:{type:"select",options:["assess","submit","event"]}},{key:"muid",label:"Device",formatter:"truncate_middle(12)"}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No bouncer signals recorded yet.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BouncerDeviceView extends t.View{constructor(e={}){super({className:"bouncer-device-view",...e}),this.template='\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="avatar-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 72px; height: 72px;">\n <i class="bi bi-fingerprint text-secondary" style="font-size: 36px;"></i>\n </div>\n <div>\n <h3 class="mb-1">Device</h3>\n <div class="text-muted small">\n <span>MUID: <code>{{model.muid}}</code></span>\n {{#model.duid}}\n <span class="mx-2">|</span>\n <span>DUID: <code>{{model.duid|truncate_middle(16)}}</code></span>\n {{/model.duid}}\n </div>\n <div class="mt-1">\n <span class="badge {{riskBadge}} me-1">{{model.risk_tier|uppercase|default(\'UNKNOWN\')}}</span>\n <span class="text-muted small">\n {{model.event_count}} events · {{model.block_count}} blocks\n </span>\n </div>\n </div>\n </div>\n <div data-container="device-context-menu"></div>\n </div>\n <div data-container="device-tabs"></div>\n '}get riskBadge(){return{blocked:"bg-danger",high:"bg-danger",medium:"bg-warning",low:"bg-success",unknown:"bg-secondary"}[this.model?.get("risk_tier")]||"bg-secondary"}async onInit(){await this.model.fetch({params:{graph:"detail"}});const s=new t.View({model:this.model,template:'\n <div class="row">\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Device Info</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">MUID</label><div><code>{{model.muid}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">DUID</label><div><code>{{model.duid|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Fingerprint ID</label><div><code>{{model.fingerprint_id|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Risk Tier</label><div><span class="badge {{riskBadge}}">{{model.risk_tier|uppercase|default(\'UNKNOWN\')}}</span></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Last Seen IP</label><div><code>{{model.last_seen_ip|default(\'—\')}}</code></div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Last Seen</label><div>{{model.last_seen|datetime|default(\'—\')}}</div></div>\n </div>\n </div>\n </div>\n <div class="col-md-6">\n <div class="card border-0 bg-light mb-3">\n <div class="card-body">\n <h6 class="fw-bold mb-3">Activity</h6>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Event Count</label><div>{{model.event_count}}</div></div>\n <div class="mb-2"><label class="form-label text-muted small fw-bold">Block Count</label><div>{{model.block_count}}</div></div>\n <h6 class="fw-bold mb-2 mt-3">Linked MUIDs</h6>\n {{#model.linked_muids.length}}\n <div class="d-flex flex-wrap gap-1">\n {{#model.linked_muids}}\n <code class="badge bg-light text-dark border">{{.}}</code>\n {{/model.linked_muids}}\n </div>\n {{/model.linked_muids.length}}\n {{^model.linked_muids.length}}\n <span class="text-muted">No linked devices</span>\n {{/model.linked_muids.length}}\n </div>\n </div>\n </div>\n </div>\n '}),i=new n.TableView({Collection:BouncerSignalList,collectionParams:{size:10,sort:"-created",muid:this.model.get("muid")},columns:[{key:"created",label:"Time",formatter:"relative"},{key:"ip_address",label:"IP",template:"<code>{{model.ip_address}}</code>"},{key:"decision",label:"Decision",formatter:e=>`<span class="badge ${{allow:"bg-success",monitor:"bg-warning",block:"bg-danger"}[e]||"bg-secondary"}">${(e||"—").toUpperCase()}</span>`},{key:"risk_score",label:"Risk"},{key:"page_type",label:"Page"}],searchable:!0,paginated:!0,tableOptions:{hover:!0,size:"sm"}}),o=new n.TableView({Collection:a.IncidentList,collectionParams:{size:10,sort:"-created",category__startswith:"security:bouncer",search:this.model.get("muid")},columns:[{key:"created",label:"Created",formatter:"epoch|datetime"},{key:"status",label:"Status"},{key:"category",label:"Category"},{key:"title",label:"Title",formatter:"truncate(60)"}],searchable:!0,paginated:!0,tableOptions:{hover:!0,size:"sm"}});this.tabView=new a.TabView({tabs:{Overview:s,Signals:i,Incidents:o},activeTab:"Overview",containerId:"device-tabs"}),this.addChild(this.tabView);const l=new e.ContextMenu({containerId:"device-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Refresh",action:"refresh",icon:"bi-arrow-clockwise"}]}});this.addChild(l)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}class BouncerDeviceTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_devices",pageName:"Bouncer Devices",router:"admin/security/bouncer-devices",Collection:BouncerDeviceList,itemViewClass:BouncerDeviceView,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-last_seen"},columns:[{key:"muid",label:"MUID",template:"<code>{{model.muid|truncate_middle(16)}}</code>",sortable:!0,filter:{type:"text"}},{key:"risk_tier",label:"Risk Tier",sortable:!0,formatter:e=>`<span class="badge ${{blocked:"bg-danger",high:"bg-danger",medium:"bg-warning",low:"bg-success",unknown:"bg-secondary"}[e]||"bg-secondary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["unknown","low","medium","high","blocked"]}},{key:"event_count",label:"Events",sortable:!0},{key:"block_count",label:"Blocks",sortable:!0},{key:"last_seen_ip",label:"Last IP",template:'<code>{{model.last_seen_ip|default("—")}}</code>'},{key:"last_seen",label:"Last Seen",formatter:"relative",sortable:!0}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No bouncer devices tracked yet.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class BotSignatureTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_bot_signatures",pageName:"Bot Signatures",router:"admin/security/bot-signatures",Collection:BouncerSignatureList,formCreate:$.create,formEdit:$.edit,viewDialogOptions:{size:"lg"},defaultQuery:{sort:"-modified"},columns:[{key:"sig_type",label:"Type",formatter:"badge",sortable:!0,filter:{type:"select",options:["user_agent","ip_pattern","fingerprint","behavior","header","cookie"]}},{key:"value",label:"Value",formatter:"truncate(60)",filter:{type:"text"}},{key:"source",label:"Source",formatter:e=>`<span class="badge ${"auto"===e?"bg-info":"bg-primary"}">${(e||"unknown").toUpperCase()}</span>`,filter:{type:"select",options:["auto","manual"]}},{key:"confidence",label:"Confidence",sortable:!0,formatter:e=>`${e||0}%`},{key:"hit_count",label:"Hits",sortable:!0},{key:"is_active",label:"Active",formatter:e=>e?'<span class="badge bg-success">ON</span>':'<span class="badge bg-secondary">OFF</span>',filter:{type:"select",options:[{label:"Active",value:"true"},{label:"Inactive",value:"false"}]}},{key:"expires_at",label:"Expires",formatter:'datetime|default("Never")'}],searchable:!0,sortable:!0,filterable:!0,paginated:!0,selectable:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No bot signatures. Click "Add" to create a manual signature.',batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-check-circle",action:"enable"},{label:"Disable",icon:"bi bi-pause-circle",action:"disable"},{label:"Delete",icon:"bi bi-trash",action:"delete"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} signature(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} signature(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Delete ${e.length} signature${e.length>1?"s":""}? This cannot be undone.`)&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} signature(s) deleted`),this.tableView.collection.fetch())}}const z=[{value:"country",label:"Country"},{value:"abuse",label:"Abuse Feed"},{value:"datacenter",label:"Datacenter"},{value:"custom",label:"Custom"}],j=[{value:"ipdeny",label:"IPDeny (Country Zones)"},{value:"abuseipdb",label:"AbuseIPDB"},{value:"manual",label:"Manual"}],B=[{value:"cn",label:"China"},{value:"ru",label:"Russia"},{value:"kp",label:"North Korea"},{value:"ir",label:"Iran"},{value:"ng",label:"Nigeria"},{value:"ro",label:"Romania"},{value:"br",label:"Brazil"},{value:"in",label:"India"},{value:"pk",label:"Pakistan"},{value:"id",label:"Indonesia"},{value:"vn",label:"Vietnam"},{value:"ua",label:"Ukraine"},{value:"th",label:"Thailand"},{value:"ph",label:"Philippines"},{value:"bd",label:"Bangladesh"},{value:"eg",label:"Egypt"},{value:"tr",label:"Turkey"},{value:"mx",label:"Mexico"},{value:"ar",label:"Argentina"},{value:"co",label:"Colombia"}];class IPSet extends t.Model{constructor(e={}){super(e,{endpoint:"/api/incident/ipset"})}}class IPSetList extends t.Collection{constructor(e={}){super({ModelClass:IPSet,endpoint:"/api/incident/ipset",...e})}}const O={create:{title:"Create IP Set",size:"md",fields:[{name:"kind",type:"select",label:"What do you want to block?",required:!0,options:[{value:"country",label:"Country — Block all traffic from a country"},{value:"abuse",label:"Abuse Feed — Import known attacker IPs"},{value:"datacenter",label:"Datacenter — Block datacenter/hosting ranges"},{value:"custom",label:"Custom — Define your own CIDR list"}],value:"country",columns:12},{name:"country_code",type:"select",label:"Country",required:!0,options:B,help:"Select a country to block. CIDRs are fetched automatically from IPDeny.",columns:8,showWhen:{field:"kind",value:"country"}},{name:"source_key",type:"text",label:"API Key",required:!0,placeholder:"Your AbuseIPDB API key",help:"Get a free key at abuseipdb.com. Never stored in plaintext.",columns:12,showWhen:{field:"kind",value:"abuse"}},{name:"source_url",type:"url",label:"Source URL",required:!0,placeholder:"https://example.com/datacenter-ranges.txt",help:"URL to a plain text file with one CIDR per line.",columns:12,showWhen:{field:"kind",value:"datacenter"}},{name:"data",type:"textarea",label:"CIDR List",rows:8,placeholder:"# One CIDR per line\n192.0.2.0/24\n198.51.100.0/24\n203.0.113.0/24",help:"Enter IP ranges in CIDR notation. Lines starting with # are ignored.",columns:12,showWhen:{field:"kind",value:"custom"}},{name:"name",type:"text",label:"Name",required:!0,placeholder:"e.g., abuse_ips, dc_aws",help:"Unique identifier. Used as the kernel ipset name.",columns:6,showWhen:{field:"kind",value:"country",negate:!0}},{name:"description",type:"text",label:"Description",placeholder:"Human-readable label",columns:6,showWhen:{field:"kind",value:"country",negate:!0}},{name:"is_enabled",type:"switch",label:"Enable immediately",value:!0,help:"When enabled, CIDRs are synced to the fleet and traffic is blocked.",columns:4}]},edit:{title:"Edit IP Set",size:"md",fields:[{name:"name",type:"text",label:"Name",required:!0,columns:6},{name:"kind",type:"select",label:"Kind",options:z,disabled:!0,columns:3},{name:"is_enabled",type:"switch",label:"Enabled",columns:3},{name:"description",type:"text",label:"Description",columns:12},{name:"source",type:"select",label:"Source",options:j,columns:6},{name:"source_url",type:"url",label:"Source URL",columns:6},{name:"source_key",type:"text",label:"API Key",placeholder:"Leave blank to keep current key",help:"Write-only — current value is never shown.",columns:12}]}};IPSet.EDIT_FORM=O.edit;class IPSetView extends t.View{constructor(e={}){super({className:"ipset-view",...e}),this.model=e.model||new IPSet(e.data||{});const t=this.model.get("kind")||"",s=z.find(e=>e.value===t);this.kindLabel=s?s.label:t,this.isEnabled=!!this.model.get("is_enabled"),this.enabledLabel=this.isEnabled?"Enabled":"Disabled",this.enabledBadge=this.isEnabled?"bg-success":"bg-secondary",this.template='\n <div class="ipset-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-shield-shaded"></i>\n </div>\n <div>\n <h4 class="mb-1">{{model.name}}</h4>\n {{#model.description}}\n <div class="text-muted mb-1">{{model.description}}</div>\n {{/model.description}}\n <div class="d-flex align-items-center gap-2">\n <span class="badge bg-primary">{{kindLabel}}</span>\n <span class="badge {{enabledBadge}}">{{enabledLabel}}</span>\n {{#model.cidr_count}}\n <span class="badge bg-light text-dark border">{{model.cidr_count}} CIDRs</span>\n {{/model.cidr_count}}\n </div>\n </div>\n </div>\n <div data-container="ipset-context-menu"></div>\n </div>\n\n \x3c!-- Tabs --\x3e\n <div data-container="ipset-tabs"></div>\n </div>\n '}async onInit(){const s=this.model.get("source")||"",i=j.find(e=>e.value===s),n=i?i.label:s,o=this.model.get("last_synced"),l=this.model.get("sync_error"),d=[{name:"name",label:"Name",cols:6},{name:"kind",label:"Kind",template:this.kindLabel,cols:3},{name:"is_enabled",label:"Enabled",formatter:"yesnoicon",cols:3},{name:"description",label:"Description",cols:12},{name:"source",label:"Source",template:n,cols:4},{name:"source_url",label:"Source URL",cols:8},{name:"cidr_count",label:"CIDRs Loaded",cols:4},{name:"last_synced",label:"Last Synced",template:o?new Date(o).toLocaleString():"Never",cols:4},{name:"sync_error",label:"Sync Status",template:l?`<span class="text-danger">${l}</span>`:'<span class="text-success"><i class="bi bi-check-circle me-1"></i>OK</span>',cols:4}];this.configView=new r.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:d}),this.cidrView=new t.View({className:"p-3",ipsetModel:this.model,cidrData:null,cidrLoading:!0,template:'\n {{#cidrLoading|bool}}\n <div class="text-center py-4 text-muted">\n <div class="spinner-border spinner-border-sm me-2" role="status"></div>\n Loading CIDR data...\n </div>\n {{/cidrLoading|bool}}\n {{^cidrLoading|bool}}\n {{#cidrData}}\n <div class="d-flex justify-content-between align-items-center mb-2">\n <span class="text-muted small">{{ipsetModel.cidr_count}} CIDRs loaded</span>\n <button class="btn btn-outline-secondary btn-sm" data-action="copy-cidrs" data-bs-toggle="tooltip" title="Copy to clipboard">\n <i class="bi bi-clipboard me-1"></i>Copy\n </button>\n </div>\n <pre class="bg-light border rounded p-3 small" style="max-height: 500px; overflow-y: auto;"><code>{{cidrData}}</code></pre>\n {{/cidrData}}\n {{^cidrData}}\n <div class="text-center py-5 text-muted">\n <i class="bi bi-database fs-1 mb-2 d-block"></i>\n <p>No CIDRs loaded.</p>\n <button class="btn btn-primary btn-sm" data-action="refresh-source">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh Source\n </button>\n </div>\n {{/cidrData}}\n {{/cidrLoading|bool}}\n ',async onInit(){try{const e=await this.ipsetModel.rest.GET(`${this.ipsetModel.endpoint}/${this.ipsetModel.id}`,{graph:"detailed"});if(e.success||200===e.status){const t=e.data?.data||e.data;this.cidrData=t?.data||null}}catch(e){}this.cidrLoading=!1},async onActionCopyCidrs(){this.cidrData&&(await navigator.clipboard.writeText(this.cidrData),this.getApp()?.toast?.success("CIDRs copied to clipboard"))},async onActionRefreshSource(){const e=await this.ipsetModel.save({refresh_source:1});e.success||200===e.status?this.getApp()?.toast?.success("Refreshing source — this may take a moment"):this.getApp()?.toast?.error("Failed to refresh source")}}),this.tabView=new a.TabView({containerId:"ipset-tabs",tabs:{Configuration:this.configView,"CIDR Data":this.cidrView},activeTab:"Configuration"}),this.addChild(this.tabView);const c=this.model.get("is_enabled"),m=new e.ContextMenu({containerId:"ipset-context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Sync to Fleet",action:"sync-fleet",icon:"bi-broadcast"},{label:"Refresh Source",action:"refresh-source",icon:"bi-arrow-clockwise"},{type:"divider"},c?{label:"Disable",action:"disable-ipset",icon:"bi-toggle-off"}:{label:"Enable",action:"enable-ipset",icon:"bi-toggle-on"},{label:"Edit IP Set",action:"edit-ipset",icon:"bi-pencil"},{type:"divider"},{label:"Delete IP Set",action:"delete-ipset",icon:"bi-trash",danger:!0}]}});this.addChild(m)}async onActionSyncFleet(){const e=await this.model.save({sync:1});e.success||200===e.status?this.getApp()?.toast?.success("Syncing to fleet..."):this.getApp()?.toast?.error("Sync failed")}async onActionRefreshSource(){const e=await this.model.save({refresh_source:1});e.success||200===e.status?this.getApp()?.toast?.success("Refreshing source data..."):this.getApp()?.toast?.error("Refresh failed")}async onActionEnableIpset(){const e=await this.model.save({enable:1});e.success||200===e.status?(this.getApp()?.toast?.success("IP Set enabled and synced"),await this.render()):this.getApp()?.toast?.error("Failed to enable")}async onActionDisableIpset(){if(!(await s.Dialog.confirm("Disable this IP set? It will be removed from iptables on all fleet instances.","Disable IP Set")))return;const e=await this.model.save({disable:1});e.success||200===e.status?(this.getApp()?.toast?.success("IP Set disabled and removed from fleet"),await this.render()):this.getApp()?.toast?.error("Failed to disable")}async onActionEditIpset(){await s.Dialog.showModelForm({title:`Edit IP Set — ${this.model.get("name")}`,model:this.model,formConfig:O.edit})&&(await this.render(),this.getApp()?.toast?.success("IP Set updated"))}async onActionDeleteIpset(){if(await s.Dialog.confirm(`Delete IP set "${this.model.get("name")}"? This will remove it from all fleet instances. This cannot be undone.`,"Delete IP Set",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("IP Set deleted"),this.emit("ipset:deleted",{model:this.model});const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}catch(e){this.getApp()?.toast?.error(`Delete failed: ${e.message}`)}}}IPSet.VIEW_CLASS=IPSetView;class IPSetTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_ipsets",pageName:"IP Sets",router:"admin/security/ipsets",Collection:IPSetList,itemViewClass:IPSetView,formEdit:O.edit,onAdd:()=>this._handleAdd(),viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"name"},columns:[{key:"is_enabled",label:"Active",width:"70px",sortable:!0,formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Enabled"},{value:"false",label:"Disabled"}]}},{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",sortable:!0,width:"120px",formatter:e=>{const t=z.find(t=>t.value===e);return`<span class="badge bg-primary bg-opacity-75">${t?t.label:e}</span>`},filter:{type:"select",options:z}},{key:"description",label:"Description",formatter:"truncate(40)|default('—')"},{key:"cidr_count",label:"CIDRs",width:"80px",sortable:!0},{key:"source",label:"Source",width:"110px",formatter:e=>{const t=j.find(t=>t.value===e);return t?t.label:e||"—"}},{key:"last_synced|datetime",label:"Last Synced",width:"160px",sortable:!0},{key:"sync_error",label:"Status",width:"80px",formatter:e=>e?'<span class="text-danger" title="'+e+'"><i class="bi bi-exclamation-triangle"></i> Error</span>':'<span class="text-success"><i class="bi bi-check-circle"></i></span>'}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No IP sets configured. Create one to start blocking traffic at the network level.",batchBarLocation:"top",batchActions:[{label:"Enable",icon:"bi bi-toggle-on",action:"enable"},{label:"Disable",icon:"bi bi-toggle-off",action:"disable"},{label:"Sync to Fleet",icon:"bi bi-broadcast",action:"sync"},{label:"Refresh Source",icon:"bi bi-arrow-clockwise",action:"refresh"},{label:"Delete",icon:"bi bi-trash",action:"delete",danger:!0}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async _handleAdd(){const e=await l.default.form({...O.create});if(!e)return;if("country"===e.kind&&e.country_code){const t=e.country_code,s=B.find(e=>e.value===t);e.name=`country_${t}`,e.source="ipdeny",e.description=s?`Country block: ${s.label}`:`Country block: ${t.toUpperCase()}`,delete e.country_code}else"abuse"===e.kind?e.source="abuseipdb":"datacenter"!==e.kind&&"custom"!==e.kind||(e.source="manual");const t=new IPSet,s=await t.save(e);s?.data?.status?(this.getApp()?.toast?.success("IP Set created"),this.tableView?.collection?.fetch()):l.default.showError(s?.data?.error||"Failed to create IP Set")}async onActionBatchEnable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Enable ${e.length} IP set(s)? They will be synced to the fleet.`)&&(await Promise.all(e.map(e=>e.model.save({enable:1}))),this.getApp().toast.success(`${e.length} IP set(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Disable ${e.length} IP set(s)? They will be removed from the fleet.`)&&(await Promise.all(e.map(e=>e.model.save({disable:1}))),this.getApp().toast.success(`${e.length} IP set(s) disabled`),this.tableView.collection.fetch())}async onActionBatchSync(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Sync ${e.length} IP set(s) to all fleet instances?`)&&(await Promise.all(e.map(e=>e.model.save({sync:1}))),this.getApp().toast.success(`${e.length} IP set(s) syncing to fleet`),this.tableView.collection.fetch())}async onActionBatchRefresh(){const e=this.tableView.getSelectedItems();e.length&&await this.getApp().confirm(`Refresh source data for ${e.length} IP set(s)?`)&&(await Promise.all(e.map(e=>e.model.save({refresh_source:1}))),this.getApp().toast.success(`${e.length} IP set(s) refreshing`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView.getSelectedItems();e.length&&await s.Dialog.confirm(`Delete ${e.length} IP set(s)? They will be removed from all fleet instances. This cannot be undone.`,"Delete IP Sets",{confirmText:"Delete",confirmClass:"btn-danger"})&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} IP set(s) deleted`),this.tableView.collection.fetch())}}class LogTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_logs",pageName:"Manage Logs",router:"admin/logs",Collection:n.LogList,itemViewClass:LogView,viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"created|epoch|datetime",label:"Timestamp",sortable:!0,filter:{type:"daterange"}},{key:"level",label:"Level",sortable:!0,formatter:"badge",filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",filter:{type:"text"}},{key:"method",label:"Method",filter:{type:"text"}},{key:"path",label:"Path",filter:{type:"text"}},{key:"username",label:"User",filter:{type:"text"}},{key:"ip",label:"IP",filter:{type:"text"}},{key:"duid",label:"Browser ID",formatter:"truncate_middle(16)",filter:{type:"text"}}],defaultQuery:{sort:"-created"},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No log entries found.",batchBarLocation:"top",batchActions:[{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Archive",icon:"bi bi-archive",action:"batch-archive"},{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Mark as Reviewed",icon:"bi bi-check2",action:"batch-reviewed"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class MetricsPermissionsView extends t.View{constructor(e={}){super({className:"metrics-permissions-view",...e}),this.model=e.model||new a.MetricsPermission(e.data||{}),this.template='\n <div class="container p-3">\n <div class="d-flex justify-content-between align-items-center mb-4">\n <div>\n <h3 class="mb-1">Permissions for {{model.account}}</h3>\n </div>\n <div data-container="context-menu"></div>\n </div>\n <div data-container="data-view"></div>\n </div>\n '}async onInit(){this.dataView=new r.default({containerId:"data-view",model:this.model,fields:[{name:"view_permissions",label:"View Permissions",format:"list|badge"},{name:"write_permissions",label:"Write Permissions",format:"list|badge"}]}),this.addChild(this.dataView);const t=new e.ContextMenu({containerId:"context-menu",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit",icon:"bi-pencil"},{label:"Delete",action:"delete",icon:"bi-trash",danger:!0}]}});this.addChild(t)}async onActionEdit(){const e=await s.Dialog.showModelForm({title:`Edit Permissions for ${this.model.get("account")}`,model:this.model,formConfig:a.MetricsForms.edit});e&&(this.model.set(e.data.data),this.render())}async onActionDelete(){await s.Dialog.confirm(`Are you sure you want to delete all permissions for ${this.model.get("account")}?`)&&(await this.model.destroy(),this.emit("deleted",this.model))}}a.MetricsPermission.VIEW_CLASS=MetricsPermissionsView;class MetricsPermissionsTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_metrics_permissions",pageName:"Metrics Permissions",router:"admin/metrics/permissions",Collection:a.MetricsPermissionList,formEdit:a.MetricsForms.edit,itemViewClass:MetricsPermissionsView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"account",label:"Account",sortable:!0},{key:"view_permissions",label:"View Permissions",formatter:"list|badge"},{key:"write_permissions",label:"Write Permissions",formatter:"list|badge"}],selectable:!0,searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,tableOptions:{pageSizes:[10,25,50],defaultPageSize:25,emptyMessage:"No metrics permissions found.",emptyIcon:"bi-bar-chart-line",actions:["view","edit","delete"]}})}}class Setting extends t.Model{constructor(e={},t={}){super(e,{endpoint:"/api/settings",...t})}}class SettingList extends t.Collection{constructor(e={}){super({ModelClass:Setting,endpoint:"/api/settings",size:25,...e})}}const U={create:{title:"Create Setting",fields:[{name:"key",type:"text",label:"Key",placeholder:"WEBHOOK_SECRET",required:!0,columns:12,help:"A unique configuration key name."},{name:"value",type:"textarea",label:"Value",required:!0,columns:12,help:"The configuration value. For secrets, this will be masked after creation."},{type:"collection",name:"parent",label:"Parent Group",Collection:s.GroupList,labelField:"name",valueField:"id",maxItems:10,placeholder:"Search groups...",emptyFetch:!1,debounceMs:300,columns:12},{name:"is_secret",type:"switch",label:"Secret",columns:6,help:"Mark as secret to mask the value in API responses."}]},edit:{title:"Edit Setting",fields:[{name:"key",type:"text",label:"Key",columns:12,disabled:!0},{name:"value",type:"textarea",label:"Value",columns:12,help:"Enter a new value to replace the current one."},{name:"is_secret",type:"switch",label:"Secret",columns:12,help:"Mark as secret to mask the value in API responses."},{type:"collection",name:"parent",label:"Parent Group",Collection:s.GroupList,labelField:"name",valueField:"id",maxItems:10,placeholder:"Search groups...",emptyFetch:!1,debounceMs:300,columns:12}]}};class SettingView extends t.View{constructor(e={}){super({className:"setting-view",...e}),this.model=e.model||new Setting(e.data||{}),this.template='\n <div class="setting-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-4">\n \x3c!-- Left: Icon & Identity --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="fs-1 text-primary">\n <i class="bi bi-gear"></i>\n </div>\n <div>\n <h3 class="mb-1 font-monospace">{{model.key|default(\'Unnamed Setting\')}}</h3>\n <div class="text-muted small">\n ID: {{model.id}}\n <span class="mx-2">|</span>\n Scope: {{model.group.name|default(\'Global\')}}\n </div>\n <div class="mt-1">\n <span class="badge {{model.is_secret|boolean(\'bg-warning text-dark\',\'bg-secondary\')}}">\n {{model.is_secret|boolean(\'Secret\',\'Plain\')}}\n </span>\n </div>\n </div>\n </div>\n\n \x3c!-- Right: Meta & Actions --\x3e\n <div class="d-flex align-items-start gap-4">\n <div class="text-end">\n <div class="text-muted small">Created</div>\n <div>{{model.created|datetime}}</div>\n </div>\n <div data-container="setting-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="list-group mb-3">\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Key</h6>\n <p class="mb-0 font-monospace">{{model.key}}</p>\n </div>\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Value</h6>\n {{#model.is_secret|bool}}\n <p class="mb-0 text-muted font-monospace">{{model.display_value|default(\'******\')}}</p>\n <small class="text-muted">This is a secret value. Enter a new value to replace it.</small>\n {{/model.is_secret|bool}}\n {{^model.is_secret|bool}}\n <p class="mb-0 font-monospace">{{model.value|default(model.display_value)|default(\'—\')}}</p>\n {{/model.is_secret|bool}}\n </div>\n {{#model.group}}\n <div class="list-group-item">\n <h6 class="mb-1 text-muted">Group</h6>\n <p class="mb-0">{{model.group.name|default(model.group)}}</p>\n </div>\n {{/model.group}}\n </div>\n </div>\n '}async onInit(){const t=new e.ContextMenu({containerId:"setting-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"Edit",action:"edit-setting",icon:"bi-pencil"},{type:"divider"},{label:"Delete Setting",action:"delete-setting",icon:"bi-trash",danger:!0}]}});this.addChild(t)}async onActionEditSetting(){const e=this.getApp();await e.showModelForm({title:`Edit Setting — ${this.model.get("key")}`,model:this.model,formConfig:U.edit})&&this.render()}async onActionDeleteSetting(){const e=this.getApp();if(!(await e.confirm({title:"Delete Setting",message:`Permanently delete "${this.model.get("key")}"? This cannot be undone.`,confirmLabel:"Delete",confirmClass:"btn-danger"})))return;e.showLoading();const t=await this.model.delete();e.hideLoading(),t&&!1!==t.success?(e.toast.success("Setting deleted"),this.emit("deleted",{model:this.model})):e.toast.error("Failed to delete setting")}}Setting.VIEW_CLASS=SettingView,Setting.ADD_FORM=U.create,Setting.EDIT_FORM=U.edit;class SettingTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_settings",pageName:"Settings",router:"admin/settings",Collection:SettingList,itemViewClass:SettingView,viewDialogOptions:{header:!1,size:"lg"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"key",label:"Key",sortable:!0},{key:"display_value",label:"Value",formatter:"default('—')"},{key:"group.name",label:"Group",sortable:!0,formatter:"default('Global')"},{key:"is_secret",label:"Secret",formatter:"boolean('Secret|bg-warning text-dark','Plain|bg-secondary')|badge",width:"100px"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}],defaultQuery:{sort:"key"},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!1,addButtonLabel:"New Setting",emptyMessage:"No settings found.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class FileManagerTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_file_managers",pageName:"Manage Storage Backends",router:"admin/file-managers",Collection:s.FileManagerList,formCreate:s.FileManagerForms.create,formEdit:s.FileManagerForms.edit,columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Name",formatter:"default('Unnamed Backend')"},{key:"backend_url",label:"Backend URL",sortable:!0},{key:"is_default",label:"Default",formatter:"boolean|badge"},{key:"is_active",label:"Active",formatter:"boolean|badge"},{key:"is_public",label:"Public",formatter:"boolean|badge"},{key:"backend_type",label:"Type",formatter:"default('Unknown')"},{key:"created",label:"Created",formatter:"epoch|datetime"}],contextMenu:[{icon:"bi-pencil",action:"edit",label:"Edit Name"},{icon:"bi-shield",action:"edit-credentials",label:"Edit Credentials"},{icon:"bi-person",action:"edit-owners",label:"Edit Owners"},{divider:!0},{icon:"bi-copy",action:"clone",label:"Clone Manager"},{divider:!0},{icon:"bi-check",action:"test-connection",label:"Test Connection"},{icon:"bi-question-circle",action:"check-cors",label:"Check CORS"},{icon:"bi-wrench",action:"fix-cors",label:"Fix CORS"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No storage backends found. Click "Add Storage Backend" to configure your first backend.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Activate",icon:"bi bi-check-circle",action:"batch-activate"},{label:"Deactivate",icon:"bi bi-x-circle",action:"batch-deactivate"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}async onActionEditOwners(e,t){const i=this.collection.get(t.dataset.id),a=await s.Dialog.showModelForm({title:"Edit Owners",model:i,fields:s.FileManagerForms.owners.fields});if(!a)return!0;a.success?this.getApp().toast.success("Owners Updated successfully"):this.getApp().toast.error("Owners update failed")}async onActionCheckCors(e,t){const i=this.collection.get(t.dataset.id),a=await i.save({check_cors:!0});return a.success&&a.data.status?await s.Dialog.showData({title:`Audit Report - ${i._.name}`,data:a.data,size:"lg"}):this.getApp().toast.error("Connection test failed"),!0}async onActionTestConnection(e,t){const s=this.collection.get(t.dataset.id),i=await s.save({test_connection:!0});return i.success&&i.data.status?this.getApp().toast.success("Connection test successful"):this.getApp().toast.error("Connection test failed"),!0}async onActionEditCredentials(e,t){const i=this.collection.get(t.dataset.id),a=await s.Dialog.showModelForm({title:"Edit Credentials",model:i,fields:s.FileManagerForms.credentials.fields});return!a||(a.success&&a.data.status?this.getApp().toast.success("Credentials updated successfully"):this.getApp().toast.error("Failed to update credentials"),!0)}async onActionClone(e,t){if(!(await s.Dialog.showConfirm({title:"Clone File Manager",message:"This will create a clone with the same credentials."})))return!0;const i=this.collection.get(t.dataset.id),a=await i.save({clone:!0});return a.success&&a.data.status?(this.getApp().toast.success("Connection cloned successfully"),this.collection.fetch()):this.getApp().toast.error("Failed to clone connection"),!0}}class FileView extends t.View{constructor(e={}){super({className:"file-view",...e}),this.model=e.model||new s.File(e.data||{}),this.isImage="image"===this.model.get("category");const i=this.model.get("renditions")||{};this.renditionsCollection=new t.Collection(Object.values(i)),this.template='\n <div class="file-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-center mb-4">\n \x3c!-- Left Side: Thumbnail & Info --\x3e\n <div class="d-flex align-items-center gap-3">\n <div class="file-thumbnail" style="width: 80px; height: 80px;">\n {{#isImage}}\n <a href="{{model.url}}" target="_blank" title="View original file">\n <img src="{{model.renditions.thumbnail.url|default(model.url)}}" class="img-fluid rounded" style="width: 80px; height: 80px; object-fit: cover;">\n </a>\n {{/isImage}}\n {{^isImage}}\n <div class="avatar-placeholder rounded bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">\n <i class="bi bi-file-earmark-text text-secondary" style="font-size: 40px;"></i>\n </div>\n {{/isImage}}\n </div>\n <div>\n <h3 class="mb-1" style="word-break: break-all;">{{model.filename|truncate(40)}}</h3>\n <div class="text-muted small">\n <span><i class="bi bi-hdd"></i> {{model.file_size|filesize}}</span>\n <span class="mx-2">|</span>\n <span>{{model.content_type}}</span>\n </div>\n <div class="text-muted small mt-1">\n Uploaded: {{model.created|datetime}}\n </div>\n </div>\n </div>\n\n \x3c!-- Right Side: Status & Actions --\x3e\n <div class="d-flex align-items-center gap-4">\n <div class="text-end">\n <div class="d-flex align-items-center gap-2 justify-content-end">\n <span class="badge {{model.upload_status|badge}}">{{model.upload_status|capitalize}}</span>\n </div>\n <div class="text-muted small mt-1">\n Public: {{{model.is_public|yesnoicon}}}\n </div>\n </div>\n <div data-container="file-context-menu"></div>\n </div>\n </div>\n\n \x3c!-- Tab Container --\x3e\n <div data-container="file-tabs"></div>\n </div>\n '}async onInit(){this.infoView=new r.default({model:this.model,className:"p-3",showEmptyValues:!0,emptyValueText:"—",columns:2,fields:[{name:"id",label:"ID"},{name:"filename",label:"Filename"},{name:"storage_filename",label:"Storage Filename"},{name:"content_type",label:"Content Type"},{name:"file_size",label:"File Size",format:"filesize"},{name:"category",label:"Category"},{name:"upload_status",label:"Status",format:"badge"},{name:"created",label:"Created",format:"datetime"},{name:"modified",label:"Modified",format:"datetime"},{name:"user.display_name",label:"Uploaded By"},{name:"file_manager.name",label:"Storage Backend"},{name:"storage_file_path",label:"Storage Path"},{name:"url",label:"Public URL",format:"url"},{name:"is_public",label:"Is Public",format:"boolean"}]}),this.renditionsView=new n.TableView({collection:this.renditionsCollection,columns:[{key:"role",label:"Role",formatter:"badge"},{key:"filename",label:"Filename",formatter:"truncate(40)"},{key:"file_size",label:"Size",formatter:"filesize"},{key:"content_type",label:"Content Type"},{key:"actions",label:"Actions",template:'\n <a href="{{url}}" target="_blank" class="btn btn-sm btn-outline-primary" title="View">\n <i class="bi bi-eye"></i>\n </a>\n <a href="{{url}}" download="{{filename}}" class="btn btn-sm btn-outline-secondary" title="Download">\n <i class="bi bi-download"></i>\n </a>\n '}]});const t={Info:this.infoView};t.Renditions=this.renditionsView,this.tabView=new a.TabView({tabs:t,activeTab:"Info",containerId:"file-tabs"}),this.addChild(this.tabView);const s=new e.ContextMenu({containerId:"file-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[{label:"View",action:"view-file",icon:"bi-eye"},{label:"Download",action:"download-file",icon:"bi bi-download"},{label:"Edit Details",action:"edit-file",icon:"bi bi-pencil"},{type:"divider"},this.model.get("is_public")?{label:"Make Private",action:"make-private",icon:"bi bi-lock"}:{label:"Make Public",action:"make-public",icon:"bi bi-unlock"},{type:"divider"},{label:"Delete File",action:"delete-file",icon:"bi bi-trash",danger:!0}]}});this.addChild(s)}async onActionViewFile(){const e=this.model.get("content_type"),t=this.model.get("url");if(e.startsWith("image/")){const e=this.model.get("renditions")||{},s=[{src:t,alt:"Original"},...Object.values(e).map(e=>({src:e.url,alt:e.role}))];c.LightboxGallery.show(s,{fitToScreen:!1})}else"application/pdf"===e?c.PDFViewer.showDialog(t,{title:this.model.get("filename")}):window.open(t,"_blank")}async onActionDownloadFile(){const e=this.model.get("url");if(e){const t=document.createElement("a");t.href=e,t.download=this.model.get("filename"),document.body.appendChild(t),t.click(),document.body.removeChild(t)}}async onActionEditFile(){await s.Dialog.showModelForm({title:`Edit File - ${this.model.get("filename")}`,model:this.model,formConfig:s.FileForms.edit})&&this.render()}async onActionMakePublic(){await this.model.save({is_public:!0}),this.render()}async onActionMakePrivate(){await this.model.save({is_public:!1}),this.render()}async onActionDeleteFile(){await s.Dialog.confirm(`Are you sure you want to delete the file "${this.model.get("filename")}"? This action cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&this.emit("file:deleted",{model:this.model})}}s.File.VIEW_CLASS=FileView;class FileTablePage extends a.TablePage{constructor(e={}){super({name:"admin_files",pageName:"Manage Files",router:"admin/files",Collection:s.FileList,formEdit:s.FileForms.edit,itemViewClass:FileView,onAdd:async e=>{await this.handleFileUpload(e)},viewDialogOptions:{header:!1,size:"xl"},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"filename",label:"Filename"},{key:"content_type",label:"Type",formatter:"default('Unknown')"},{key:"file_size",label:"Size",formatter:"filesize"},{key:"group.name",label:"Group",formatter:"default('No Group')"},{key:"upload_status",label:"Status",formatter:"badge"},{key:"created",label:"Uploaded",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No files found. Click "Add File" to upload your first file.',batchBarLocation:"top",batchActions:[{label:"Download",icon:"bi bi-download",action:"batch-download"},{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Move to Group",icon:"bi bi-folder",action:"batch-move"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e}),this.enableFileDrop({acceptedTypes:["*/*"],maxFileSize:104857600,multiple:!1,validateOnDrop:!0})}async handleFileUpload(e){e&&e.preventDefault();const t=document.createElement("input");t.type="file",t.accept="*/*",t.multiple=!1,t.style.display="none",t.addEventListener("change",async e=>{const i=e.target.files[0];if(!i)return;const a=104857600;if(i.size>a)this.showError(`File size (${this._formatFileSize(i.size)}) exceeds maximum (${this._formatFileSize(a)})`);else try{const e=new s.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const a=e.upload({file:i,name:i.name,description:`File uploaded on ${/* @__PURE__ */(new Date).toLocaleDateString()}`,showToast:!0,onProgress:e=>{e.percentage},onComplete:e=>{this.refresh()},onError:e=>{console.error("Upload failed:",e),this.showError("Upload failed: "+e.message)},...t});await a}catch(n){console.error("Error starting file upload:",n),this.showError("Failed to start file upload: "+n.message)}finally{t.remove()}}),document.body.appendChild(t),t.click()}_formatFileSize(e){if(0===e)return"0 Bytes";const t=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,t)).toFixed(2))+" "+["Bytes","KB","MB","GB"][t]}async onFileDrop(e,t,i){const a=e[0];a.name,a.type,a.size;try{const e=new s.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const i=e.upload({file:a,name:a.name,description:`File uploaded via drag & drop on ${/* @__PURE__ */(new Date).toLocaleDateString()}`,showToast:!0,onProgress:e=>{e.percentage},onComplete:e=>{this.refresh()},onError:e=>{console.error("Upload failed:",e),this.showError("Upload failed: "+e.message)},...t});await i}catch(n){console.error("Error starting file upload:",n),this.showError("Failed to start file upload: "+n.message)}}}o.applyFileDropMixin(FileTablePage);class S3BucketTablePage extends a.TablePage{constructor(e={}){super({...e,name:"admin_s3_buckets",pageName:"Manage S3 Buckets",router:"admin/s3-buckets",Collection:a.S3BucketList,formCreate:a.S3BucketForms.create,formEdit:a.S3BucketForms.edit,columns:[{key:"id",label:"ID",width:"60px",sortable:!0,class:"text-muted"},{key:"name",label:"Bucket Name",sortable:!0},{key:"created",label:"Created",formatter:"epoch|datetime"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:'No S3 buckets found. Click "Add S3 Bucket" to create your first bucket.',batchBarLocation:"top",batchActions:[{label:"Delete",icon:"bi bi-trash",action:"batch-delete"},{label:"Export",icon:"bi bi-download",action:"batch-export"},{label:"Make Public",icon:"bi bi-unlock",action:"batch-public"},{label:"Make Private",icon:"bi bi-lock",action:"batch-private"},{label:"Empty Bucket",icon:"bi bi-bucket",action:"batch-empty"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}}class UserDeviceLocationView extends t.View{constructor(e={}){super({className:"udl-view",...e}),this.model=e.model||new s.UserDeviceLocation(e.data||{}),this._ud=this.model.get("user_device")||{},this._di=this._ud.device_info||{},this._geo=this.model.get("geolocation")||{},this.deviceIcon=this._getDeviceIcon(),this.browserFull=this._getBrowser(),this.osFull=this._getOS(),this.deviceFull=this._getDevice(),this.locationSummary=this._getLocationSummary(),this.countryFlag=this._geo.country_code||"",this.threatLevel=this._geo.threat_level||"unknown",this.threatColor=this._getThreatColor(),this.hasCoordinates=!(!this._geo.latitude||!this._geo.longitude),this.template='\n <style>\n .udl-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }\n .udl-identity { display: flex; align-items: center; gap: 1rem; }\n .udl-icon-wrap { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; flex-shrink: 0; }\n .udl-title { font-size: 1.15rem; font-weight: 600; margin: 0; line-height: 1.3; }\n .udl-subtitle { font-size: 0.8rem; color: #6c757d; margin-top: 0.15rem; }\n .udl-right { display: flex; align-items: flex-start; gap: 0.75rem; }\n .udl-threat-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.04em; }\n .udl-threat-value { font-size: 1rem; font-weight: 600; }\n .udl-threat-flags { display: flex; gap: 0.35rem; margin-top: 0.35rem; }\n .udl-flag { font-size: 0.65rem; padding: 0.15em 0.45em; border-radius: 3px; font-weight: 600; }\n </style>\n\n <div class="udl-header">\n <div class="udl-identity">\n <div class="udl-icon-wrap bg-primary bg-opacity-10 text-primary">\n <i class="bi {{deviceIcon}}"></i>\n </div>\n <div>\n <h4 class="udl-title">{{locationSummary}}</h4>\n <div class="udl-subtitle">\n {{browserFull}} <span class="text-muted">on</span> {{deviceFull}}\n <span class="text-muted mx-1">·</span>\n {{model.ip_address}}\n </div>\n </div>\n </div>\n <div class="udl-right">\n <div class="text-end">\n <div class="udl-threat-label">Threat Level</div>\n <div class="udl-threat-value {{threatColor}}">{{threatLevel|capitalize}}</div>\n <div class="udl-threat-flags">\n {{#_geo.is_vpn}}<span class="udl-flag bg-warning text-dark">VPN</span>{{/_geo.is_vpn}}\n {{#_geo.is_tor}}<span class="udl-flag bg-danger text-white">Tor</span>{{/_geo.is_tor}}\n {{#_geo.is_proxy}}<span class="udl-flag bg-warning text-dark">Proxy</span>{{/_geo.is_proxy}}\n {{#_geo.is_datacenter}}<span class="udl-flag bg-secondary text-white">DC</span>{{/_geo.is_datacenter}}\n {{#_geo.is_cloud}}<span class="udl-flag bg-info text-white">Cloud</span>{{/_geo.is_cloud}}\n </div>\n </div>\n <div data-container="udl-context-menu"></div>\n </div>\n </div>\n\n <div data-container="udl-sidenav" style="min-height: 300px;"></div>\n '}_getDeviceIcon(){const e=this._di?.user_agent?.family?.toLowerCase()||"",t=this._di?.os?.family?.toLowerCase()||"",s=this._di?.device?.family?.toLowerCase()||"";return e.includes("chrome")?"bi-browser-chrome":e.includes("firefox")?"bi-browser-firefox":e.includes("safari")?"bi-browser-safari":e.includes("edge")?"bi-browser-edge":t.includes("mac")||t.includes("ios")?"bi-apple":t.includes("windows")?"bi-windows":t.includes("android")?"bi-android2":s.includes("iphone")?"bi-phone":s.includes("ipad")?"bi-tablet":"bi-geo-alt"}_getBrowser(){const e=this._di?.user_agent||{};return e.family?`${e.family} ${e.major||""}`.trim():"Unknown Browser"}_getOS(){const e=this._di?.os||{},t=[e.major,e.minor].filter(Boolean).join(".");return e.family?`${e.family} ${t}`.trim():"Unknown OS"}_getDevice(){const e=this._di?.device||{},t=[e.brand,e.family].filter(Boolean);return t.length?t.join(" "):"Unknown Device"}_getLocationSummary(){const e=[this._geo.city,this._geo.region,this._geo.country_name].filter(Boolean);return e.length?e.join(", "):"Unknown Location"}_getThreatColor(){const e=(this._geo.threat_level||"").toLowerCase();return"high"===e||this._geo.is_threat?"text-danger":"medium"===e||this._geo.is_suspicious?"text-warning":"low"===e?"text-success":"text-muted"}async onInit(){const s=this._geo,i=this._di,o=[{key:"location",label:"Location",icon:"bi-geo-alt",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n </style>\n\n <div class="udl-section-label">Geography</div>\n <div class="udl-field-row">\n <div class="udl-field-label">City</div>\n <div class="udl-field-value">${s.city||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Region</div>\n <div class="udl-field-value">${s.region||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Country</div>\n <div class="udl-field-value">${s.country_name||"—"} ${s.country_code?`<span class="text-muted">(${s.country_code})</span>`:""}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Postal Code</div>\n <div class="udl-field-value">${s.postal_code||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Timezone</div>\n <div class="udl-field-value">${s.timezone||"—"}</div>\n </div>\n ${s.latitude?`\n <div class="udl-field-row">\n <div class="udl-field-label">Coordinates</div>\n <div class="udl-field-value">${s.latitude}, ${s.longitude}</div>\n </div>`:""}\n\n <div class="udl-section-label">Network</div>\n <div class="udl-field-row">\n <div class="udl-field-label">IP Address</div>\n <div class="udl-field-value" style="font-family: ui-monospace, monospace; font-size: 0.82rem;">{{model.ip_address}}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ISP</div>\n <div class="udl-field-value">${s.isp||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ASN</div>\n <div class="udl-field-value">${s.asn||"—"} ${s.asn_org?`<span class="text-muted small">(${s.asn_org})</span>`:""}</div>\n </div>\n\n <div class="udl-section-label">Timestamps</div>\n <div class="udl-field-row">\n <div class="udl-field-label">First Seen</div>\n <div class="udl-field-value">{{model.first_seen|epoch|datetime|default('—')}}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Last Seen</div>\n <div class="udl-field-value">{{model.last_seen|epoch|datetime|default('—')}}</div>\n </div>\n `})},{key:"device",label:"Device",icon:"bi-laptop",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .udl-ua-string { font-family: ui-monospace, monospace; font-size: 0.73rem; color: #6c757d; word-break: break-all; line-height: 1.5; padding: 0.5rem 0.75rem; background: #f8f9fa; border-radius: 6px; margin-top: 0.25rem; }\n </style>\n\n <div class="udl-section-label">Browser</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Name</div>\n <div class="udl-field-value">${i?.user_agent?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Version</div>\n <div class="udl-field-value">${[i?.user_agent?.major,i?.user_agent?.minor,i?.user_agent?.patch].filter(Boolean).join(".")||"—"}</div>\n </div>\n\n <div class="udl-section-label">Operating System</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Name</div>\n <div class="udl-field-value">${i?.os?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Version</div>\n <div class="udl-field-value">${[i?.os?.major,i?.os?.minor,i?.os?.patch].filter(Boolean).join(".")||"—"}</div>\n </div>\n\n <div class="udl-section-label">Hardware</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Brand</div>\n <div class="udl-field-value">${i?.device?.brand||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Family</div>\n <div class="udl-field-value">${i?.device?.family||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Model</div>\n <div class="udl-field-value">${i?.device?.model||"—"}</div>\n </div>\n\n ${i?.string?`\n <div class="udl-section-label">User Agent String</div>\n <div class="udl-ua-string">${i.string}</div>`:""}\n `})},{key:"risk",label:"Risk",icon:"bi-shield-exclamation",view:new t.View({model:this.model,template:`\n <style>\n .udl-section-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #adb5bd; margin-bottom: 0.5rem; margin-top: 1.5rem; }\n .udl-section-label:first-child { margin-top: 0; }\n .udl-field-row { display: flex; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }\n .udl-field-row:last-child { border-bottom: none; }\n .udl-field-label { width: 130px; font-size: 0.78rem; color: #6c757d; flex-shrink: 0; }\n .udl-field-value { flex: 1; font-size: 0.88rem; color: #212529; }\n .udl-risk-icon { font-size: 0.85rem; margin-right: 0.35rem; }\n .udl-risk-yes { color: #dc3545; }\n .udl-risk-no { color: #adb5bd; }\n </style>\n\n <div class="udl-section-label">Threat Assessment</div>\n <div class="udl-field-row">\n <div class="udl-field-label">Threat Level</div>\n <div class="udl-field-value ${this.threatColor}" style="font-weight: 600;">${s.threat_level||"Unknown"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Risk Score</div>\n <div class="udl-field-value">${null!=s.risk_score?s.risk_score:"—"}</div>\n </div>\n\n <div class="udl-section-label">Detection Flags</div>\n ${this._riskRow("VPN","bi-shield",s.is_vpn)}\n ${this._riskRow("Tor Exit Node","bi-shield-lock",s.is_tor)}\n ${this._riskRow("Proxy","bi-diagram-3",s.is_proxy)}\n ${this._riskRow("Cloud Provider","bi-cloud",s.is_cloud)}\n ${this._riskRow("Datacenter","bi-hdd-stack",s.is_datacenter)}\n ${this._riskRow("Mobile","bi-phone",s.is_mobile)}\n\n <div class="udl-section-label">Reputation</div>\n ${this._riskRow("Known Attacker","bi-exclamation-triangle",s.is_known_attacker)}\n ${this._riskRow("Known Abuser","bi-flag",s.is_known_abuser)}\n ${this._riskRow("Threat","bi-shield-exclamation",s.is_threat)}\n ${this._riskRow("Suspicious","bi-question-circle",s.is_suspicious)}\n `})}];if(this.hasCoordinates)try{const e=new(0,(await Promise.resolve().then(()=>require("./chunks/MetricsCountryMapView-ww-c8cxk.js")).then(e=>e.MapView$1)).default)({markers:[{lat:this._geo.latitude,lng:this._geo.longitude,popup:`<strong>${this.model.get("ip_address")}</strong><br>${this.locationSummary}`}],tileLayer:"light",zoom:6,height:400});o.push({key:"map",label:"Map",icon:"bi-map",view:e})}catch(d){}const l=this.model.get("ip_address");if(l){const e=new n.TableView({collection:new a.IncidentEventList({params:{size:10,source_ip:l}}),hideActivePillNames:["source_ip"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"150px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]});o.push({type:"divider",label:"Activity"}),o.push({key:"events",label:"Events",icon:"bi-calendar-event",view:e});const t=new n.TableView({collection:new n.LogList({params:{size:10,ip:l}}),permissions:"view_logs",hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime"},{key:"level",label:"Level",sortable:!0},{key:"kind",label:"Kind"},{name:"log",label:"Log"}]});o.push({key:"logs",label:"Logs",icon:"bi-journal-text",view:t,permissions:"view_logs"})}this.sideNavView=new SideNavView({containerId:"udl-sidenav",activeSection:this.hasCoordinates?"map":"location",navWidth:160,contentPadding:"1rem 1.5rem",enableResponsive:!0,minWidth:450,sections:o}),this.addChild(this.sideNavView);const r=new e.ContextMenu({containerId:"udl-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:[...this._ud?.user?[{label:"View User",action:"view-user",icon:"bi-person"}]:[],...this._ud?.id?[{label:"View Device",action:"view-device",icon:"bi-laptop"}]:[],...this.hasCoordinates?[{label:"Open in Maps",action:"open-in-maps",icon:"bi-box-arrow-up-right"}]:[],{type:"divider"},{label:"Delete Record",action:"delete-record",icon:"bi-trash",danger:!0}]}});this.addChild(r)}_riskRow(e,t,s){return`\n <div class="udl-field-row">\n <div class="udl-field-label"><i class="bi ${t} me-1 ${s?"udl-risk-yes":"udl-risk-no"}"></i>${e}</div>\n <div class="udl-field-value">${s?'<i class="bi bi-check-circle-fill udl-risk-icon udl-risk-yes"></i>Yes':'<i class="bi bi-dash-circle udl-risk-icon udl-risk-no"></i>No'}</div>\n </div>`}async onActionViewUser(){const e=this._ud?.user?.id||this._ud?.user;e&&this.emit("view-user",{userId:e})}async onActionViewDevice(){const e=this._ud?.id;e&&this.emit("view-device",{deviceId:e})}async onActionOpenInMaps(){this.hasCoordinates&&window.open(`https://www.google.com/maps/search/?api=1&query=${this._geo.latitude},${this._geo.longitude}`,"_blank")}async onActionDeleteRecord(){return!(await s.Dialog.confirm("Are you sure you want to delete this location record?","Delete Location Record"))||((await this.model.destroy()).success&&this.emit("location:deleted",{model:this.model}),!0)}static async show(e){const t=new s.UserDeviceLocation({id:e});return await t.fetch(),t.id?s.Dialog.showDialog({title:!1,size:"lg",body:new UserDeviceLocationView({model:t}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(s.Dialog.alert({message:`Could not find location record: ${e}`,type:"warning"}),null)}}s.UserDeviceLocation.VIEW_CLASS=UserDeviceLocationView;const q={ec2:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"memory",label:"Memory Usage",unit:"%"},{key:"disk",label:"Disk Usage",unit:"%"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"},{key:"disk_read",label:"Disk Read Ops",unit:"ops"},{key:"disk_write",label:"Disk Write Ops",unit:"ops"},{key:"status_check",label:"Status Check",unit:""}],rds:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"conns",label:"Active Connections",unit:""},{key:"free_storage",label:"Free Storage",unit:"bytes"},{key:"free_memory",label:"Freeable Memory",unit:"bytes"},{key:"read_iops",label:"Read IOPS",unit:"ops/s"},{key:"write_iops",label:"Write IOPS",unit:"ops/s"},{key:"read_latency",label:"Read Latency",unit:"s"},{key:"write_latency",label:"Write Latency",unit:"s"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"}],redis:[{key:"cpu",label:"CPU Utilization",unit:"%"},{key:"conns",label:"Current Connections",unit:""},{key:"cache_memory",label:"Cache Memory Used",unit:"bytes"},{key:"cache_hits",label:"Cache Hits",unit:""},{key:"cache_misses",label:"Cache Misses",unit:""},{key:"replication_lag",label:"Replication Lag",unit:"s"},{key:"net_in",label:"Network In",unit:"bytes"},{key:"net_out",label:"Network Out",unit:"bytes"}]},H={ec2:"bi-pc-display",rds:"bi-database",redis:"bi-lightning-charge"},W={ec2:"EC2 Instance",rds:"RDS Database",redis:"ElastiCache Redis"};function G(e){return"%"===e?{label:"%",beginAtZero:!0,max:100}:"bytes"===e?{label:"Bytes",beginAtZero:!0}:"s"===e?{label:"Seconds",beginAtZero:!0}:{beginAtZero:!0}}class CloudWatchResourceView extends t.View{constructor(e={}){super({className:"cloudwatch-resource-view",...e}),this.resourceType=e.resourceType||"ec2",this.slug=e.slug||"",this.resource=e.resource||{}}async getTemplate(){const e=q[this.resourceType]||[],t=H[this.resourceType]||"bi-cloud",s=W[this.resourceType]||"Resource";this.resource.state||this.resource.status;const i=this._buildMetaItems().map(e=>`<span class="me-3" style="font-size: 0.78rem; color: #6c757d;">${e}</span>`).join("");return`\n <style>\n .cwrv-header { padding: 1rem 0; border-bottom: 1px solid #e9ecef; margin-bottom: 1rem; }\n .cwrv-name { font-size: 1.15rem; font-weight: 700; }\n .cwrv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n @media (max-width: 768px) { .cwrv-grid { grid-template-columns: 1fr; } }\n </style>\n\n <div class="cwrv-header">\n <div class="cwrv-name">\n <i class="bi ${t} me-2"></i>${this.slug}\n <span class="badge bg-secondary ms-2" style="font-size: 0.7rem;">${s}</span>\n </div>\n <div class="mt-1">${i}</div>\n </div>\n\n <div class="cwrv-grid">\n ${e.map((e,t)=>`<div id="cwrv-chart-${t}"></div>`).join("")}\n </div>\n `}async onInit(){const e=q[this.resourceType]||[];for(let t=0;t<e.length;t++){const s=e[t],i=new CloudWatchChart({containerId:`cwrv-chart-${t}`,account:this.resourceType,category:s.key,slug:this.slug,title:s.label,height:200,yAxis:G(s.unit),showGranularity:!0,showDateRange:!1,defaultDateRange:"24h",granularity:"hours"});this.addChild(i)}}_buildMetaItems(){const e=this.resource;switch(this.resourceType){case"ec2":return[e.instance_type,e.private_ip,e.public_ip].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-cpu","bi-hdd-network","bi-globe"][t]} me-1"></i>${e}`);case"rds":return[e.engine,e.instance_class].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-database","bi-cpu"][t]} me-1"></i>${e}`);case"redis":return[e.engine,e.node_type,e.num_nodes?`${e.num_nodes} node${e.num_nodes>1?"s":""}`:""].filter(Boolean).map((e,t)=>`<i class="bi ${["bi-lightning","bi-cpu","bi-diagram-3"][t]} me-1"></i>${e}`);default:return[]}}static async show(e,t,i={},a={}){const n=new CloudWatchResourceView({resourceType:e,slug:t,resource:i}),o=H[e]||"bi-cloud",l=W[e]||"Resource";await s.Dialog.showDialog(n,{header:`<i class="bi ${o} me-2"></i>${t} <small class="text-muted">— ${l}</small>`,size:"xl",scrollable:!0})}}function J(e,t=!0){if(e.registerPage("system/dashboard",AdminDashboardPage,{permissions:["security"]}),e.registerPage("system/jobs/dashboard",JobDashboardPage,{permissions:["view_jobs","manage_jobs"]}),e.registerPage("system/jobs/runners",JobRunnersPage,{permissions:["view_jobs"]}),e.registerPage("system/jobs/list",JobsTablePage,{permissions:["view_jobs"]}),e.registerPage("system/jobs/scheduled-tasks",ScheduledTaskTablePage,{permissions:["view_scheduled_tasks","manage_scheduled_tasks"]}),e.registerPage("system/users",UserTablePage,{permissions:["view_users","manage_users"]}),e.registerPage("system/groups",GroupTablePage,{permissions:["view_groups","manage_groups"]}),e.registerPage("system/members",MemberTablePage,{permissions:["view_members","manage_groups"]}),e.registerPage("system/s3buckets",S3BucketTablePage,{permissions:["manage_aws"]}),e.registerPage("system/filemanagers",FileManagerTablePage,{permissions:["view_fileman","manage_files"]}),e.registerPage("system/files",FileTablePage,{permissions:["manage_files"]}),e.registerPage("system/incidents",IncidentTablePage,{permissions:["view_security"]}),e.registerPage("system/events",EventTablePage,{permissions:["view_security"]}),e.registerPage("system/logs",LogTablePage,{permissions:["view_logs"]}),e.registerPage("system/user/devices",UserDeviceTablePage,{permissions:["manage_users"]}),e.registerPage("system/user/device-locations",UserDeviceLocationTablePage,{permissions:["manage_users"]}),e.registerPage("system/system/geoip",GeoLocatedIPTablePage,{permissions:["view_security","manage_users"]}),e.registerPage("system/email/mailboxes",EmailMailboxTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/domains",EmailDomainTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/sent",SentMessageTablePage,{permissions:["manage_aws"]}),e.registerPage("system/email/templates",EmailTemplateTablePage,{permissions:["manage_aws"]}),e.registerPage("system/incident-dashboard",IncidentDashboardPage,{permissions:["view_security"]}),e.registerPage("system/rulesets",RuleSetTablePage,{permissions:["manage_security"]}),e.registerPage("system/tickets",TicketTablePage,{permissions:["manage_security"]}),e.registerPage("system/metrics/permissions",MetricsPermissionsTablePage,{permissions:["manage_metrics"]}),e.registerPage("system/push/dashboard",PushDashboardPage,{permissions:["manage_notifications"]}),e.registerPage("system/push/configs",PushConfigTablePage,{permissions:["manage_push_config"]}),e.registerPage("system/push/templates",PushTemplateTablePage,{permissions:["manage_notifications"]}),e.registerPage("system/push/deliveries",PushDeliveryTablePage,{permissions:["view_notifications","manage_notifications"]}),e.registerPage("system/push/devices",PushDeviceTablePage,{permissions:["view_devices","manage_devices"]}),e.registerPage("system/phonehub/numbers",PhoneNumberTablePage,{permissions:["view_phone_numbers","manage_phone_numbers"]}),e.registerPage("system/phonehub/sms",SMSTablePage,{permissions:["view_sms","manage_sms"]}),e.registerPage("system/api-keys",ApiKeyTablePage,{permissions:["manage_groups","manage_group"]}),e.registerPage("system/settings",SettingTablePage,{permissions:["manage_settings"]}),e.registerPage("system/cloudwatch",CloudWatchDashboardPage,{permissions:["manage_aws"]}),e.registerPage("system/security/blocked-ips",BlockedIPsTablePage,{permissions:["view_security"]}),e.registerPage("system/security/firewall-log",FirewallLogTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bouncer-signals",BouncerSignalTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bouncer-devices",BouncerDeviceTablePage,{permissions:["view_security"]}),e.registerPage("system/security/bot-signatures",BotSignatureTablePage,{permissions:["manage_security"]}),e.registerPage("system/security/ipsets",IPSetTablePage,{permissions:["view_security"]}),t&&e.sidebar&&e.sidebar.getMenuConfig){const t=e.sidebar.getMenuConfig("system");if(t&&t.items){const e=[{text:"Dashboard",route:"?page=system/dashboard",icon:"bi-speedometer2",permissions:["security"]},{text:"Users",route:"?page=system/users",icon:"bi-people",permissions:["view_users","manage_users"]},{text:"Groups",route:"?page=system/groups",icon:"bi-diagram-3",permissions:["view_groups","manage_groups"]},{text:"Job Engine",route:null,icon:"bi-gear-wide-connected",permissions:["view_jobs","manage_jobs"],children:[{text:"Dashboard",route:"?page=system/jobs/dashboard",icon:"bi-bar-chart-line",permissions:["view_jobs"]},{text:"Runners",route:"?page=system/jobs/runners",icon:"bi-cpu",permissions:["view_jobs"]},{text:"Jobs",route:"?page=system/jobs/list",icon:"bi-list-task",permissions:["view_jobs"]},{text:"Scheduled Tasks",route:"?page=system/jobs/scheduled-tasks",icon:"bi-clock-history",permissions:["view_scheduled_tasks","manage_scheduled_tasks"]}]},{text:"Security",route:null,icon:"bi-shield-lock",permissions:["view_security"],children:[{text:"Dashboard",route:"?page=system/incident-dashboard",icon:"bi-bar-chart-line",permissions:["view_security"]},{text:"Incidents",route:"?page=system/incidents",icon:"bi-exclamation-triangle",permissions:["view_security"]},{text:"Tickets",route:"?page=system/tickets",icon:"bi-ticket-detailed",permissions:["manage_security"]},{text:"Events",route:"?page=system/events",icon:"bi-bell",permissions:["view_security"]},{text:"Rule Engine",route:"?page=system/rulesets",icon:"bi-funnel",permissions:["manage_security"]},{text:"Blocked IPs",route:"?page=system/security/blocked-ips",icon:"bi-slash-circle",permissions:["view_security"]},{text:"IP Sets",route:"?page=system/security/ipsets",icon:"bi-shield-shaded",permissions:["view_security"]},{text:"Firewall Log",route:"?page=system/security/firewall-log",icon:"bi-journal-code",permissions:["view_security"]},{text:"GeoIP",route:"?page=system/system/geoip",icon:"bi-globe",permissions:["view_security"]},{text:"Bouncer Signals",route:"?page=system/security/bouncer-signals",icon:"bi-activity",permissions:["view_security"]},{text:"Bouncer Devices",route:"?page=system/security/bouncer-devices",icon:"bi-fingerprint",permissions:["view_security"]},{text:"Bot Signatures",route:"?page=system/security/bot-signatures",icon:"bi-robot",permissions:["manage_security"]}]},{text:"Email",route:null,icon:"bi-envelope",permissions:["manage_aws"],children:[{text:"Domains",route:"?page=system/email/domains",icon:"bi-globe",permissions:["manage_aws"]},{text:"Mailboxes",route:"?page=system/email/mailboxes",icon:"bi-inbox",permissions:["manage_aws"]},{text:"Sent",route:"?page=system/email/sent",icon:"bi-send-check",permissions:["manage_aws"]},{text:"Templates",route:"?page=system/email/templates",icon:"bi-file-text",permissions:["manage_aws"]}]},{text:"Push Notifications",route:null,icon:"bi-broadcast",permissions:["manage_notifications","manage_push_config"],children:[{text:"Dashboard",route:"?page=system/push/dashboard",icon:"bi-bar-chart-line",permissions:["manage_notifications"]},{text:"Configurations",route:"?page=system/push/configs",icon:"bi-gear",permissions:["manage_push_config"]},{text:"Templates",route:"?page=system/push/templates",icon:"bi-file-earmark-text",permissions:["manage_notifications"]},{text:"Deliveries",route:"?page=system/push/deliveries",icon:"bi-send",permissions:["view_notifications","manage_notifications"]},{text:"Devices",route:"?page=system/push/devices",icon:"bi-phone",permissions:["view_devices","manage_devices"]}]},{text:"Phone Hub",route:null,icon:"bi-telephone",permissions:["view_phone_numbers","manage_phone_numbers"],children:[{text:"Numbers",route:"?page=system/phonehub/numbers",icon:"bi-collection",permissions:["view_phone_numbers","manage_phone_numbers"]},{text:"SMS",route:"?page=system/phonehub/sms",icon:"bi-chat-dots",permissions:["view_sms","manage_sms"]}]},{text:"Storage",route:null,icon:"bi-folder",permissions:["manage_files","manage_aws"],children:[{text:"S3 Buckets",route:"?page=system/s3buckets",icon:"bi-bucket",permissions:["manage_aws"]},{text:"Storage Backends",route:"?page=system/filemanagers",icon:"bi-hdd-stack",permissions:["view_fileman","manage_files"]},{text:"Files",route:"?page=system/files",icon:"bi-file-earmark",permissions:["manage_files"]}]},{text:"System",route:null,icon:"bi-wrench-adjustable",permissions:["view_logs","manage_settings","manage_groups"],children:[{text:"Logs",route:"?page=system/logs",icon:"bi-journal-text",permissions:["view_logs"]},{text:"API Keys",route:"?page=system/api-keys",icon:"bi-key",permissions:["manage_groups","manage_group"]},{text:"User Devices",route:"?page=system/user/devices",icon:"bi-phone",permissions:["manage_users"]},{text:"Device Locations",route:"?page=system/user/device-locations",icon:"bi-geo-alt",permissions:["manage_users"]},{text:"Metrics Permissions",route:"?page=system/metrics/permissions",icon:"bi-bar-chart-line",permissions:["manage_metrics"]},{text:"Settings",route:"?page=system/settings",icon:"bi-gear",permissions:["manage_settings"]},{text:"CloudWatch",route:"?page=system/cloudwatch",icon:"bi-cloud",permissions:["manage_aws"]}]}];t.items.unshift(...e)}}}exports.WebApp=m.WebApp,exports.BUILD_TIME=u.BUILD_TIME,exports.VERSION=u.VERSION,exports.VERSION_INFO=u.VERSION_INFO,exports.VERSION_MAJOR=u.VERSION_MAJOR,exports.VERSION_MINOR=u.VERSION_MINOR,exports.VERSION_REVISION=u.VERSION_REVISION,exports.AdminDashboardPage=AdminDashboardPage,exports.ApiKeyTablePage=ApiKeyTablePage,exports.ApiKeyView=ApiKeyView,exports.AssistantView=AssistantView,exports.BlockedIPsTablePage=BlockedIPsTablePage,exports.BotSignatureTablePage=BotSignatureTablePage,exports.BouncerDeviceTablePage=BouncerDeviceTablePage,exports.BouncerDeviceView=BouncerDeviceView,exports.BouncerSignalTablePage=BouncerSignalTablePage,exports.BouncerSignalView=BouncerSignalView,exports.CloudWatchChart=CloudWatchChart,exports.CloudWatchDashboardPage=CloudWatchDashboardPage,exports.CloudWatchResourceView=CloudWatchResourceView,exports.DeviceView=DeviceView,exports.EmailDomainTablePage=EmailDomainTablePage,exports.EmailMailboxTablePage=EmailMailboxTablePage,exports.EmailTemplateTablePage=EmailTemplateTablePage,exports.EmailTemplateView=EmailTemplateView,exports.EmailView=EmailView,exports.EventTablePage=EventTablePage,exports.EventView=EventView,exports.FileManagerTablePage=FileManagerTablePage,exports.FileTablePage=FileTablePage,exports.FileView=FileView,exports.FirewallLogTablePage=FirewallLogTablePage,exports.GeoIPView=GeoIPView,exports.GeoLocatedIPTablePage=GeoLocatedIPTablePage,exports.GroupTablePage=GroupTablePage,exports.GroupView=GroupView,exports.HandlerBuilderView=HandlerBuilderView,exports.IPSetTablePage=IPSetTablePage,exports.IPSetView=IPSetView,exports.IncidentDashboardPage=IncidentDashboardPage,exports.IncidentTablePage=IncidentTablePage,exports.IncidentView=IncidentView,exports.JobDashboardPage=JobDashboardPage,exports.JobDetailsView=JobDetailsView,exports.JobHealthView=JobHealthView,exports.JobRunnersPage=JobRunnersPage,exports.JobStatsView=JobStatsView,exports.JobsTablePage=JobsTablePage,exports.LogTablePage=LogTablePage,exports.LogView=LogView,exports.MemberTablePage=MemberTablePage,exports.MemberView=MemberView,exports.MetricsPermissionsTablePage=MetricsPermissionsTablePage,exports.MetricsPermissionsView=MetricsPermissionsView,exports.PhoneNumberTablePage=PhoneNumberTablePage,exports.PhoneNumberView=PhoneNumberView,exports.PushConfigTablePage=PushConfigTablePage,exports.PushDashboardPage=PushDashboardPage,exports.PushDeliveryTablePage=PushDeliveryTablePage,exports.PushDeliveryView=PushDeliveryView,exports.PushDeviceTablePage=PushDeviceTablePage,exports.PushDeviceView=PushDeviceView,exports.PushTemplateTablePage=PushTemplateTablePage,exports.RuleSetTablePage=RuleSetTablePage,exports.RuleSetView=RuleSetView,exports.RunnerDetailsView=RunnerDetailsView,exports.S3BucketTablePage=S3BucketTablePage,exports.SMSTablePage=SMSTablePage,exports.ScheduledTaskTablePage=ScheduledTaskTablePage,exports.ScheduledTaskView=ScheduledTaskView,exports.SentMessageTablePage=SentMessageTablePage,exports.SettingTablePage=SettingTablePage,exports.SettingView=SettingView,exports.TicketTablePage=TicketTablePage,exports.TicketView=TicketView,exports.UserDeviceLocationTablePage=UserDeviceLocationTablePage,exports.UserDeviceLocationView=UserDeviceLocationView,exports.UserDeviceTablePage=UserDeviceTablePage,exports.UserTablePage=UserTablePage,exports.UserView=UserView,exports.registerAdminPages=J,exports.registerAssistant=function(e){const t={id:"assistant",icon:"bi-robot",action:"open-assistant",isButton:!0,buttonClass:"btn btn-link nav-link",tooltip:"Admin Assistant",permissions:["view_admin"],handler:async()=>{const{default:t}=await Promise.resolve().then(()=>w),{default:s}=await Promise.resolve().then(()=>require("./chunks/Modal-Y1PW_Fmf.js")),i=new t({app:e});s.show(i,{size:"fullscreen",noBodyPadding:!0,title:" ",buttons:[]})}};e.topbar&&e.topbar.config?(e.topbar.config.rightItems.unshift(t),e.topbar.isMounted()&&e.topbar.render()):e.topbarConfig&&(e.topbarConfig.rightItems||(e.topbarConfig.rightItems=[]),e.topbarConfig.rightItems.unshift(t))},exports.registerSystemPages=J;
|
|
2
2
|
//# sourceMappingURL=admin.cjs.js.map
|