web-mojo 2.5.2 → 2.5.3

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 CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./chunks/ContextMenu-DavvvDHE.js"),t=require("./chunks/User-B1rsVKZn.js"),i=require("./chunks/exportChart-CbdjAe3W.js"),s=require("./chunks/MetricsCountryMapView-BtOe426O.js"),a=require("./chunks/Modal-BlwwVcGL.js"),n=require("./chunks/ChatView-BPJGwNB-.js"),o=require("./chunks/FormView-DNuchWxp.js"),l=require("./chunks/Passkeys-1N9xo02Z.js"),r=require("./chunks/ListView-DbLe2wcm.js"),c=require("./chunks/admin-models-s9AXuwJ1.js"),d=require("./chunks/DataView-BClQAaKx.js"),h=require("./chunks/WebApp-B4a19_WH.js"),u=require("./chunks/version-arvp8nRj.js");class KPITile extends t.View{constructor(e={}){super({tagName:"button",...e,className:`mojo-kpi-tile ${e.severity?"mojo-kpi-tile-"+e.severity:""} ${e.className||""}`.trim()}),this.slug=e.slug||null,this.label=e.label||"",this.value=e.value??null,this.delta=e.delta??null,this.deltaPct=e.deltaPct??null,this.severity=e.severity||null,this.tone=e.tone||null,this.sparklineValues=Array.isArray(e.sparkline)?e.sparkline.slice():[],this.sparklineColor=e.sparklineColor||null,this.sparklineHeight=e.sparklineHeight||36,this.formatter="function"==typeof e.formatter?e.formatter:null,this.element.setAttribute("type","button")}async onInit(){this.sparkline=new i.MiniChart({containerId:"spark",chartType:"line",data:this.sparklineValues,color:this.sparklineColor||this._defaultSparkColor(),fillColor:this._fillColorFor(this.sparklineColor||this._defaultSparkColor()),fill:!0,smoothing:.3,height:this.sparklineHeight,width:"100%",showTooltip:!1,showCrosshair:!1,showXAxis:!1,animate:!1,padding:2}),this.addChild(this.sparkline)}async getTemplate(){const e=this._formatValue(this.value),t=this._renderDelta();return`\n <span class="mojo-kpi-tile-label">${this.escapeHtml(this.label)}</span>\n <span class="mojo-kpi-tile-value">${this.escapeHtml(e)}</span>\n ${t}\n <div data-container="spark" class="mojo-kpi-tile-spark"></div>\n `}async onAfterRender(){this._clickBound||(this.element.addEventListener("click",()=>{this.emit?.("tile:click",{tile:this,slug:this.slug})}),this._clickBound=!0)}setData({value:e,delta:t,deltaPct:i,sparkline:s}={}){void 0!==e&&(this.value=e),void 0!==t&&(this.delta=t),void 0!==i&&(this.deltaPct=i),void 0!==s&&(this.sparklineValues=Array.isArray(s)?s.slice():[],this.sparkline?.setData(this.sparklineValues)),this.isMounted()&&this.render()}_defaultSparkColor(){return{critical:"rgba(220, 53, 69, 1)",high:"rgba(253, 126, 20, 1)",warn:"rgba(255, 193, 7, 1)",info:"rgba(13, 202, 240, 1)",good:"rgba(25, 135, 84, 1)"}[this.severity]||"rgba(13, 202, 240, 1)"}_fillColorFor(e){const t=String(e).match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);return t?`rgba(${t[1]}, ${t[2]}, ${t[3]}, 0.12)`:"rgba(108, 117, 125, 0.12)"}_formatValue(e){return null==e?"—":this.formatter?this.formatter(e):"number"==typeof e?e.toLocaleString():String(e)}_renderDelta(){if(null==this.deltaPct&&null==this.delta)return"";let e,t;if(null!=this.deltaPct&&Number.isFinite(this.deltaPct)){const i=Math.abs(this.deltaPct)>=10?Math.round(this.deltaPct):Math.round(10*this.deltaPct)/10;t=i>0?"+":i<0?"−":"±",e=`${t}${Math.abs(i)}%`}else{if(null==this.delta)return"";t=this.delta>0?"+":this.delta<0?"−":"±",e=`${t}${Math.abs(this.delta)}`}let i="mojo-kpi-tile-delta";return"±"===t?i+=" mojo-kpi-tile-delta-flat":"bad"===this.tone?i+="+"===t?" mojo-kpi-tile-delta-bad":" mojo-kpi-tile-delta-good":"good"===this.tone?i+="+"===t?" mojo-kpi-tile-delta-good":" mojo-kpi-tile-delta-bad":i+=" mojo-kpi-tile-delta-neutral",`<span class="${i}">${this.escapeHtml(e)}</span>`}}class KPIStrip extends t.View{constructor(e={}){super({...e,className:`mojo-kpi-strip ${e.className||""}`.trim()}),this.tiles=Array.isArray(e.tiles)?e.tiles:[],this.account=e.account||"incident",this.granularity=e.granularity||"days",this.sparklineDays=e.sparklineDays??7,this.sparklineGranularity=e.sparklineGranularity||"days",this.seriesEndpoint=e.seriesEndpoint||"/api/metrics/series",this.fetchEndpoint=e.fetchEndpoint||"/api/metrics/fetch",this.includeSparkline=!1!==e.includeSparkline,this.tileHeight=e.tileHeight||36,this._tileViews=[]}async getTemplate(){return'<div class="mojo-kpi-strip-grid" data-container="grid"></div>'}async onInit(){for(let e=0;e<this.tiles.length;e++){const t=this.tiles[e],i=new KPITile({containerId:`kpi-tile-${e}`,slug:t.slug||t.key||`tile-${e}`,label:t.label||t.slug||"",severity:t.severity||null,tone:t.tone||null,sparklineHeight:this.tileHeight,formatter:t.formatter||null});i.on?.("tile:click",e=>{this.emit?.("tile:click",{...e,key:t.key||t.slug})}),this._tileViews.push(i),this.addChild(i)}}async getViewData(){return{...this.data,tilesHtml:this._tileViews.map((e,t)=>`<div data-container="kpi-tile-${t}" class="mojo-kpi-strip-cell"></div>`).join("")}}async renderTemplate(){return`<div class="mojo-kpi-strip-grid">${this._tileViews.map((e,t)=>`<div data-container="kpi-tile-${t}" class="mojo-kpi-strip-cell"></div>`).join("")}</div>`}async onAfterRender(){await this.refresh()}async refresh(){const e=this.getApp()?.rest;if(!e)return;const t=this.tiles.filter(e=>e.slug),i=this.tiles.filter(e=>e.rest),s=Array.from(/* @__PURE__ */new Set([...t.map(e=>e.slug),...i.map(e=>e.sparklineSlug).filter(Boolean)]));t.map(e=>e.slug);const a=[];let n=null;if(s.length){const t={slugs:s.join(","),account:this.account,granularity:this.granularity,with_delta:!0,_:Date.now()};n=e.GET(this.seriesEndpoint,t).catch(e=>(console.warn("[KPIStrip] series fetch failed:",e),null)),a.push(n)}let o=null;if(this.includeSparkline&&s.length){const t=new Date(Date.now()-864e5*this.sparklineDays),i={slugs:s.join(","),account:this.account,granularity:this.sparklineGranularity,with_labels:!0,dr_start:Math.floor(t.getTime()/1e3),_:Date.now()};o=e.GET(this.fetchEndpoint,i).catch(e=>(console.warn("[KPIStrip] sparkline fetch failed:",e),null)),a.push(o)}const l=i.map(t=>{const i={...t.rest.params||{},_:Date.now()};return e.GET(t.rest.endpoint,i).catch(e=>(console.warn(`[KPIStrip] count fetch failed for ${t.label}:`,e),null))});a.push(...l),await Promise.allSettled(a);const r=n?await n:null,c=o?await o:null,d=this._unwrap(r),h=this._unwrap(c)?.data;for(const m of t){const e=this.tiles.indexOf(m),t=this._tileViews[e];if(!t)continue;const i=d?.data?.[m.slug]??null,s=d?.deltas?.[m.slug]||{},a=s.delta??null,n=s.delta_pct??null,o=h?.data?.[m.slug],l=h?.data?.default,r=Array.isArray(o)?o:Array.isArray(l)?l:[];t.setData({value:i,delta:a,deltaPct:n,sparkline:r})}let u=0;for(const m of i){const e=this.tiles.indexOf(m),t=this._tileViews[e];if(!t)continue;const i=await l[u++],s={value:this._readRestCount(i),delta:null,deltaPct:null};if(m.sparklineSlug){const e=h?.data?.[m.sparklineSlug],t=h?.data?.default;s.sparkline=Array.isArray(e)?e:Array.isArray(t)?t:[];const i=d?.deltas?.[m.sparklineSlug];i&&(s.delta=i.delta??null,s.deltaPct=i.delta_pct??null)}t.setData(s)}this.emit?.("strip:refreshed")}_unwrap(e){return e?e.success&&e.data?e.data:e:null}_readRestCount(e){if(!e)return null;const t=e.data||e;return"number"==typeof t?.count?t.count:"number"==typeof t?.data?.count?t.data.count:null}}const m={success_login:"rgba(32, 201, 151, 0.85)",success:"rgba(32, 201, 151, 0.85)",login:"rgba(32, 201, 151, 0.85)",failed_login:"rgba(220, 53, 69, 0.85)",failure:"rgba(220, 53, 69, 0.85)",failed:"rgba(220, 53, 69, 0.85)",suspicious:"rgba(255, 193, 7, 0.85)",mfa_required:"rgba(255, 193, 7, 0.85)",mfa:"rgba(255, 193, 7, 0.85)"};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.viewMode=e.viewMode||"summary",this.listZoom=e.listZoom??3.3,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-flex align-items-center gap-2 mb-2">\n <div class="d-none align-items-center gap-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 class="ms-auto btn-group btn-group-sm" role="group">\n <button class="btn btn-sm btn-outline-secondary ${"summary"===this.viewMode?"active":""}"\n data-action="set-mode" data-mode="summary"\n title="Aggregated by country">\n <i class="bi bi-globe-americas"></i>\n </button>\n <button class="btn btn-sm btn-outline-secondary ${"list"===this.viewMode?"active":""}"\n data-action="set-mode" data-mode="list"\n title="Every login">\n <i class="bi bi-pin-map"></i>\n </button>\n </div>\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{this.mapView=new s.MapLibreView({containerId:"map",height:this.height,style:this.mapStyle,...this._defaultView(),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 onActionSetMode(e,t){const i=t?.dataset?.mode;if(!i||i===this.viewMode)return;this.viewMode=i,this._drillCountry=null,this._hideDrillBar(),this.element?.querySelectorAll('[data-action="set-mode"]').forEach(e=>{e.classList.toggle("active",e.dataset.mode===this.viewMode)});const{center:s,zoom:a}=this._defaultView();this.mapView?.map?.flyTo({center:s,zoom:a,duration:600}),await this.refresh()}_defaultView(){return"list"===this.viewMode?{center:[-98.58,39.83],zoom:this.listZoom}:{center:[10,20],zoom:1.3}}async refresh(){if(!this._refreshing&&this._mapAvailable){this._refreshing=!0,this._setStatus("Loading locations…");try{if("list"===this.viewMode){const e=await this._fetchList();this._applyListMarkers(e)}else{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 i={};let s;this.drStart&&(i.dr_start=this.drStart),this.drEnd&&(i.dr_end=this.drEnd),this.userId?(s="/api/account/logins/user",i.user_id=this.userId):s="/api/account/logins/summary",e&&(i.country_code=e,i.region=!0);const a=await t.GET(s,i);if(!a.success||!a.data?.status)throw new Error(a.data?.error||"Login summary API error");return a.data.data||[]}async _fetchList(){const e=this.getApp()?.rest;if(!e)throw new Error("REST client unavailable");const t={graph:"list",size:1e3,sort:"-created"};this.drStart&&(t.dr_start=this.drStart),this.drEnd&&(t.dr_end=this.drEnd),this.userId&&(t.user=this.userId);const i=await e.GET("/api/account/logins",t);if(!i.success||!i.data?.status)throw new Error(i.data?.error||"Login events API error");return i.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)),i=e.filter(e=>e.latitude&&e.longitude).map(e=>{const i=e.count/(t||1),s=Math.round(18+26*i),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:s,color:this._getMarkerColor(i),popup:r,_countryCode:e.country_code,_isRegion:a}});this.mapView.updateMarkers(i),this._drillCountry||"summary"!==this.viewMode||this._attachMarkerClicks(i)}_attachMarkerClicks(e){this.mapView?.mapMarkers&&this.mapView.mapMarkers.forEach((t,i)=>{const s=e[i];if(!s||s._isRegion)return;const a=t.getElement();a&&a.addEventListener("dblclick",e=>{e.stopPropagation(),this.drillDown(s._countryCode)})})}_getMarkerColor(e){const t=[255,193,7],i=[32,201,151].map((i,s)=>Math.round(i+(t[s]-i)*e));return`rgba(${i[0]}, ${i[1]}, ${i[2]}, 0.9)`}_applyListMarkers(e){const t=e.filter(e=>e.latitude&&e.longitude);if(!t.length)return this.mapView.updateMarkers([]),void this._setStatus("No login events with location data found.");const i=t.map(e=>{const t=[e.city,e.region,e.country_code].filter(Boolean).join(", "),i=e.created?new Date(1e3*e.created).toLocaleString():"—",s=!this.userId&&e.user?.id?`<button class="btn btn-outline-primary mt-2 w-100"\n style="font-size:0.7rem;padding:2px 8px;"\n data-action="open-user"\n data-user-id="${e.user.id}">\n <i class="bi bi-person me-1"></i>${e.user.display_name||e.user.username||"View User"}\n </button>`:"",a=`\n <div style="min-width:160px;">\n <div class="fw-semibold">${t||"—"}</div>\n <div class="text-muted small"><code>${e.ip_address||""}</code></div>\n <div class="text-muted small">${i}</div>\n ${e.source?`<div class="text-muted small">via ${e.source}</div>`:""}\n ${s}\n </div>\n `;return{lng:e.longitude,lat:e.latitude,size:10,color:this._getEventColor(e.event_type),popup:a}});this.mapView.updateMarkers(i),this._setStatus(`${i.length.toLocaleString()} login${1!==i.length?"s":""} plotted`)}async onActionOpenUser(e,i){const s=Number(i?.dataset?.userId);if(s)if(t.User.VIEW_CLASS)try{await a.Modal.showModelById(t.User,s)}catch(n){console.error("LoginLocationMapView: failed to open user",n)}else console.warn("LoginLocationMapView: User.VIEW_CLASS not registered")}_getEventColor(e){return m[String(e||"").toLowerCase()]||"rgba(108, 117, 125, 0.85)"}async drillDown(e){if(!this._refreshing&&"summary"===this.viewMode){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"]'),i=this.element?.querySelector('[data-region="drill-label"]');t&&t.classList.replace("d-none","d-flex"),i&&(i.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 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!-- Login Locations Map --\x3e\n <div class="row mb-4">\n <div class="col-12">\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-transparent border-bottom d-flex align-items-center gap-2">\n <i class="bi bi-geo-alt"></i>\n <span class="fw-semibold">Login Locations</span>\n <span class="text-muted small ms-1">— last 30 days</span>\n </div>\n <div class="card-body p-0">\n <div data-container="login-map"></div>\n </div>\n </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);const e=/* @__PURE__ */new Date,t=new Date(e.getTime()-2592e6);this.loginMapView=new LoginLocationMapView({containerId:"login-map",height:360,mapStyle:"dark",drStart:t.toISOString().slice(0,10),drEnd:e.toISOString().slice(0,10)}),this.addChild(this.loginMapView),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:"bar",showDateRange:!1,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"api-metrics-chart"}),this.addChild(this.apiMetricsChart)}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");try{s?.classList.add("bi-spin"),i&&(i.disabled=!0);const e=[this.headerView?.loadValues(),this.apiMetricsChart?.refresh(),this.loginMapView?.refresh()].filter(Boolean);await Promise.allSettled(e),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString();const t=this.getApp()?.events;t&&t.emit("admin:dashboard-refreshed",{page:this,timestamp:this.lastUpdated})}catch(a){console.error("Failed to refresh dashboard:",a);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{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}async onActionExportMetrics(e,t){try{this.apiMetricsChart?.chart&&i.exportChartPng(this.apiMetricsChart.chart);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 i=this.getApp()?.router;i&&i.navigateTo("/admin/alerts")}async onActionViewSystemStatus(e,t){const i=this.getApp()?.router;i&&i.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()}}const p=t.MOJOUtils.escapeHtml;function b(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}function g(e){const t=b(e);if(null==t)return"—";const i=Math.round((Date.now()-t)/1e3);return i<0?"in the future":i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}function v(e){const t=e?.user_agent||{},i=[t.family,t.major].filter(Boolean);return i.length?i.join(" "):"Unknown browser"}function y(e){const t=e.get("device_info")||{};return t.last_geo||t.geolocation||e.get("last_geo")||{}}function w(e,t){return function(e){const t=y(e),i=[];return t.is_vpn&&i.push({key:"vpn",label:"VPN"}),t.is_tor&&i.push({key:"tor",label:"Tor"}),t.is_proxy&&i.push({key:"proxy",label:"Proxy"}),t.is_cloud&&i.push({key:"cloud",label:"Cloud"}),i}(e).some(e=>e.key===t)}class DeviceLocationRow extends l.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 text-bg-warning">VPN</span>'),e.is_tor&&t.push('<span class="badge text-bg-danger">Tor</span>'),e.is_proxy&&t.push('<span class="badge text-bg-warning">Proxy</span>'),e.is_cloud&&t.push('<span class="badge text-bg-info">Cloud</span>'),t.join(" ")}get hasThreatFlags(){const e=this.model?.get("geolocation")||{};return!!(e.is_vpn||e.is_tor||e.is_proxy||e.is_cloud)}}class DeviceOverviewSection extends t.View{constructor(e={}){const{locationsCollection:t,...i}=e;super({className:"device-overview-section",template:'\n <div class="detail-section-eyebrow">Overview</div>\n <div class="detail-kpi-grid">\n <div data-container="dv-kpi-sessions"></div>\n <div data-container="dv-kpi-locations"></div>\n <div data-container="dv-kpi-days"></div>\n <div data-container="dv-kpi-last-login"></div>\n </div>\n\n <div class="detail-section-eyebrow">Threat signals</div>\n <div data-container="dv-overview-threats"></div>\n ',...i}),this.locationsCollection=t||null}async onInit(){const e=this.model;this.kpiSessions=new n.MetricCard({containerId:"dv-kpi-sessions",label:"Sessions",value:()=>{const t=e.get("session_count")??e.get("sessions");return null==t?"—":String(t)}}),this.kpiLocations=new n.MetricCard({containerId:"dv-kpi-locations",label:"Locations",value:()=>{const e=this._readLocationsCount();return null==e?"—":String(e)}}),this.kpiDays=new n.MetricCard({containerId:"dv-kpi-days",label:"Days active",value:()=>{const t=function(e){const t=b(e.get("first_seen")),i=b(e.get("last_seen"));return null==t||null==i?null:Math.max(0,Math.floor((i-t)/864e5))}(e);return null==t?"—":String(t)}}),this.kpiLastLogin=new n.MetricCard({containerId:"dv-kpi-last-login",label:"Last login",value:()=>e.get("last_seen")?g(e.get("last_seen")):"Never",tone:e.get("last_seen")?"success":"default"}),[this.kpiSessions,this.kpiLocations,this.kpiDays,this.kpiLastLogin].forEach(e=>this.addChild(e)),this.threatTimeline=new n.Timeline({containerId:"dv-overview-threats",model:e,emptyText:"No threat signals recorded.",items:e=>this._threatItems(e)}),this.addChild(this.threatTimeline),this.locationsCollection&&this.locationsCollection.on("fetch:success",()=>{this.kpiLocations?.isMounted?.()&&this.kpiLocations.render()},this)}_readLocationsCount(){return this.locationsCollection?this.locationsCollection.totalCount??this.locationsCollection.models?.length??null:null}_threatItems(e){const t=e||this.model,i=y(t),s=!!t.get("is_trusted"),a=t.get("last_seen")?g(t.get("last_seen")):"unknown",n=[];n.push({tone:s?"success":"default",headline:s?"Marked trusted":"Not marked trusted",detail:s?"Operator has flagged this device as safe.":"No trust override set.",when:a});const o=w(t,"vpn");n.push({tone:o?"warning":"success",headline:o?"VPN detected":"No VPN signal",detail:o?"Last session originated from a VPN exit.":"",when:"live"});const l=w(t,"tor");n.push({tone:l?"danger":"success",headline:l?"Seen from a Tor exit":"No Tor signal",detail:l?"Recent activity routed through the Tor network.":"",when:"live"}),w(t,"proxy")&&n.push({tone:"warning",headline:"Open proxy detected",detail:"Last session went through an open proxy.",when:"live"});const r=t.get("location_count")??null,c=null!=r?`${r} distinct location${1===r?"":"s"}`:i.country_name?`Last from ${p(i.country_name)}`:"Location unknown";n.push({tone:"info",headline:"Geo footprint",detail:c,when:"live"});const d=t.get("duid");return d&&n.push({tone:"default",headline:"Device fingerprint",detail:`<code>${p(String(d))}</code>`,when:t.get("first_seen")?g(t.get("first_seen")):""}),n}}class DeviceHardwareSection extends t.View{constructor(e={}){super({className:"device-hardware-section",template:'\n <div class="detail-section-eyebrow">Browser</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.user_agent.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Version</div>\n <div class="detail-flat-row-value">\n {{#hasBrowserVersion|bool}}{{browserVersion}}{{/hasBrowserVersion|bool}}\n {{^hasBrowserVersion|bool}}<span class="text-secondary fst-italic">—</span>{{/hasBrowserVersion|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Engine</div>\n <div class="detail-flat-row-value">{{model.device_info.user_agent.engine|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Operating system</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.os.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Version</div>\n <div class="detail-flat-row-value">\n {{#hasOsVersion|bool}}{{osVersion}}{{/hasOsVersion|bool}}\n {{^hasOsVersion|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOsVersion|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Hardware</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Brand</div>\n <div class="detail-flat-row-value">{{model.device_info.device.brand|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.device.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Model</div>\n <div class="detail-flat-row-value">{{model.device_info.device.model|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Display &amp; environment</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Resolution</div>\n <div class="detail-flat-row-value">\n {{#hasResolution|bool}}{{resolutionDisplay}}{{/hasResolution|bool}}\n {{^hasResolution|bool}}<span class="text-secondary fst-italic">—</span>{{/hasResolution|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Pixel ratio</div>\n <div class="detail-flat-row-value">\n {{#hasPixelRatio|bool}}{{pixelRatioDisplay}}{{/hasPixelRatio|bool}}\n {{^hasPixelRatio|bool}}<span class="text-secondary fst-italic">—</span>{{/hasPixelRatio|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Color depth</div>\n <div class="detail-flat-row-value">\n {{#hasColorDepth|bool}}{{colorDepthDisplay}}{{/hasColorDepth|bool}}\n {{^hasColorDepth|bool}}<span class="text-secondary fst-italic">—</span>{{/hasColorDepth|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Locale</div>\n <div class="detail-flat-row-value">\n {{#model.device_info.locale}}<code>{{model.device_info.locale}}</code>{{/model.device_info.locale}}\n {{^model.device_info.locale}}<span class="text-secondary fst-italic">—</span>{{/model.device_info.locale}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#model.device_info.timezone}}<code>{{model.device_info.timezone}}</code>{{/model.device_info.timezone}}\n {{^model.device_info.timezone}}<span class="text-secondary fst-italic">—</span>{{/model.device_info.timezone}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Identification</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Device ID</div>\n <div class="detail-flat-row-value">\n {{#model.duid}}<code>{{model.duid}}</code>{{/model.duid}}\n {{^model.duid}}<span class="text-secondary fst-italic">—</span>{{/model.duid}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last IP</div>\n <div class="detail-flat-row-value">\n {{#model.last_ip}}<code>{{model.last_ip}}</code>{{/model.last_ip}}\n {{^model.last_ip}}<span class="text-secondary fst-italic">—</span>{{/model.last_ip}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">First seen</div>\n <div class="detail-flat-row-value">\n {{#model.first_seen}}<code>{{model.first_seen|datetime}}</code>{{/model.first_seen}}\n {{^model.first_seen}}<span class="text-secondary fst-italic">—</span>{{/model.first_seen}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last seen</div>\n <div class="detail-flat-row-value">\n {{#model.last_seen}}<code>{{model.last_seen|datetime}}</code>{{/model.last_seen}}\n {{^model.last_seen}}<span class="text-secondary fst-italic">—</span>{{/model.last_seen}}\n </div>\n </div>\n\n {{#hasUaString|bool}}\n <div class="detail-section-eyebrow">User agent</div>\n <pre class="detail-error-block">{{model.device_info.string}}</pre>\n {{/hasUaString|bool}}\n ',...e})}get hasBrowserVersion(){const e=this.model?.get("device_info")?.user_agent||{};return[e.major,e.minor,e.patch].some(e=>null!=e&&""!==e)}get browserVersion(){const e=this.model?.get("device_info")?.user_agent||{};return[e.major,e.minor,e.patch].filter(e=>null!=e&&""!==e).join(".")}get hasOsVersion(){const e=this.model?.get("device_info")?.os||{};return[e.major,e.minor,e.patch].some(e=>null!=e&&""!==e)}get osVersion(){const e=this.model?.get("device_info")?.os||{};return[e.major,e.minor,e.patch].filter(e=>null!=e&&""!==e).join(".")}get hasResolution(){const e=this.model?.get("device_info")?.screen||{};return!(!e.width||!e.height)}get resolutionDisplay(){const e=this.model?.get("device_info")?.screen||{};return`${e.width} × ${e.height}`}get hasPixelRatio(){return null!=(this.model?.get("device_info")?.screen||{}).pixel_ratio}get pixelRatioDisplay(){return`${(this.model?.get("device_info")?.screen||{}).pixel_ratio}×`}get hasColorDepth(){return null!=(this.model?.get("device_info")?.screen||{}).color_depth}get colorDepthDisplay(){return`${(this.model?.get("device_info")?.screen||{}).color_depth}-bit`}get hasUaString(){return!!this.model?.get("device_info")?.string}}class DeviceSessionsSection extends t.View{constructor(e={}){super({className:"device-sessions-section",template:'\n <div class="detail-section-eyebrow">Sessions</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value text-secondary fst-italic">\n Session history is not yet recorded server-side. Once a\n UserDeviceSession collection lands, this section will\n list every login / token-grant for this device.\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Sessions seen</div>\n <div class="detail-flat-row-value">\n {{#hasSessionCount|bool}}<strong>{{sessionCount}}</strong>{{/hasSessionCount|bool}}\n {{^hasSessionCount|bool}}<span class="text-secondary fst-italic">—</span>{{/hasSessionCount|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last login</div>\n <div class="detail-flat-row-value">\n {{#model.last_seen}}<code>{{model.last_seen|datetime}}</code> <span class="text-secondary">· {{model.last_seen|relative}}</span>{{/model.last_seen}}\n {{^model.last_seen}}<span class="text-secondary fst-italic">Never</span>{{/model.last_seen}}\n </div>\n </div>\n ',...e})}get hasSessionCount(){return null!=(this.model?.get("session_count")??this.model?.get("sessions"))}get sessionCount(){const e=this.model?.get("session_count")??this.model?.get("sessions");return null==e?"":String(e)}}class DeviceMetadataSection extends t.View{constructor(e={}){super({className:"device-metadata-section",template:'\n <div class="detail-section-eyebrow">Metadata</div>\n <div data-container="dv-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"dv-metadata-card",model:this.model,data:e=>({...e.get("metadata")||{},_record_id:e.get("id"),_user:e.get("user"),_duid:e.get("duid"),_first_seen:e.get("first_seen"),_last_seen:e.get("last_seen"),_is_trusted:e.get("is_trusted")}),knownKeys:[{key:"_record_id",label:"Record ID",formatter:e=>null!=e?`<code>${p(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"_duid",label:"DUID",formatter:e=>e?`<code>${p(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"_user.display_name",label:"Owner",hideEmpty:!0},{key:"_first_seen",label:"First seen",formatter:"datetime"},{key:"_last_seen",label:"Last seen",formatter:"datetime"},{key:"_is_trusted",label:"Trusted",formatter:"yesnoicon"}],rawLabel:"Raw metadata",rawCollapsed:!0,emptyText:"No metadata recorded for this device."}),this.addChild(this.knownFields)}}class DeviceView extends n.DetailView{constructor(e={}){const i=e.model||new t.UserDevice(e.data||{}),s=i.get("device_info")||{},a=new t.UserDeviceLocationList({params:{user_device:i.get("id"),size:10}}),n=new DeviceOverviewSection({model:i,locationsCollection:a}),o=new DeviceHardwareSection({model:i}),r=new l.TableView({collection:a,title:"Locations",eyebrow:"Section · Locations",showFullscreen:!1,showRefresh:!0,searchable:!1,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["user_device"],clickAction:"view",itemClass:DeviceLocationRow,selectable:!1,columns:[{key:"ip_address",label:"Location",template:'\n <div class="fw-semibold small">\n <i class="bi bi-geo-alt text-secondary me-1"></i>{{locationText}}\n {{#countryName}} <span class="text-secondary fw-normal">· {{countryName}}</span>{{/countryName}}\n </div>\n <div class="text-secondary small mt-1">\n <code>{{model.ip_address}}</code>\n {{#ispName}} <span class="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",sortable:!0,width:"120px"},{key:"last_seen",label:"Last seen",formatter:"epoch|relative",sortable:!0,width:"120px"}]}),c=new DeviceSessionsSection({model:i}),d=new DeviceMetadataSection({model:i}),h=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:n},{key:"Hardware",label:"Hardware",icon:"bi-cpu",view:o},{type:"divider",label:"Activity"},{key:"Locations",label:"Locations",icon:"bi-geo-alt",view:r},{key:"Sessions",label:"Sessions",icon:"bi-clock-history",view:c},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:d}],u=function(e){const t=(e?.user_agent?.family||"").toLowerCase(),i=(e?.os?.family||"").toLowerCase(),s=(e?.device?.family||"").toLowerCase();return t.includes("chrome")?"bi-browser-chrome":t.includes("firefox")?"bi-browser-firefox":t.includes("safari")?"bi-browser-safari":t.includes("edge")?"bi-browser-edge":i.includes("mac")||i.includes("ios")?"bi-apple":i.includes("windows")?"bi-windows":i.includes("android")?"bi-android2":i.includes("linux")?"bi-ubuntu":s.includes("iphone")?"bi-phone":s.includes("ipad")?"bi-tablet":"bi-laptop"}(s),m=[{icon:"bi-window",text:e=>v(e.get("device_info")||{}),variant:"info",when:e=>!!e.get("device_info")?.user_agent?.family},{text:e=>{const t=e.get("session_count")??e.get("sessions");return null!=t?`${t} ${1===t?"session":"sessions"}`:null},variant:"light"},{text:e=>{const t=e.get("location_count");return null!=t?`${t} ${1===t?"location":"locations"}`:null},variant:"light"},{icon:"bi-shield-check",text:"Trusted",variant:"success",when:e=>!!e.get("is_trusted")},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",when:e=>!!e.get("is_blocked")},{icon:"bi-shield-exclamation",text:"VPN",variant:"warning",when:e=>w(e,"vpn")},{icon:"bi-shield-x",text:"Tor",variant:"danger",when:e=>w(e,"tor")},{icon:"bi-shield-exclamation",text:"Proxy",variant:"warning",when:e=>w(e,"proxy")},{icon:"bi-cloud",text:"Cloud",variant:"info",when:e=>w(e,"cloud")}],f=void 0!==i.get("is_trusted"),_=[{label:"View user",action:"view-user",icon:"bi-person"},{label:"View locations",action:"view-locations-section",icon:"bi-geo-alt"}];f&&_.push({label:i.get("is_trusted")?"Mark untrusted":"Mark trusted",action:"toggle-trusted",icon:"bi-shield-check"}),_.push({type:"divider"}),_.push({label:"Forget device",action:"forget-device",icon:"bi-trash",danger:!0}),super({className:"device-view",...e,model:i,header:{icon:u,iconToneFn:e=>w(e,"tor")||w(e,"proxy")?"danger":w(e,"vpn")||w(e,"cloud")?"warning":e.get("is_blocked")?"danger":e.get("is_trusted")?"success":"info",titleFn:e=>{const t=e.get("device_info")||{};return`${v(t)} on ${function(e){const t=e?.os||{},i=[t.major,t.minor].filter(Boolean).join(".");return t.family?`${t.family} ${i}`.trim():"Unknown OS"}(t)}`},subtitleFn:e=>function(e){const t=e.get("last_seen"),i=e.get("last_ip"),s=y(e),a=e.get("user"),n=[];n.push(t?`Last seen ${g(t)}`:"Never seen"),i&&n.push(`from ${i}`);const o=[s.city,s.country_name].filter(Boolean).join(", ");return o&&n.push(`· ${o}`),a?.display_name&&n.push(`· owner ${a.display_name}`),n.join(" ")}(e),chips:m,auxFn:e=>function(e){const t=e.get("last_seen"),i=(()=>{const e=b(t);return null!=e&&Date.now()-e<3e5})();let s="",a="";e.get("is_blocked")?(s=" dh-aux-dot-danger",a="Blocked"):i?(s=" dh-aux-dot-success",a="Online"):t?(s=" dh-aux-dot-secondary",a="Offline"):(s=" dh-aux-dot-secondary",a="Never seen");const n=t?`Last seen ${p(g(t))}`:"";return`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${s}"></span>\n <span>${p(a)}</span>\n </span>\n ${n?`<span class="dh-aux-meta">${n}</span>`:""}\n `}(e),activeField:f?"is_trusted":null,actions:[{label:"View user",icon:"bi-person",action:"view-user",title:"Open the user that owns this device"},{label:"Forget",icon:"bi-trash",action:"forget-device",title:"Delete this device record"}],contextMenu:{items:_}},sections:h,activeSection:"Overview"}),this.locationsCollection=a,this.overviewSection=n,this.hardwareSection=o,this.locationsSection=r,this.sessionsSection=c,this.metadataSection=d}async onAfterBuild(){const e=()=>{const e=this.locationsCollection.totalCount??this.locationsCollection.models?.length??0;this.setBadge("Locations",e>0?{text:String(e),variant:"muted"}:null)};this.locationsCollection.on("fetch:success",e,this),this.locationsCollection.models?.length&&e();const t=this.model.get("session_count")??this.model.get("sessions");null!=t&&t>0&&this.setBadge("Sessions",{text:String(t),variant:"muted"}),this.locationsCollection.fetch().catch(()=>{})}async onActionViewUser(){const e=this.model.get("user"),i=e?.id||e;if(!i)return void this.getApp()?.toast?.warning?.("No user linked to this device");this.emit("view-user",{userId:i});const s=t.User.VIEW_CLASS;if(!s)return;const n=new t.User({id:i});try{await n.fetch()}catch{}const o=new s({model:n});await a.Modal.detail(o)}async onActionViewLocationsSection(){await this.showSection("Locations")}async onActionToggleTrusted(){const e=!this.model.get("is_trusted");try{const t=await this.model.save({is_trusted:e});if(t&&t.status&&t.status>=400)throw new Error("Save failed");this.model.set("is_trusted",e),this.getApp()?.toast?.success(e?"Marked trusted":"Marked untrusted"),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.threatTimeline?.isMounted()&&await this.overviewSection.threatTimeline.render(),this.emit("detail:updated")}catch(t){this.getApp()?.toast?.error(`Failed to update trust: ${t.message}`)}}async onActionForgetDevice(){if(!(await a.Modal.confirm("Forget this device? The record will be deleted.","Forget Device")))return!0;try{const e=await this.model.destroy();if(e&&!1===e.success)throw new Error(e.data?.error||"Delete failed");this.getApp()?.toast?.success("Device forgotten");const t=this.element?.closest(".modal");if(t){const e=window.bootstrap?.Modal?.getInstance(t);e&&e.hide()}this.emit("device:deleted",{model:this.model})}catch(e){this.getApp()?.toast?.error(`Failed to forget: ${e.message}`)}return!0}static async show(e){const i=await t.UserDevice.getByDuid(e);return i?a.Modal.detail(new DeviceView({model:i})):(a.Modal.alert({message:`Could not find device with DUID: ${e}`,type:"warning"}),null)}}DeviceView.VIEW_CLASS=DeviceView,t.UserDevice.VIEW_CLASS=DeviceView;const f={in_app:"In-App",email:"Email",push:"Push"},_=["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 _.map(e=>({key:e,label:f[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:_.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,i){const s=i.dataset.kind,a=i.dataset.channel,n=i.checked;this.preferences[s]||(this.preferences[s]={}),this.preferences[s][a]=n;try{const e=await t.rest.POST("/api/account/notification/preferences",{user:this.model.id,preferences:{[s]:{[a]:n}}});e.success||(this.getApp()?.toast?.error(e.message||"Failed to update preference"),i.checked=!n)}catch(o){this.getApp()?.toast?.error("Failed to update preference"),i.checked=!n}return!0}}class AdminPersonalSection extends t.View{constructor(e={}){super({className:"admin-personal-section",enableTooltips:!0,template:'\n \x3c!-- Name --\x3e\n <div class="detail-section-eyebrow">Name</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display Name</div>\n <div class="detail-flat-row-value">\n {{#model.display_name}}{{model.display_name}}{{/model.display_name}}\n {{^model.display_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.display_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">First Name</div>\n <div class="detail-flat-row-value">\n {{#model.first_name}}{{model.first_name}}{{/model.first_name}}\n {{^model.first_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.first_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-first-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last Name</div>\n <div class="detail-flat-row-value">\n {{#model.last_name}}{{model.last_name}}{{/model.last_name}}\n {{^model.last_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.last_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-last-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="detail-section-eyebrow">Details</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Date of Birth</div>\n <div class="detail-flat-row-value">\n {{#hasDob|bool}}\n {{dobFormatted}}\n {{#model.is_dob_verified|bool}}<span class="badge text-bg-success">Verified</span>{{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}<span class="badge text-bg-warning">Unverified</span>{{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n {{^hasDob|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasDob|bool}}\n </div>\n <div class="detail-flat-row-action">\n {{#hasDob|bool}}\n {{#model.is_dob_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="edit-dob" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">{{timezoneDisplay}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n \x3c!-- Address --\x3e\n <div class="detail-section-eyebrow">Address</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Address</div>\n <div class="detail-flat-row-value">\n {{#hasAddress|bool}}{{addressSummary}}{{/hasAddress|bool}}\n {{^hasAddress|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasAddress|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-address" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\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,i,s]=e.split("-");return`${i}/${s}/${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 a.Modal.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 a.Modal.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 a.Modal.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({display_name:e.trim()},"Display name"),!0}async onActionEditFirstName(){const e=await a.Modal.prompt("First name:","Edit First Name",{defaultValue:this.model.get("first_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({first_name:e.trim()},"First name"),!0}async onActionEditLastName(){const e=await a.Modal.prompt("Last name:","Edit Last Name",{defaultValue:this.model.get("last_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({last_name:e.trim()},"Last name"),!0}async onActionEditDob(){const e=await a.Modal.form({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 a.Modal.form({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 a.Modal.form({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 i=await this.model.save(e);200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(i.message||`Failed to update ${t.toLowerCase()}`)}}class AdminSecuritySection extends t.View{constructor(e={}){super({className:"admin-security-section",template:'\n <div class="detail-section-eyebrow">Authentication</div>\n\n <div class="admin-security-item" data-action="reset-password">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Send Password Reset</div>\n <div class="admin-security-desc">Send a password reset email to {{model.email}}</div>\n </div>\n <span class="badge text-bg-light border">Send</span>\n </div>\n\n <div class="admin-security-item" data-action="send-magic-link">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-link-45deg"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Send Magic Login Link</div>\n <div class="admin-security-desc">Send a one-click login link to {{model.email}}</div>\n </div>\n <span class="badge text-bg-light border">Send</span>\n </div>\n\n {{#isAdminCaller|bool}}\n <div class="admin-security-item" data-action="change-password">\n <div class="admin-security-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Set Password</div>\n <div class="admin-security-desc">Set a new password directly for this user</div>\n </div>\n <span class="badge text-bg-light border">Set</span>\n </div>\n {{/isAdminCaller|bool}}\n\n <div class="detail-section-eyebrow">Multi-Factor Authentication</div>\n\n {{#isAdminCaller|bool}}\n <div class="admin-security-item" data-action="toggle-mfa">\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-shield-lock"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">MFA Requirement</div>\n <div class="admin-security-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}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-light border">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n {{/isAdminCaller|bool}}\n\n <div class="admin-security-item{{#totpEnabled|bool}} admin-security-item-clickable{{/totpEnabled|bool}}"{{#totpEnabled|bool}} data-action="disable-totp"{{/totpEnabled|bool}}>\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Authenticator (TOTP)</div>\n <div class="admin-security-desc">\n {{#totpEnabled|bool}}User has an authenticator app enrolled — click to disable{{/totpEnabled|bool}}\n {{^totpEnabled|bool}}User has not enrolled an authenticator app{{/totpEnabled|bool}}\n </div>\n </div>\n {{#totpEnabled|bool}}<span class="badge text-bg-success">Enrolled</span>{{/totpEnabled|bool}}\n {{^totpEnabled|bool}}<span class="badge text-bg-light border">Not enrolled</span>{{/totpEnabled|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-info bg-opacity-10 text-info"><i class="bi bi-phone"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">SMS Verification</div>\n <div class="admin-security-desc">\n {{#smsEligible|bool}}Verified phone available — SMS-based MFA can be used{{/smsEligible|bool}}\n {{^smsEligible|bool}}No verified phone on file — SMS-based MFA unavailable{{/smsEligible|bool}}\n </div>\n </div>\n {{#smsEligible|bool}}<span class="badge text-bg-success">Eligible</span>{{/smsEligible|bool}}\n {{^smsEligible|bool}}<span class="badge text-bg-light border">Unavailable</span>{{/smsEligible|bool}}\n </div>\n\n <div class="admin-security-item admin-security-item-clickable" data-action="manage-passkeys">\n <div class="admin-security-icon bg-success bg-opacity-10 text-success"><i class="bi bi-fingerprint"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Passkeys</div>\n <div class="admin-security-desc">View and manage registered passkeys</div>\n </div>\n {{#hasPasskey|bool}}<span class="badge text-bg-success me-2">Registered</span>{{/hasPasskey|bool}}\n <i class="bi bi-chevron-right admin-security-chevron"></i>\n </div>\n\n {{#totpEnabled|bool}}\n <div class="admin-security-item admin-security-item-clickable" data-action="view-recovery-codes">\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-file-earmark-lock"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Recovery Codes</div>\n <div class="admin-security-desc">View remaining recovery codes</div>\n </div>\n <i class="bi bi-chevron-right admin-security-chevron"></i>\n </div>\n {{/totpEnabled|bool}}\n\n {{#isAdminCaller|bool}}\n <div class="detail-section-eyebrow">Sessions</div>\n\n <div class="admin-security-item" data-action="revoke-all-sessions">\n <div class="admin-security-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-box-arrow-right"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Revoke All Sessions</div>\n <div class="admin-security-desc">Force sign-out from all devices</div>\n </div>\n </div>\n {{/isAdminCaller|bool}}\n ',...e})}get totpEnabled(){const e=this.model;return!!(e?.get?.("has_totp")||e?.get?.("totp_enabled")||e?.get?.("totp")?.is_enabled)}get smsEligible(){const e=this.model;return!(!e?.get?.("phone_number")||!e?.get?.("is_phone_verified"))}get hasPasskey(){return!!this.model?.get?.("has_passkey")}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}async onActionToggleMfa(){const e=this.getApp(),t=this.model.get("requires_mfa"),i=t?"disable":"enable";if(!(await a.Modal.confirm((t?"Disable":"Enable")+" MFA requirement for this user?",(t?"Disable":"Enable")+" MFA")))return!0;const s=await this.model.save({requires_mfa:!t});return 200===s.status?(e?.toast?.success(`MFA ${i}d`),await this.render()):e?.toast?.error(s.message||`Failed to ${i} MFA`),!0}async onActionDisableTotp(){const e=this.getApp();if(!(await a.Modal.confirm("Disable the authenticator app for this user? Their existing TOTP enrollment will be removed and they will need to re-enroll if they want to use one again.","Disable Authenticator")))return!0;const i=await t.rest.DELETE(`/api/user/${this.model.id}/totp`);return i.success?(this.model.set("has_totp",!1),this.model.set("totp_enabled",!1),e?.toast?.success("Authenticator disabled"),await this.render()):e?.toast?.error(i.message||"Failed to disable authenticator"),!0}async onActionManagePasskeys(){const e=new l.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch(n){}const i=e.models||[],s=new t.View({template:'\n {{#passkeys}}\n <div class="admin-passkey-row">\n <div class="admin-passkey-icon"><i class="bi bi-fingerprint"></i></div>\n <div class="admin-passkey-info">\n <div class="admin-passkey-name">{{.friendly_name|default:\'Unnamed Passkey\'}}</div>\n <div class="admin-passkey-meta text-secondary">Created {{.created|date}} &middot; Last used {{.last_used|relative|default:\'never\'}} &middot; {{.sign_count}} uses</div>\n </div>\n <div class="admin-passkey-actions">\n <button type="button" class="btn btn-sm 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-sm 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="admin-passkey-empty text-secondary">\n <i class="bi bi-fingerprint"></i>\n <div>No passkeys registered</div>\n </div>\n {{/passkeys|bool}}\n '});return s.passkeys=i.map(e=>e.toJSON?e.toJSON():e),s.onActionEditPasskey=async(e,t)=>{const s=t.dataset.id,n=i.find(e=>String(e.id)===String(s));return n&&await a.Modal.modelForm({title:"Edit Passkey",model:n,fields:l.PasskeyForms.edit.fields,size:"sm"}),!0},s.onActionDeletePasskey=async(e,t)=>{const s=t.dataset.id;if(await a.Modal.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(s));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await a.Modal.dialog({title:"Passkeys",body:s,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:s,codes:n}=i.data,o=new t.View({template:'\n <div class="admin-recovery-info text-secondary"><span class="admin-recovery-remaining">{{remaining}}</span> recovery codes remaining</div>\n <div class="admin-recovery-list">\n {{#codes}}<div class="admin-recovery-code">{{.}}</div>{{/codes}}\n </div>\n '});return o.remaining=s,o.codes=n||[],await a.Modal.dialog({title:"Recovery Codes",body:o,size:"sm",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}}const k={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",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Linked accounts</div>\n {{#connections}}\n <div class="admin-connected-row">\n <div class="admin-connected-icon"><i class="bi {{.icon}}"></i></div>\n <div class="admin-connected-info">\n <div class="admin-connected-provider">{{.provider}}</div>\n <div class="admin-connected-meta text-secondary">{{.email}} &middot; Connected {{.created|relative}}</div>\n </div>\n <div class="admin-connected-actions">\n <button type="button" class="btn btn-sm 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="admin-connected-empty text-secondary">\n <i class="bi bi-plug"></i>\n <div>No connected accounts</div>\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}),i=e?.data?.results||e?.data||[];this.connections=i.map(e=>({...e,icon:k[e.provider]||"bi-link-45deg"}))}catch(e){this.connections=[]}}async onActionUnlink(e,i){const s=i.dataset.id,n=this.connections.find(e=>String(e.id)===String(s)),o=n?.provider||"this account";if(!(await a.Modal.confirm(`Unlink ${o} for this user?`,"Unlink Account")))return!0;const l=await t.rest.DELETE(`/api/account/oauth_connection/${s}`);return l.success?(this.getApp()?.toast?.success(`${o} account unlinked`),await this.render()):this.getApp()?.toast?.error(l.message||"Failed to unlink account"),!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(),i=Object.keys(t).sort();if(!i.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 s=i.map(e=>{const i=t[e],s="object"==typeof i?JSON.stringify(i):String(i);return`\n <div class="amd-item">\n <div class="amd-key">${this._escapeHtml(e)}</div>\n <div class="amd-value">${this._escapeHtml(s)}</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">${s}</div>`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionAddEntry(){const e=await a.Modal.form({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 s=this._getMetadata(),n=s[i],o="object"==typeof n?JSON.stringify(n):String(n),l=await a.Modal.form({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={...s};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 a.Modal.confirm(`Remove metadata key "<strong>${this._escapeHtml(i)}</strong>"?`,"Remove Entry")))return!0;const s={...this._getMetadata()};return delete s[i],200===(await this.model.save({metadata:s})).status?(this.getApp()?.toast?.success("Metadata entry removed"),this._renderEntries()):this.getApp()?.toast?.error("Failed to remove metadata entry"),!0}}const x=t.MOJOUtils.escapeHtml,S={admin:{label:"Blocked",variant:"danger"},abuse:{label:"Banned",variant:"danger"},inactive:{label:"Auto-disabled",variant:"warning"},anonymized:{label:"Anonymized",variant:"secondary"},self:{label:"Self-deactivated",variant:"secondary"}};function C(e){return e?.get?.("metadata")?.protected?.disable||null}function A(e){return C(e)?.reason||null}const T={google:"bi-google",github:"bi-github",microsoft:"bi-microsoft",apple:"bi-apple",facebook:"bi-facebook",twitter:"bi-twitter-x",linkedin:"bi-linkedin"},M={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"},I={error:"bi-shield-x",critical:"bi-shield-x",warning:"bi-exclamation-triangle",warn:"bi-exclamation-triangle",info:"bi-pencil-square"},P={success_login:"success",success:"success",login:"success",failed_login:"danger",failure:"danger",failed:"danger",suspicious:"warning",mfa_required:"warning",mfa:"warning"};t.dataFormatter.formatters?.has?.("leveltone")||t.dataFormatter.register("levelTone",e=>M[String(e||"").toLowerCase()]||"secondary"),t.dataFormatter.formatters?.has?.("levelicon")||t.dataFormatter.register("levelIcon",e=>I[String(e||"").toLowerCase()]||"bi-circle"),t.dataFormatter.formatters?.has?.("logintone")||t.dataFormatter.register("loginTone",e=>P[String(e||"").toLowerCase()]||"secondary");class UserOverviewSection extends t.View{constructor(e={}){const{devicesCollection:t,pushDevicesCollection:i,membersCollection:s,loginsCollection:a,activityCollection:n,eventsCollection:o,objectLogsCollection:l,...r}=e;super({className:"user-overview-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Account snapshot</div>\n <div class="detail-kpi-grid">\n <div data-container="user-kpi-devices"></div>\n <div data-container="user-kpi-last-login"></div>\n <div data-container="user-kpi-sessions"></div>\n <div data-container="user-kpi-groups"></div>\n </div>\n\n <div class="detail-section-eyebrow">Identity</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display name</div>\n <div class="detail-flat-row-value">{{model.display_name|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email</div>\n <div class="detail-flat-row-value">\n {{#hasEmail|bool}}{{{model.email|clipboard}}}{{/hasEmail|bool}}\n {{^hasEmail|bool}}<span class="text-secondary">—</span>{{/hasEmail|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Phone</div>\n <div class="detail-flat-row-value">\n {{#hasPhone|bool}}<code>{{model.phone_number}}</code>{{/hasPhone|bool}}\n {{^hasPhone|bool}}<span class="text-secondary">—</span>{{/hasPhone|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Account type</div>\n <div class="detail-flat-row-value">{{accountType}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value"><code>{{model.date_joined|date|default:\'—\'}}</code></div>\n </div>\n\n <div class="detail-section-eyebrow">Recent activity</div>\n <div data-container="user-overview-activity"></div>\n ',...r}),this.devicesCollection=t,this.pushDevicesCollection=i,this.membersCollection=s,this.loginsCollection=a,this.activityCollection=n,this.eventsCollection=o,this.objectLogsCollection=l}get hasEmail(){return!!this.model?.get?.("email")}get hasPhone(){return!!this.model?.get?.("phone_number")}get accountType(){const e=this.model;return e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":"User"}async onInit(){this.kpiDevices=new n.MetricCard({containerId:"user-kpi-devices",label:"Devices",value:()=>String(this._deviceCount())}),this.kpiLastLogin=new n.MetricCard({containerId:"user-kpi-last-login",label:"Last login",value:()=>this._lastLoginLabel()}),this.kpiSessions=new n.MetricCard({containerId:"user-kpi-sessions",label:"Active sessions",value:()=>String(this._sessionCount()),tone:()=>this._sessionCount()>0?"success":"default"}),this.kpiGroups=new n.MetricCard({containerId:"user-kpi-groups",label:"Groups",value:()=>String(this._groupCount())}),[this.kpiDevices,this.kpiLastLogin,this.kpiSessions,this.kpiGroups].forEach(e=>this.addChild(e)),this.activityTimeline=new n.Timeline({containerId:"user-overview-activity",limit:5,emptyText:"No recent activity yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline)}async onAfterRender(){await super.onAfterRender(),[this.devicesCollection,this.pushDevicesCollection,this.membersCollection,this.loginsCollection,this.activityCollection,this.eventsCollection,this.objectLogsCollection].forEach(e=>{e&&!e._userOverviewWired&&(e.on("fetch:success",()=>{this.kpiDevices?.isMounted()&&this.kpiDevices.render().catch(()=>{}),this.kpiLastLogin?.isMounted()&&this.kpiLastLogin.render().catch(()=>{}),this.kpiSessions?.isMounted()&&this.kpiSessions.render().catch(()=>{}),this.kpiGroups?.isMounted()&&this.kpiGroups.render().catch(()=>{}),this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())},this),e._userOverviewWired=!0)})}_deviceCount(){return(this.devicesCollection?.meta?.count??this.devicesCollection?.models?.length??0)+(this.pushDevicesCollection?.meta?.count??this.pushDevicesCollection?.models?.length??0)}_sessionCount(){return this.devicesCollection?.meta?.count??this.devicesCollection?.models?.length??0}_lastLoginLabel(){const e=this.loginsCollection?.models?.[0]?.get?.("created")??this.model?.get?.("last_login");return e&&t.dataFormatter.apply("relative",e)||"—"}_groupCount(){return this.membersCollection?.meta?.count??this.membersCollection?.models?.length??0}_buildActivityItems(){const e=[],i=this.loginsCollection?.models?.slice(0,2)||[];for(const o of i){const i=o.get("ip_address"),s=[o.get("city"),o.get("country_code")].filter(Boolean).join(", "),a=[i?`<code>${x(String(i))}</code>`:"",s?`<span class="text-secondary">${x(s)}</span>`:""].filter(Boolean).join(" · ");e.push({_ts:L(o.get("created")),tone:"info",headline:"Logged in",detail:a,when:t.dataFormatter.apply("relative",o.get("created"))})}const s=this.eventsCollection?.models?.slice(0,2)||[];for(const o of s){const i=o.get("category");e.push({_ts:L(o.get("created")),tone:"danger",headline:o.get("title")||i||"Incident event",detail:i?`<span class="text-secondary">${x(String(i))}</span>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}const a=this.objectLogsCollection?.models?.slice(0,2)||[];for(const o of a){const i=o.get("log");e.push({_ts:L(o.get("created")),tone:M[(o.get("level")||"").toLowerCase()]||null,headline:o.get("kind")||"Change",detail:i?`<span class="text-secondary">${x(String(i).slice(0,80))}</span>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}const n=this.activityCollection?.models?.slice(0,1)||[];for(const o of n){const i=o.get("path");e.push({_ts:L(o.get("created")),tone:M[(o.get("level")||"").toLowerCase()]||null,headline:o.get("kind")||"Activity",detail:i?`<code class="small">${x(String(i))}</code>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}return e.filter(e=>null!=e._ts).sort((e,t)=>t._ts-e._ts).slice(0,5)}}class UserProfileSection extends t.View{constructor(e={}){super({className:"user-profile-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Personal</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display name</div>\n <div class="detail-flat-row-value">{{model.display_name|default:\'—\'}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Identity</div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-secondary bg-opacity-10 text-secondary"><i class="bi bi-at"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Username</div>\n <div class="admin-security-desc"><code>{{model.username|default:\'—\'}}</code></div>\n </div>\n {{#isAdminCaller|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-username" title="Edit username"><i class="bi bi-pencil"></i></button>\n {{/isAdminCaller|bool}}\n {{^isAdminCaller|bool}}\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="send-magic-link"><i class="bi bi-link-45deg me-1"></i>Send magic link</button>\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">\n Email\n {{#model.is_email_verified|bool}}<span class="badge text-bg-success ms-2"><i class="bi bi-shield-check me-1"></i>verified</span>{{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}{{#hasEmail|bool}}<span class="badge text-bg-warning ms-2">unverified</span>{{/hasEmail|bool}}{{/model.is_email_verified|bool}}\n </div>\n <div class="admin-security-desc">\n {{#hasEmail|bool}}{{{model.email|clipboard}}}{{/hasEmail|bool}}\n {{^hasEmail|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEmail|bool}}\n </div>\n </div>\n {{#isAdminCaller|bool}}\n {{#hasEmail|bool}}\n {{#model.is_email_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="force-verify-email" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_email_verified|bool}}\n {{/hasEmail|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="change-email" title="Edit email"><i class="bi bi-pencil"></i></button>\n {{/isAdminCaller|bool}}\n {{^isAdminCaller|bool}}\n {{#hasEmail|bool}}<button type="button" class="btn btn-sm btn-outline-secondary" data-action="send-magic-link"><i class="bi bi-link-45deg me-1"></i>Send magic link</button>{{/hasEmail|bool}}\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-info bg-opacity-10 text-info"><i class="bi bi-telephone"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">\n Phone\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}<span class="badge text-bg-success ms-2"><i class="bi bi-shield-check me-1"></i>verified</span>{{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}<span class="badge text-bg-warning ms-2">unverified</span>{{/model.is_phone_verified|bool}}\n {{/hasPhone|bool}}\n </div>\n <div class="admin-security-desc">\n {{#hasPhone|bool}}<code>{{model.phone_number}}</code>{{/hasPhone|bool}}\n {{^hasPhone|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasPhone|bool}}\n </div>\n </div>\n {{#isAdminCaller|bool}}\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="remove-phone" title="Clear phone"><i class="bi bi-x-lg"></i></button>\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="change-phone" title="Edit phone"><i class="bi bi-pencil"></i></button>\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="set-phone" title="Set phone"><i class="bi bi-plus-lg"></i></button>\n {{/hasPhone|bool}}\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Password</div>\n <div class="admin-security-desc">{{#hasEmail|bool}}Send a password reset link to {{model.email}}{{/hasEmail|bool}}{{^hasEmail|bool}}<span class="text-secondary fst-italic">No email on file</span>{{/hasEmail|bool}}</div>\n </div>\n {{#hasEmail|bool}}<button type="button" class="btn btn-sm btn-outline-secondary" data-action="reset-password"><i class="bi bi-envelope me-1"></i>Send Reset Link</button>{{/hasEmail|bool}}\n </div>\n\n <div class="detail-section-eyebrow">\n Account\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-account" title="Edit account"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Account type</div>\n <div class="detail-flat-row-value">{{accountType}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">MFA</div>\n <div class="detail-flat-row-value">\n {{#model.requires_mfa|bool}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-secondary">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value"><code>{{model.date_joined|date|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last login</div>\n <div class="detail-flat-row-value">{{model.last_login|relative|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last seen</div>\n <div class="detail-flat-row-value">{{model.last_activity|relative|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#hasTimezone|bool}}{{timezone}}{{/hasTimezone|bool}}\n {{^hasTimezone|bool}}<span class="text-secondary">—</span>{{/hasTimezone|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Linked accounts\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="manage-linked" title="Manage linked accounts"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">SSO providers</div>\n <div class="detail-flat-row-value">{{{linkedProvidersHtml}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">2-factor</div>\n <div class="detail-flat-row-value">\n {{#model.requires_mfa|bool}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-secondary">Not required</span>{{/model.requires_mfa|bool}}\n <a href="#" class="small ms-2" data-action="manage-passkeys">Manage passkeys</a>\n </div>\n </div>\n ',...e}),this.connections=[]}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id}),i=e?.data?.results||e?.data||[];this.connections=Array.isArray(i)?i:[]}catch(e){this.connections=[]}}get accountType(){const e=this.model;return e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":"User"}get hasEmail(){return!!this.model?.get?.("email")}get hasPhone(){return!!this.model?.get?.("phone_number")}get hasTimezone(){return!!this.model?.get?.("metadata")?.timezone}get timezone(){return this.model?.get?.("metadata")?.timezone||""}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}get linkedProvidersHtml(){return this.connections.length?this.connections.map(e=>{const t=T[e.provider]||"bi-link-45deg",i=x(String(e.provider||"")),s=e.email?` · ${x(String(e.email))}`:"";return`<span class="badge text-bg-light border me-1"><i class="bi ${x(t)} me-1"></i>${i}${s}</span>`}).join(""):'<span class="text-secondary fst-italic">No linked accounts</span>'}}class UserPermissionsSection extends t.View{constructor(e={}){super({className:"user-permissions-section",template:'\n <div class="detail-section-eyebrow">Permissions</div>\n <p class="text-secondary small mb-3">Toggles autosave as soon as you flip them.</p>\n <div data-container="user-permissions-form"></div>\n ',...e})}async onInit(){const e=t.User._permSwitch,i=[{label:"Categories",fields:(t.User.CATEGORY_PERMISSIONS||[]).map(e)},...(t.User.GRANULAR_PERMISSION_TABS||[]).map(t=>({label:t.label,fields:(t.permissions||[]).map(e)}))];this.formView=new o.FormView({containerId:"user-permissions-form",fields:[{type:"tabset",tabs:i}],model:this.model,autosaveModelField:!0}),this.addChild(this.formView)}}class UserDevicesSection extends t.View{constructor(e={}){const{devicesCollection:t,pushDevicesCollection:i,...s}=e;super({className:"user-devices-section",template:'\n <div class="detail-section-eyebrow">Devices &amp; sessions</div>\n <div data-container="user-devices-tabs"></div>\n ',...s}),this.devicesCollection=t,this.pushDevicesCollection=i}async onInit(){this.browserList=new r.ListView({collection:this.devicesCollection,paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},searchable:!0,searchPlaceholder:"Search browser devices…",hideActivePillNames:["user"],onItemClick:e=>a.Modal.detail(new DeviceView({model:e})),emptyMessage:"No browser devices on file.",itemTemplate:'\n <div class="user-device-row" role="button">\n <div class="user-device-icon"><i class="bi bi-laptop"></i></div>\n <div class="user-device-info">\n <div class="user-device-label">{{model.device_info.user_agent.family|default:\'Unknown browser\'}} {{model.device_info.user_agent.major}} · {{model.device_info.os.family|default:\'Unknown OS\'}} {{model.device_info.os.major}}</div>\n <div class="user-device-meta">\n {{model.last_seen|relative|default:\'never\'}}\n {{#model.duid}} · <code>{{model.duid|truncate_middle(20)}}</code>{{/model.duid}}\n </div>\n </div>\n <span class="badge text-bg-info">Browser</span>\n </div>\n '}),this.browserList.onTabActivated=async()=>{await(this.devicesCollection?.fetch?.().catch(()=>{}))},this.pushList=new r.ListView({collection:this.pushDevicesCollection,paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},searchable:!0,searchPlaceholder:"Search push devices…",hideActivePillNames:["user"],onItemClick:e=>a.Modal.detail(new c.PushDeviceView({model:e})),emptyMessage:"No push devices on file.",itemTemplate:'\n <div class="user-device-row" role="button">\n <div class="user-device-icon"><i class="bi bi-bell"></i></div>\n <div class="user-device-info">\n <div class="user-device-label">{{model.device_info.device.family|default:\'Push device\'}}{{#model.device_info.os.family}} · {{model.device_info.os.family}} {{model.device_info.os.major}}{{/model.device_info.os.family}}</div>\n <div class="user-device-meta">\n {{model.last_seen|relative|default:\'never\'}}\n {{#model.duid}} · <code>{{model.duid|truncate_middle(20)}}</code>{{/model.duid}}\n </div>\n </div>\n <span class="badge text-bg-primary"><i class="bi bi-bell me-1"></i>Push</span>\n </div>\n '}),this.pushList.onTabActivated=async()=>{await(this.pushDevicesCollection?.fetch?.().catch(()=>{}))},this.tabView=new o.TabView({containerId:"user-devices-tabs",tabs:{Browser:this.browserList,Push:this.pushList},activeTab:"Browser"}),this.addChild(this.tabView)}}class UserAuditSection extends t.View{constructor(e={}){const{eventsCollection:t,activityCollection:i,objectLogsCollection:s,...a}=e;super({className:"user-audit-section",template:'\n {{#hasDisableHistory|bool}}\n <div class="detail-section-eyebrow">Disable history</div>\n <div class="user-disable-history accordion mb-3" id="user-disable-history">\n {{#disableHistory}}\n <div class="accordion-item">\n <h2 class="accordion-header">\n <button class="accordion-button collapsed" type="button"\n data-bs-toggle="collapse" data-bs-target="#disable-history-{{.idx}}">\n <span class="badge text-bg-{{.tone}} me-2">{{.label}}</span>\n <span class="text-secondary me-2">{{.atRel}}</span>\n {{#.byUsername|bool}}<span class="me-2">by <code>{{.byUsername}}</code></span>{{/.byUsername|bool}}\n {{#.reactivated|bool}}<span class="ms-auto badge text-bg-light border">Reactivated</span>{{/.reactivated|bool}}\n </button>\n </h2>\n <div id="disable-history-{{.idx}}" class="accordion-collapse collapse" data-bs-parent="#user-disable-history">\n <div class="accordion-body small">\n <div><strong>Disabled:</strong> {{.atFmt}}</div>\n {{#.note|bool}}<div class="mt-1"><strong>Note:</strong> {{.note}}</div>{{/.note|bool}}\n {{#.reactivated|bool}}\n <div class="mt-2 pt-2 border-top">\n <div><strong>Reactivated:</strong> {{.reactivatedAtFmt}}{{#.reactivatedBy|bool}} by <code>{{.reactivatedBy}}</code>{{/.reactivatedBy|bool}}</div>\n {{#.reactivatedNote|bool}}<div class="mt-1"><strong>Note:</strong> {{.reactivatedNote}}</div>{{/.reactivatedNote|bool}}\n </div>\n {{/.reactivated|bool}}\n </div>\n </div>\n </div>\n {{/disableHistory}}\n </div>\n {{/hasDisableHistory|bool}}\n <div class="detail-section-eyebrow">Audit</div>\n <div data-container="user-audit-tabs"></div>\n ',...a}),this.eventsCollection=t,this.activityCollection=i,this.objectLogsCollection=s}get hasDisableHistory(){return Array.isArray(C(this.model)?.history)&&C(this.model).history.length>0}get disableHistory(){return(C(this.model)?.history||[]).map((e,i)=>{const s=S[e?.reason]||{label:"Inactive",variant:"secondary"},a=e?.at,n=e?.reactivated_at;return{idx:i,label:s.label,tone:s.variant,atFmt:a?t.dataFormatter.apply("datetime",a)||a:"",atRel:a&&t.dataFormatter.apply("relative",a)||"",byUsername:e?.by_username||"",note:e?.note||"",reactivated:!!n,reactivatedAtFmt:n?t.dataFormatter.apply("datetime",n)||n:"",reactivatedBy:e?.reactivated_by_username||"",reactivatedNote:e?.reactivated_note||""}})}async onInit(){this.activityTable=new r.ListView({collection:this.activityCollection,searchable:!0,searchPlaceholder:"Search activity…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["uid"],emptyMessage:"No activity recorded yet.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-{{model.level|levelTone}}">\n <div class="user-audit-icon"><i class="bi {{model.level|levelIcon}}"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.kind}}{{model.kind}}{{/model.kind}}{{^model.kind}}{{model.level|default:\'event\'}}{{/model.kind}}</div>\n <div class="user-audit-detail">{{model.log|default:\'(no message)\'}}</div>\n {{#model.path}}<div class="user-audit-path font-monospace">{{model.path}}</div>{{/model.path}}\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.activityTable.onTabActivated=async()=>{await(this.activityCollection?.fetch?.().catch(()=>{}))},this.incidentsTable=new r.ListView({collection:this.eventsCollection,searchable:!0,searchPlaceholder:"Search events…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["model_id","model_name"],emptyMessage:"No events for this user.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-info">\n <div class="user-audit-icon"><i class="bi bi-shield-exclamation"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.title}}{{model.title}}{{/model.title}}{{^model.title}}{{model.category|default:\'event\'}}{{/model.title}}</div>\n {{#model.description}}<div class="user-audit-detail">{{model.description}}</div>{{/model.description}}\n {{#model.category}}<div class="user-audit-meta"><span class="badge text-bg-secondary">{{model.category}}</span></div>{{/model.category}}\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.incidentsTable.onTabActivated=async()=>{await(this.eventsCollection?.fetch?.().catch(()=>{}))},this.objectTable=new r.ListView({collection:this.objectLogsCollection,searchable:!0,searchPlaceholder:"Search audit log…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},permissions:"view_logs",hideActivePillNames:["model_id","model_name"],emptyMessage:"No record changes logged.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-{{model.level|levelTone}}">\n <div class="user-audit-icon"><i class="bi {{model.level|levelIcon}}"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.kind}}{{model.kind}}{{/model.kind}}{{^model.kind}}{{model.level|default:\'event\'}}{{/model.kind}}</div>\n <div class="user-audit-detail">{{model.log|default:\'(no message)\'}}</div>\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.objectTable.onTabActivated=async()=>{await(this.objectLogsCollection?.fetch?.().catch(()=>{}))},this.tabView=new o.TabView({containerId:"user-audit-tabs",tabs:{Activity:this.activityTable,Events:this.incidentsTable,"Audit Log":this.objectTable},activeTab:"Activity"}),this.addChild(this.tabView)}}class UserApiKeysSection extends t.View{constructor(e={}){super({className:"user-api-keys-section",template:'\n <div class="detail-section-eyebrow">API Keys</div>\n <div data-container="user-api-keys-token"></div>\n <div data-container="user-api-keys-table"></div>\n ',...e}),this.apiKeys=[],this.generatedToken=null}async onInit(){this.tokenView=new t.View({containerId:"user-api-keys-token",template:'\n {{#hasToken|bool}}\n <div class="alert alert-success">\n <div class="fw-semibold mb-2">Generated API Key</div>\n <div class="d-flex gap-2 align-items-center">\n <code class="flex-grow-1">{{token}}</code>\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="copy-token"><i class="bi bi-clipboard"></i></button>\n </div>\n <div class="small mt-2 text-danger fw-semibold"><i class="bi bi-exclamation-circle me-1"></i>This token will not be shown again. Copy it now.</div>\n </div>\n {{/hasToken|bool}}\n '}),Object.defineProperty(this.tokenView,"token",{get:()=>this.generatedToken||""}),Object.defineProperty(this.tokenView,"hasToken",{get:()=>!!this.generatedToken}),this.tokenView.onActionCopyToken=async()=>this.onActionCopyToken(),this.addChild(this.tokenView),this.tableView=new l.TableView({containerId:"user-api-keys-table",collection:this.apiKeys,showAdd:!1,showExport:!1,showFullscreen:!1,searchable:!1,paginated:!1,sortable:!0,emptyMessage:"No API keys for this user.",columns:[{key:"name",label:"Key",sortable:!0,template:'\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-key text-secondary"></i>\n <div class="min-w-0">\n <div class="fw-semibold small">{{model.name|default:\'API Key\'}}</div>\n <div class="text-secondary small"><code>{{tokenPreview}}</code></div>\n </div>\n </div>\n '},{key:"is_active",label:"Status",width:"100px",template:'\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n '},{key:"created",label:"Created",formatter:"date",sortable:!0,width:"120px"},{key:"expires",label:"Expires",formatter:"default('Never')",width:"120px"},{key:"last_used",label:"Last used",formatter:"relative",width:"140px"},{key:"allowed_ips",label:"Allowed IPs",template:'{{#hasIps|bool}}{{ipsLabel}}{{/hasIps|bool}}{{^hasIps|bool}}<span class="text-secondary">Any</span>{{/hasIps|bool}}'}],actions:["delete"],onItemDelete:async e=>this._revokeKey(e),toolbarButtons:[{label:"Generate Key",icon:"bi bi-plus-lg",variant:"primary",handler:()=>this.onActionGenerateKey()}]}),this.addChild(this.tableView)}async onAfterRender(){await super.onAfterRender(),this._loadedOnce||(this._loadedOnce=!0,this._loadKeys().catch(()=>{}))}async _loadKeys(){try{const e=await t.rest.GET("/api/account/api_keys",{user:this.model.id},{},{dataOnly:!0}),i=e.success&&Array.isArray(e.data)?e.data:[];this.apiKeys=i.map(e=>this._decorate(e)),this.emit("count:changed",this.apiKeys.length),this.tableView&&(this.tableView.collection=this.apiKeys,this.tableView.isMounted()&&this.tableView.render().catch(()=>{}))}catch(e){this.apiKeys=[],this.emit("count:changed",0)}}_decorate(e){const t=Array.isArray(e.allowed_ips)?e.allowed_ips:[];return{...e,tokenPreview:e.token_prefix?`${e.token_prefix}…`:"••••••••",hasIps:t.length>0,ipsLabel:t.length?t.join(", "):""}}async _revokeKey(e){const i=e?.get?.("id")??e?.id;if(!i)return;if(!(await a.Modal.confirm("Revoke this API key? Any applications using it will lose access immediately.","Revoke API Key")))return;const s=await t.rest.DELETE(`/api/account/api_keys/${i}`,{},{},{dataOnly:!0});s.success?(this.getApp()?.toast?.success("API key revoked"),this.generatedToken=null,await this._refreshTokenView(),await this._loadKeys()):this.getApp()?.toast?.error(s.message||"Failed to revoke API key")}async onActionGenerateKey(){const e=await a.Modal.form({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",required:!0,placeholder:"e.g., CI/CD Pipeline, Mobile App",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)},s=(e.allowed_ips||"").trim();s&&(i.allowed_ips=s.split(",").map(e=>e.trim()).filter(Boolean));const n=await t.rest.POST("/api/auth/manage/generate_api_key",i,{},{dataOnly:!0});return n.success&&n.data?.token?(this.generatedToken=n.data.token,this.getApp()?.toast?.success("API key generated"),await this._refreshTokenView(),await this._loadKeys()):this.getApp()?.toast?.error(n.message||"Failed to generate API key"),!0}async onActionCopyToken(){if(!this.generatedToken)return!0;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}async _refreshTokenView(){this.tokenView?.isMounted()&&await this.tokenView.render()}}class UserView extends n.DetailView{constructor(e={}){const i=e.model||new t.User(e.data||{}),s=i.get("id"),a=new t.UserDeviceList({params:{user:s,size:25}}),d=new c.PushDeviceList({params:{user:s,size:25}}),h=new l.MemberList({params:{user:s,size:10}}),u=new c.LoginEventList({params:{user:s,size:10}}),m=new c.IncidentEventList({params:{size:25,model_name:"account.User",model_id:s,sort:"-created"}}),p=new l.LogList({params:{size:25,uid:s,sort:"-created"}}),b=new l.LogList({params:{size:25,model_name:"account.User",model_id:s,sort:"-created"}}),g=new UserOverviewSection({model:i,devicesCollection:a,pushDevicesCollection:d,membersCollection:h,loginsCollection:u,activityCollection:p,eventsCollection:m,objectLogsCollection:b}),v=new UserProfileSection({model:i}),y=new UserPermissionsSection({model:i}),w=new UserApiKeysSection({model:i}),f=new r.ListView({collection:h,title:"Groups",searchable:!0,searchPlaceholder:"Search groups…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",hideActivePillNames:["user"],viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},emptyMessage:"This user has no group memberships.",itemTemplate:'\n <div class="user-feed-row" role="button">\n <div class="user-feed-meta">\n <strong>{{model.group.name|default:\'—\'}}</strong>\n {{#model.group.kind}}<span class="badge text-bg-secondary">{{model.group.kind}}</span>{{/model.group.kind}}\n <span class="ms-auto text-secondary small">Joined {{model.created|date|default:\'—\'}}</span>\n </div>\n {{#model.permissions|keys}}\n <div class="user-feed-body small text-secondary">\n {{#model.permissions|keys}}<span class="badge text-bg-light border me-1">{{.}}</span>{{/model.permissions|keys}}\n </div>\n {{/model.permissions|keys}}\n </div>\n '}),_=new UserDevicesSection({model:i,devicesCollection:a,pushDevicesCollection:d}),k=new LoginLocationMapView({userId:s,height:300,mapStyle:"dark",viewMode:"list",listZoom:2.3}),T=new r.ListView({collection:u,searchable:!0,searchPlaceholder:"Search logins…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["user"],emptyMessage:"No login events on file.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-login-row">\n <span class="user-login-dot user-login-dot-{{model.event_type|loginTone}}"></span>\n <div class="user-login-body">\n <div class="user-login-title">{{#model.city}}{{model.city}}{{/model.city}}{{^model.city}}—{{/model.city}}{{#model.region}}, {{model.region}}{{/model.region}}{{#model.country_code}} · {{model.country_code}}{{/model.country_code}}</div>\n <div class="user-login-meta small text-secondary">\n <code>{{model.ip_address}}</code>{{#model.source}} · {{model.source}}{{/model.source}}\n </div>\n </div>\n <div class="user-login-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '});T.onTabActivated=async()=>{await(T.collection?.fetch())};const M=new o.TabView({tabs:{Map:k,Logins:T},activeTab:"Map"}),I=new UserAuditSection({model:i,eventsCollection:m,activityCollection:p,objectLogsCollection:b}),P=new AdminNotificationsSection({model:i}),L=new AdminPersonalSection({model:i}),E=new AdminSecuritySection({model:i}),D=new AdminConnectedSection({model:i}),V=new AdminMetadataSection({model:i}),$=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:g},{key:"Profile",label:"Profile",icon:"bi-person",view:v},{key:"Personal",label:"Personal",icon:"bi-card-text",view:L},{key:"Security",label:"Security",icon:"bi-shield-lock",view:E},{key:"OAuth",label:"OAuth",icon:"bi-link-45deg",view:D},{type:"divider",label:"Access"},{key:"Groups",label:"Groups",icon:"bi-people",view:f},{key:"Permissions",label:"Permissions",icon:"bi-shield-check",view:y,permissions:["users","manage_users"]},{key:"ApiKeys",label:"API Keys",icon:"bi-key",view:w},{type:"divider",label:"Activity"},{key:"Devices",label:"Devices",icon:"bi-laptop",view:_},{key:"Logins",label:"Logins",icon:"bi-geo-alt",view:M},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:I,permissions:"view_logs"},{type:"divider",label:"Settings"},{key:"Notifications",label:"Notifications",icon:"bi-bell",view:P},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:V}],R=["users","manage_users"],N=[{label:"Edit User",action:"edit-user",icon:"bi-pencil",permissions:R},{label:"Change Avatar",action:"change-avatar",icon:"bi-image",permissions:R},{label:"Clear Avatar",action:"clear-avatar",icon:"bi-person-x",permissions:R},{type:"divider"},{label:"Change Password",action:"change-password",icon:"bi-key",permissions:R},{label:"Send Password Reset",action:"reset-password",icon:"bi-envelope"},{label:"Send Magic Login Link",action:"send-magic-link",icon:"bi-link-45deg"},{type:"divider"},{label:"Clear Rate Limit",action:"clear-rate-limit",icon:"bi-shield-slash",permissions:R},{label:"Revoke All Sessions",action:"revoke-all-sessions",icon:"bi-box-arrow-right",permissions:R}];super({className:"user-view",...e,model:i,header:{icon:"bi-person-circle",iconToneFn:e=>e.get("is_active")?e.get("is_superuser")?"danger":e.get("is_staff")?"info":"primary":null,iconHtml:e=>{const i=e.get("avatar");return i&&i.url?`<button type="button" class="dh-icon-action" data-action="change-avatar" data-bs-toggle="tooltip" title="Change avatar">${t.dataFormatter.apply("avatar",i)}</button>`:null},titleField:"display_name",titleFn:e=>e.get("display_name")||e.get("username")||e.get("email")||(null!=e.get("id")?`User #${e.get("id")}`:"Loading user…"),subtitlePath:"_subtitle",subtitlePlaceholder:"No contact info on file",chips:[{text:e=>e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":null,variant:"info",when:e=>e.get("is_staff")||e.get("is_superuser")},{icon:"bi-shield-check",text:"Email verified",variant:"light",when:e=>!!e.get("is_email_verified")},{icon:"bi-shield-check",text:"Phone verified",variant:"light",when:e=>!!e.get("is_phone_verified")&&!!e.get("phone_number")},{text:"2FA enabled",variant:"light",when:e=>!!e.get("requires_mfa")},{icon:"bi-buildings",text:e=>e.get("org")?.name||null,variant:"light",tooltip:"Open organization",action:"view-org",when:e=>!!e.get("org")?.id}],actions:[],auxFn:e=>function(e,i,s){const a=function(e){const t=e?.get?.("last_activity");if(null==t)return!1;const i="number"==typeof t&&t<1e11?1e3*t:new Date(t).getTime();return!!Number.isFinite(i)&&Date.now()-i<3e5}(e),n=e.get("last_activity")||e.get("last_login"),o=n&&t.dataFormatter.apply("relative",n)||"",l=a?"Online":o?"Offline":"No activity",r=o?a?`active ${o}`:`last active ${o}`:"",c=a?" is-online":"",d=!!e.get("is_active"),h=function(e){return"anonymized"===A(e)}(e),u=d?null:function(e){return e?.get?.("is_active")?{label:"Active",variant:"success"}:S[A(e)]||{label:"Inactive",variant:"secondary"}}(e),m=u?`<span class="badge text-bg-${u.variant}">${x(u.label)}</span>`:"",p=Number(s?.retry_after_seconds),b=Number.isFinite(p)&&p>0?Math.floor(p):0,g=b>0?`<span class="badge text-bg-danger" title="Login attempts throttled. Use Clear Rate Limit in the kebab menu to reset."><i class="bi bi-clock-history me-1"></i>Login locked ${x(String(b))}s</span>`:"",v=h||!i?"":`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${d?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${d?"Active":"Inactive"}</span>\n </label>\n `,y=function(e){if(!e?.get?.("is_active"))return null;const t=C(e)?.warning;return t?.sent_at?{sent_at:t.sent_at,days:t.days_until_disable_at_send}:null}(e),w=y?`\n <div class="dh-aux-warning">\n <i class="bi bi-exclamation-triangle"></i>\n <span>Inactivity warning sent — ${x(String(y.days||"?"))} days until auto-disable</span>\n <a href="#" data-action="reset-inactivity">Reset</a>\n </div>\n `:"";return`\n <div class="dh-aux-top">\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${c}"></span>\n <span>${x(l)}</span>\n </span>\n ${m}\n ${g}\n ${v}\n </div>\n ${r?`<span class="dh-aux-meta">${x(r)}</span>`:""}\n ${w}\n `}(e,this.isAdminCaller,this.throttle),contextMenu:{items:N}},sections:$,activeSection:"Overview"}),this.devicesCollection=a,this.pushDevicesCollection=d,this.membersCollection=h,this.loginsCollection=u,this.eventsCollection=m,this.activityCollection=p,this.objectLogsCollection=b,this.overviewSection=g,this.profileSection=v,this.personalSection=L,this.securitySection=E,this.connectedSection=D,this.permissionsSection=y,this.apiKeysSection=w,this.groupsSection=f,this.devicesSection=_,this.locationsSection=M,this.auditSection=I,this.notificationsSection=P,this.metadataSection=V,this.throttle=null,this._refreshComputedFields()}async onAfterBuild(){this.apiKeysSection.on("count:changed",e=>{this.setBadge("ApiKeys",e>0?{text:String(e),variant:"muted"}:null)});const e=e=>e?.meta?.count??e?.models?.length??0,t=()=>{const t=e(this.devicesCollection)+e(this.pushDevicesCollection);this.setBadge("Devices",t>0?{text:String(t),variant:"muted"}:null)},i=()=>{const t=e(this.eventsCollection)+e(this.activityCollection)+e(this.objectLogsCollection);this.setBadge("Audit",t>0?{text:String(t),variant:"muted"}:null)};this.membersCollection.on("fetch:success",()=>{const t=e(this.membersCollection);this.setBadge("Groups",t>0?{text:String(t),variant:"muted"}:null)},this),this.devicesCollection.on("fetch:success",t,this),this.pushDevicesCollection.on("fetch:success",t,this),this.eventsCollection.on("fetch:success",i,this),this.activityCollection.on("fetch:success",i,this),this.objectLogsCollection.on("fetch:success",i,this),this.loginsCollection.on("fetch:success",()=>{this._refreshComputedFields(),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})},this),this.devicesCollection.fetch().catch(()=>{}),this.pushDevicesCollection.fetch().catch(()=>{}),this.membersCollection.fetch().catch(()=>{}),this.loginsCollection.fetch().catch(()=>{}),this.eventsCollection.fetch().catch(()=>{}),this.activityCollection.fetch().catch(()=>{}),this.objectLogsCollection.fetch().catch(()=>{}),this.isAdminCaller&&this._refreshThrottle().catch(()=>{})}async _refreshThrottle(){try{const e=await t.rest.GET("/api/auth/manage/throttle",{user_id:this.model.id,key:"login"});this.throttle=e?.success&&e.data?e.data.data||e.data:null}catch{this.throttle=null}this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}async _setVerification(e,t,i){const s=t?"Mark as verified":"Mark as unverified";if(await a.Modal.confirm(`${s} <strong>${x(i.toLowerCase())}</strong> for this user?`,`${s} ${i}`))try{const s=await this.model.save({[e]:t});if(s?.status>=400)throw new Error("Save failed");this.model.set(e,t),this.getApp()?.toast?.success(`${i} ${t?"marked verified":"marked unverified"}`),this.profileSection?.isMounted()&&this.profileSection.render().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}catch(n){this.getApp()?.toast?.error(`Failed to update: ${n.message}`)}}_refreshComputedFields(){const e=this.model,t=[];e.get("email")&&t.push(e.get("email")),e.get("phone_number")&&t.push(e.get("phone_number")),e.attributes._subtitle=t.join(" · ")}async onActionSendMagicLink(){const e=this.model.get("email");if(!e)return this.getApp()?.toast?.error("User has no email on file"),!0;if(!(await a.Modal.confirm(`Send a magic login link to <strong>${x(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 onActionResetPassword(){const e=this.model.get("email");if(!e)return this.getApp()?.toast?.error("User has no email on file"),!0;if(!(await a.Modal.confirm(`Send a password reset email to <strong>${x(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 onActionEditDisplayName(){const e=await a.Modal.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({display_name:e.trim()},"Display name"),!0)}async onActionEditUsername(){const e=await a.Modal.prompt("Username:","Edit Username",{defaultValue:this.model.get("username")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({username:e.trim()},"Username"),!0)}async onActionChangeEmail(){const e=await a.Modal.prompt("Email address:","Change Email",{defaultValue:this.model.get("email")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({email:e.trim()},"Email"),!0)}async onActionChangePhone(){const e=await a.Modal.prompt("Phone number:","Change Phone",{defaultValue:this.model.get("phone_number")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({phone_number:e.trim()},"Phone number"),!0)}async onActionSetPhone(){const e=await a.Modal.prompt("Phone number:","Set Phone",{placeholder:"+1 555 123 4567"});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({phone_number:e.trim()},"Phone number"),!0)}async onActionRemovePhone(){return!(await a.Modal.confirm("Clear this user's phone number?","Clear Phone"))||(await this._savePersonalField({phone_number:null},"Phone number"),!0)}async _savePersonalField(e,t){const i=await this.model.save(e);200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this._fullRefresh()):this.getApp()?.toast?.error(i.message||`Failed to update ${t.toLowerCase()}`)}async onActionEditAccount(){return await a.Modal.modelForm({title:"Edit account",model:this.model,size:"md",formConfig:{fields:[{name:"is_active",type:"switch",label:"Active",columns:6},{name:"is_staff",type:"switch",label:"Staff",columns:6},{name:"requires_mfa",type:"switch",label:"Requires MFA",columns:6},{name:"metadata.timezone",type:"text",label:"Timezone",columns:12,tooltip:"IANA timezone, e.g. America/Los_Angeles"}]}})&&await this._fullRefresh(),!0}async onActionManageLinked(){let e;try{e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id})}catch{e=null}const i=e?.data?.results||e?.data||[],s=Array.isArray(i)?i:[],n=new t.View({template:()=>s.length?s.map(e=>{const i=T[e.provider]||"bi-link-45deg",s=e.created&&t.dataFormatter.apply("relative",e.created)||"";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi ${x(i)} fs-5"></i></div>\n <div class="detail-flat-row-value">\n <div class="fw-semibold small text-capitalize">${x(e.provider||"")}</div>\n <div class="text-secondary small">${x(e.email||"")}${s?` · Connected ${x(s)}`:""}</div>\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="btn btn-sm btn-outline-danger" data-action="unlink" data-id="${x(e.id)}"><i class="bi bi-x-lg me-1"></i>Unlink</button>\n </div>\n </div>\n `}).join(""):'<div class="text-center text-secondary py-3"><i class="bi bi-plug fs-3 d-block mb-2"></i>No connected accounts</div>'});return n.onActionUnlink=async(e,i)=>{const n=i.dataset.id,o=s.find(e=>String(e.id)===String(n)),l=o?.provider||"this account";if(!(await a.Modal.confirm(`Unlink ${l} for this user?`,"Unlink Account")))return!0;const r=await t.rest.DELETE(`/api/account/oauth_connection/${n}`);return r.success?(this.getApp()?.toast?.success(`${l} account unlinked`),this.profileSection?.isMounted()&&await this.profileSection.render()):this.getApp()?.toast?.error(r.message||"Failed to unlink account"),!0},await a.Modal.dialog({title:"Linked accounts",body:n,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionManagePasskeys(){const e=new l.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch{}const i=e.models||[],s=new t.View({template:()=>i.length?i.map(e=>{const i=e.toJSON?e.toJSON():e,s=i.created&&t.dataFormatter.apply("date",i.created)||"—",a=i.last_used&&t.dataFormatter.apply("relative",i.last_used)||"never";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-fingerprint fs-5 text-primary"></i></div>\n <div class="detail-flat-row-value">\n <div class="fw-semibold small">${x(i.friendly_name||"Unnamed Passkey")}</div>\n <div class="text-secondary small">Created ${x(s)} · Last used ${x(a)} · ${x(String(i.sign_count||0))} uses</div>\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="edit-passkey" data-id="${x(i.id)}"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-sm btn-outline-danger" data-action="delete-passkey" data-id="${x(i.id)}"><i class="bi bi-trash"></i></button>\n </div>\n </div>\n `}).join(""):'<div class="text-center text-secondary py-3"><i class="bi bi-fingerprint fs-3 d-block mb-2"></i>No passkeys registered</div>'});return s.onActionEditPasskey=async(e,t)=>{const s=t.dataset.id,n=i.find(e=>String(e.id)===String(s));return n&&await a.Modal.modelForm({title:"Edit Passkey",model:n,fields:l.PasskeyForms.edit.fields,size:"sm"}),!0},s.onActionDeletePasskey=async(e,t)=>{const s=t.dataset.id;if(await a.Modal.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(s));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await a.Modal.dialog({title:"Passkeys",body:s,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionEditUser(){return await a.Modal.modelForm({title:"Edit User",model:this.model,size:"md",formConfig:t.User.EDIT_FORM})&&await this._fullRefresh(),!0}async onActionToggleActive(e,i){const s=!!i.checked;i.disabled=!0;try{if(s){const e=await t.rest.POST(`/api/user/${this.model.id}`,{reactivate:{}});if(!e.success||e.status>=400)throw new Error(e.message||"Reactivate failed");e.data?.data?this.model.set(e.data.data):this.model.set("is_active",!0),this.getApp()?.toast?.success("User reactivated")}else{const e=await a.Modal.form({title:"Disable User",size:"sm",submitText:"Disable",fields:[{name:"reason",type:"select",label:"Reason",cols:12,help:'Optional. Defaults to "admin" (manual block) if left blank.',options:[{value:"",text:"(let backend default)"},{value:"admin",text:"Admin — block / policy violation"},{value:"abuse",text:"Abuse — banned"},{value:"inactive",text:"Inactive — idle account"}]},{name:"note",type:"textarea",label:"Note",cols:12,rows:3,placeholder:"Optional note about why this user is being disabled."}]});if(null===e||!1===e||0===e)return i.checked=!0,!0;const s={};e.reason&&(s.reason=e.reason),e.note&&(s.note=e.note);const n=await t.rest.POST(`/api/user/${this.model.id}`,{disable:s});if(!n.success||n.status>=400)throw new Error(n.message||"Disable failed");n.data?.data?this.model.set(n.data.data):this.model.set("is_active",!1),this.getApp()?.toast?.success("User disabled")}}catch(n){i.checked=!s,this.getApp()?.toast?.error(n.message||"Action failed")}finally{i&&i.isConnected&&(i.disabled=!1)}return!0}async onActionResetInactivity(e){e?.preventDefault?.();try{const e=await t.rest.POST(`/api/user/${this.model.id}`,{reactivate:{note:"Inactivity warning reset"}});if(!e.success||e.status>=400)throw new Error(e.message||"Reset failed");e.data?.data&&this.model.set(e.data.data),this.getApp()?.toast?.success("Inactivity warning cleared")}catch(i){this.getApp()?.toast?.error(i.message||"Failed to reset")}return!0}async onActionForceVerifyEmail(){return!(await a.Modal.confirm(`Mark <strong>${x(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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to verify email"),!0)}async onActionForceVerifyPhone(){return this.model.get("phone_number")?!(await a.Modal.confirm(`Mark <strong>${x(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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to verify phone"),!0):(this.getApp()?.toast?.error("User has no phone number"),!0)}async onActionUnverifyEmail(){return this._setVerification("is_email_verified",!1,"Email")}async onActionUnverifyPhone(){return this._setVerification("is_phone_verified",!1,"Phone")}async onActionRevokeAllSessions(){if(!(await a.Modal.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 onActionClearRateLimit(){if(!this.throttle?.retry_after_seconds)return this.getApp()?.toast?.info("No active rate limit to clear"),!0;if(!(await a.Modal.confirm("Clear the login rate-limit on this user? They will be able to attempt sign-in immediately.","Clear Rate Limit")))return!0;const e=await t.rest.POST("/api/auth/manage/clear_rate_limit",{key:"login",user_id:this.model.id});return e.success?(this.getApp()?.toast?.success("Rate limit cleared"),await this._refreshThrottle()):this.getApp()?.toast?.error(e.message||"Failed to clear rate limit"),!0}async onActionChangePassword(){const e=this.getApp(),t=await a.Modal.form({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({new_password:t.password});return 200===i.status?e?.toast?.success("Password updated"):e?.toast?.error(i.message||"Failed to set password"),!0}async onActionViewOrg(){const e=this.model.get("org");if(!e?.id)return!0;const i=new t.Group({id:e.id});try{await i.fetch()}catch{}if(!i.id)return this.getApp()?.toast?.error("Organization not found"),!0;const s=t.Group.VIEW_CLASS;return s?(await a.Modal.detail(new s({model:i})),!0):(this.getApp()?.toast?.error("GroupView not registered"),!0)}async onActionImpersonate(){if(!(await a.Modal.confirm(`Sign in as <strong>${x(this.model.get("display_name")||this.model.get("email")||"this user")}</strong>?`,"Impersonate")))return!0;const e=await t.rest.POST("/api/auth/impersonate",{user:this.model.id});return e.success?(this.getApp()?.toast?.success("Impersonation started"),window.location.reload()):this.getApp()?.toast?.error(e.message||"Failed to impersonate"),!0}async onActionChangeAvatar(){const e=await a.Modal.updateModelImage({model:this.model,field:"avatar",title:"Change Avatar",upload:!0},{name:"avatar",size:"lg",imageSize:{width:200,height:200},placeholder:"Upload an avatar image"});return e&&200===e.status&&(this.getApp()?.toast?.success("Avatar updated"),await this._fullRefresh()),!0}async onActionClearAvatar(){return!(await a.Modal.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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to clear avatar"),!0)}async onActionDeleteUser(){const e=this.model.get("display_name")||this.model.get("email")||`User #${this.model.id}`;if(!(await a.Modal.confirm({title:"Delete User",message:`Are you sure you want to delete <strong>${x(e)}</strong>? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"})))return!0;try{await this.model.destroy(),this.getApp()?.toast?.success("User deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("user:deleted",{model:this.model})}catch(t){this.getApp()?.toast?.error(`Failed to delete: ${t.message}`)}return!0}async _fullRefresh(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.profileSection?.isMounted()&&await this.profileSection.render()}async showTab(e){return this.showSection(e)}getActiveTab(){return this.sideNav?.getActiveSection?.()??null}static create(e={}){return new UserView(e)}}function L(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}UserView.VIEW_CLASS=UserView,t.User.VIEW_CLASS=UserView,t.User.MODEL_REF="account.User";const E=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,UserApiKeysSection:UserApiKeysSection,UserAuditSection:UserAuditSection,UserDevicesSection:UserDevicesSection,UserOverviewSection:UserOverviewSection,UserPermissionsSection:UserPermissionsSection,UserProfileSection:UserProfileSection,UserView:UserView,default:UserView},Symbol.toStringTag,{value:"Module"}));class UserTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_users",pageName:"Manage Users",router:"admin/users",Collection:t.UserList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-last_activity",is_active:"true"},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:"xxl",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,searchPlaceholder:"Search name, email, or username",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,i){e.preventDefault();const s=this.collection.get(i.dataset.id);await a.Modal.modelForm({model:s,size:"lg",title:`Edit Permissions for "${s._.username}"`,fields:t.UserForms.permissions.fields})}async onActionChangePassword(e,i){const s=this.collection.get(i.dataset.id),n=await a.Modal.form({title:`Change Password for "${s._.username}"`,fields:[{type:"text",name:"username",value:s.get("email")||s.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 a=await s.save({new_password:n.new_password});this.onPasswordChange(a)||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 i=this.collection.get(t.dataset.id),s=await i.save({send_invite:!0});return s.success?(this.getApp().toast.success("Invite sent successfully"),!0):(s.data&&s.data.error?this.getApp().toast.error(s.data.error):this.getApp().toast.error("Failed to send invite"),!1)}}function D(e){return e&&"object"==typeof e?Object.values(e).filter(e=>!0===e).length:0}const V={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"};class MemberOverviewSection extends t.View{constructor(e={}){super({className:"member-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="member-kpi-role"></div>\n <div data-container="member-kpi-status"></div>\n <div data-container="member-kpi-joined"></div>\n <div data-container="member-kpi-perms"></div>\n </div>\n\n <div class="detail-section-eyebrow">This membership</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">User</div>\n <div class="detail-flat-row-value">\n {{#userDisplayName}}<a href="#" data-action="view-user">{{userDisplayName}}</a>{{/userDisplayName}}\n {{^userDisplayName}}<span class="text-secondary fst-italic">Not set</span>{{/userDisplayName}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email</div>\n <div class="detail-flat-row-value">\n {{#userEmail}}{{userEmail}}{{/userEmail}}\n {{^userEmail}}<span class="text-secondary fst-italic">Not set</span>{{/userEmail}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Group</div>\n <div class="detail-flat-row-value">\n {{#groupName}}<a href="#" data-action="view-group">{{groupName}}</a>{{/groupName}}\n {{^groupName}}<span class="text-secondary fst-italic">Not set</span>{{/groupName}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Role</div>\n <div class="detail-flat-row-value">\n {{#hasRole|bool}}<span class="badge text-bg-primary">{{roleLabel}}</span>{{/hasRole|bool}}\n {{^hasRole|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasRole|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value">\n {{#hasCreated|bool}}{{model.created|epoch|datetime}} &middot; {{model.created|epoch|relative}}{{/hasCreated|bool}}\n {{^hasCreated|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCreated|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Invited by</div>\n <div class="detail-flat-row-value">\n {{#invitedBy}}{{invitedBy}}{{/invitedBy}}\n {{^invitedBy}}<span class="text-secondary fst-italic">—</span>{{/invitedBy}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Recent activity in this group</div>\n <div data-container="member-overview-activity"></div>\n ',...e}),this.logsCollection=e.logsCollection||null}get userDisplayName(){return this.model.get("user")?.display_name||""}get userEmail(){return this.model.get("user")?.email||""}get groupName(){return this.model.get("group")?.name||""}get roleLabel(){return this.model.get("metadata")?.role||""}get hasRole(){return!!this.roleLabel}get hasCreated(){return null!=this.model.get("created")}get invitedBy(){const e=this.model.get("metadata")||{};return e.invited_by_name||e.invited_by||""}get permsCount(){return D(this.model.get("permissions"))}get isActive(){return!!this.model.get("is_active")}async onInit(){const e=this.model;this.kpiRole=new n.MetricCard({containerId:"member-kpi-role",label:"Role",value:this.roleLabel||"—"}),this.kpiStatus=new n.MetricCard({containerId:"member-kpi-status",label:"Status",value:this.isActive?"Active":"Inactive",tone:this.isActive?"success":"warning"});const i=e.get("created"),s=null!=i?t.dataFormatter.apply(i,["epoch","relative"]):"—";this.kpiJoined=new n.MetricCard({containerId:"member-kpi-joined",label:"Joined",value:s||"—"}),this.kpiPerms=new n.MetricCard({containerId:"member-kpi-perms",label:"Perms granted",value:String(this.permsCount)}),[this.kpiRole,this.kpiStatus,this.kpiJoined,this.kpiPerms].forEach(e=>this.addChild(e)),this.activityTimeline=new n.Timeline({containerId:"member-overview-activity",limit:5,emptyText:"No recorded activity for this membership yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline)}async onAfterRender(){await super.onAfterRender(),this.logsCollection&&!this._wired&&(this.logsCollection.on("fetch:success",()=>{this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())},this),this._wired=!0)}_buildActivityItems(){return(this.logsCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=V[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?this.escapeHtml(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"]);return{tone:s,headline:String(a),detail:o,when:l}})}async onActionViewUser(e){e?.preventDefault?.(),this.emit("action:view-user")}async onActionViewGroup(e){e?.preventDefault?.(),this.emit("action:view-group")}}class MemberPermissionsSection extends t.View{constructor(e={}){super({className:"member-permissions-section",template:'\n <div class="detail-section-eyebrow">Group permissions</div>\n <p class="text-secondary small mb-3">Per-group grants. Toggles autosave as soon as you flip them.</p>\n <div data-container="member-perms-group"></div>\n\n <div class="detail-section-eyebrow mt-4">System permissions <span class="text-secondary fw-normal">(read-only)</span></div>\n <p class="text-secondary small mb-3">User-record permissions. Edit via the user\'s permissions page.</p>\n <div data-container="member-perms-system"></div>\n ',...e})}async onInit(){this.formView=new o.FormView({containerId:"member-perms-group",fields:l.Member.PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),this.addChild(this.formView),this.systemPermsView=new t.View({containerId:"member-perms-system",className:"member-system-perms",template:'\n {{#hasSystemPerms|bool}}\n {{#systemPermRows}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><code>{{key}}</code></div>\n <div class="detail-flat-row-value">\n <div class="form-check form-switch m-0">\n <input class="form-check-input" type="checkbox" disabled {{#enabled|bool}}checked{{/enabled|bool}} aria-label="{{key}}">\n </div>\n </div>\n </div>\n {{/systemPermRows}}\n {{/hasSystemPerms|bool}}\n {{^hasSystemPerms|bool}}\n <div class="text-center text-body-secondary py-3">\n <p class="mb-0 small">No system permissions on this user, or the user graph does not expose them.</p>\n </div>\n {{/hasSystemPerms|bool}}\n ',model:this.model}),Object.defineProperty(this.systemPermsView,"hasSystemPerms",{get:()=>{const e=this.model?.get?.("user")?.permissions;return!(!e||"object"!=typeof e||!Object.keys(e).length)}}),Object.defineProperty(this.systemPermsView,"systemPermRows",{get:()=>{const e=this.model?.get?.("user")?.permissions;return e&&"object"==typeof e?Object.keys(e).sort().map(t=>({key:t,enabled:!!e[t]})):[]}}),this.addChild(this.systemPermsView)}}class MemberView extends n.DetailView{constructor(e={}){const t=e.model||new l.Member(e.data||{}),i=t.get("id"),s=new l.LogList({params:{size:25,model_name:"account.Member",model_id:i,sort:"-created"}}),a=new MemberOverviewSection({model:t,logsCollection:s}),n=new MemberPermissionsSection({model:t}),o=new l.TableView({collection:s,title:"Audit",eyebrow:"Section · Audit",showFullscreen:!1,searchable:!1,hideActivePillNames:["model_name","model_id"],permissions:"view_logs",tableOptions:{striped:!1,hover:!0},columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",width:"180px",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,formatter:"badge",width:"110px"},{key:"kind",label:"Kind"},{key:"log",label:"Log"}]}),r=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:a},{key:"Permissions",label:"Permissions",icon:"bi-shield-lock",view:n},{type:"divider",label:"Activity"},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:o,permissions:"view_logs"}],c=[{icon:"bi-envelope",text:e=>e.get("user")?.email||null,variant:"light",when:e=>!!e.get("user")?.email},{icon:"bi-people",text:e=>e.get("group")?.kind||null,variant:"info",when:e=>!!e.get("group")?.kind},{icon:"bi-person-badge",text:e=>e.get("metadata")?.role||null,variant:"primary",when:e=>!!e.get("metadata")?.role},{text:e=>{const t=D(e.get("permissions"));return t>0?`${t} ${1===t?"perm":"perms"} granted`:null},variant:"light",when:e=>D(e.get("permissions"))>0}];super({className:"member-view",...e,model:t,header:{icon:"bi-person-badge",titleFn:e=>`${e.get("user")?.display_name||"User"} in ${e.get("group")?.name||"Group"}`,subtitlePath:"_subtitle",chips:c,activeField:"is_active",actions:[{label:"Edit role",icon:"bi-pencil",action:"edit-role",title:"Edit role and membership details"},{label:"Remove",icon:"bi-person-dash",action:"remove-from-group",title:"Remove from group"}],contextMenu:{items:[{label:"View user",action:"view-user",icon:"bi-person"},{label:"View group",action:"view-group",icon:"bi-people"},{label:"Audit log",action:"view-audit",icon:"bi-clock-history"},{type:"divider"},{label:"Remove from group",action:"remove-from-group",icon:"bi-person-dash",danger:!0}]}},sections:r,activeSection:"Overview"}),this.logsCollection=s,this.overviewSection=a,this.permissionsSection=n,this.auditSection=o,this._refreshComputedFields()}async onAfterBuild(){this.overviewSection.on("action:view-user",()=>this.onActionViewUser()),this.overviewSection.on("action:view-group",()=>this.onActionViewGroup());const e=()=>{const e=this.logsCollection.totalCount??this.logsCollection.models?.length??0;this.setBadge("Audit",e>0?{text:String(e),variant:"muted"}:null)};this.logsCollection.on("fetch:success",e,this),this.logsCollection.models?.length&&e(),this.logsCollection.fetch().catch(()=>{})}_refreshComputedFields(){const e=this.model,i=e.get("metadata")?.role||"Member",s=e.get("created"),a=[i];if(null!=s){const e=t.dataFormatter.apply(s,["epoch","relative"]);e&&a.push(`joined ${e}`)}e.attributes._subtitle=a.join(" · ")}async onActionEditRole(){await a.Modal.modelForm({title:"Edit membership",model:this.model,size:"md",formConfig:l.MemberForms.edit})&&(this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render())}async onActionViewUser(){const e=this.model.get("user")?.id;if(!e)return!0;const i=t.User.VIEW_CLASS;if(i){const s=new t.User({id:e});if(await s.fetch(),!s.id)return a.Modal.alert({message:`Could not find User with ID: ${e}`,type:"warning"}),!0;const n=new i({model:s});await a.Modal.detail(n)}else await a.Modal.showModelById(t.User,e);return!0}async onActionViewGroup(){const e=this.model.get("group")?.id;if(!e)return!0;const i=t.Group.VIEW_CLASS;if(i){const s=new t.Group({id:e});if(await s.fetch(),!s.id)return a.Modal.alert({message:`Could not find Group with ID: ${e}`,type:"warning"}),!0;const n=new i({model:s});await a.Modal.detail(n)}else await a.Modal.showModelById(t.Group,e);return!0}async onActionViewAudit(){await this.showSection("Audit")}async onActionRemoveFromGroup(){const e=this.model.get("user")?.display_name||"this user",t=this.model.get("group")?.name||"this group";if(!(await a.Modal.confirm(`Remove <strong>${this.escapeHtml(e)}</strong> from <strong>${this.escapeHtml(t)}</strong>? This cannot be undone.`,"Remove from group")))return!0;try{const e=await this.model.destroy();if(e&&e.success){this.getApp()?.toast?.success("Member removed"),this.emit("member:removed",{model:this.model});const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}else this.getApp()?.toast?.error("Failed to remove member")}catch(i){this.getApp()?.toast?.error(`Failed to remove member: ${i.message}`)}return!0}static create(e={}){return new MemberView(e)}}MemberView.VIEW_CLASS=MemberView,l.Member.VIEW_CLASS=MemberView,l.Member.MODEL_REF="account.Member",l.Member.VIEW_CLASS=MemberView;class MemberTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_members",pageName:"Manage Members",router:"admin/members",Collection:l.MemberList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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",visibility:"xl"}],searchPlaceholder:"Search name, email, or username",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 $={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."}]}},R=t.MOJOUtils.escapeHtml;function N(e){if(!e)return"";const i=t.Group.GroupKinds?.[e];if(i)return i;const s=String(e);return s.charAt(0).toUpperCase()+s.slice(1)}const F={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"};class GroupOverviewSection extends t.View{constructor(e={}){super({className:"group-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="group-kpi-members"></div>\n <div data-container="group-kpi-subgroups"></div>\n <div data-container="group-kpi-apikeys"></div>\n <div data-container="group-kpi-activity"></div>\n </div>\n\n <div class="detail-section-eyebrow">This group</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Name</div>\n <div class="detail-flat-row-value">{{model.name|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Parent</div>\n <div class="detail-flat-row-value">\n {{#hasParent|bool}}<a href="#" data-action="view-parent">{{parentName}}</a>{{/hasParent|bool}}\n {{^hasParent|bool}}<span class="text-secondary fst-italic">None — top-level group</span>{{/hasParent|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Hierarchy</div>\n <div data-container="group-overview-hierarchy"></div>\n\n <div class="detail-section-eyebrow">Recent activity</div>\n <div data-container="group-overview-activity"></div>\n ',...e}),this.membersCollection=e.membersCollection||null,this.subGroupsCollection=e.subGroupsCollection||null,this.apiKeysCollection=e.apiKeysCollection||null,this.auditCollection=e.auditCollection||null}get hasKind(){return!!this.model?.get?.("kind")}get kindLabel(){return N(this.model?.get?.("kind"))}get hasParent(){return!!this.model?.get?.("parent")?.id}get parentName(){const e=this.model?.get?.("parent");return e?.id?e.name||`#${e.id}`:""}async onInit(){const e=this.model;this.kpiMembers=new n.MetricCard({containerId:"group-kpi-members",label:"Members",value:this._memberCount()}),this.kpiSubGroups=new n.MetricCard({containerId:"group-kpi-subgroups",label:"Sub-Groups",value:this._subGroupCount()}),this.kpiApiKeys=new n.MetricCard({containerId:"group-kpi-apikeys",label:"API Keys",value:this._apiKeyCount()}),this.kpiActivity=new n.MetricCard({containerId:"group-kpi-activity",label:"Last activity",value:this._lastActivityLabel()}),[this.kpiMembers,this.kpiSubGroups,this.kpiApiKeys,this.kpiActivity].forEach(e=>this.addChild(e)),this.hierarchyTree=new GroupHierarchyTree({containerId:"group-overview-hierarchy",model:e,subGroupsCollection:this.subGroupsCollection,membersCollection:this.membersCollection}),this.addChild(this.hierarchyTree),this.activityTimeline=new n.Timeline({containerId:"group-overview-activity",limit:5,emptyText:"No recorded activity for this group yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline),this._wireCollection(this.membersCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.subGroupsCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.apiKeysCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.auditCollection,()=>this._refreshActivity())}_wireCollection(e,t){e&&!e._groupOverviewWired&&(e.on("fetch:success",t,this),e._groupOverviewWired=!0)}_memberCount(){return this.membersCollection?.models?.length??0}_subGroupCount(){return this.subGroupsCollection?.models?.length??0}_apiKeyCount(){return this.apiKeysCollection?.models?.length??0}_lastActivityLabel(){const e=this.model.get("last_activity");return e&&t.dataFormatter.apply(e,["epoch","relative"])||"—"}_refreshAfterFetch(){this.kpiMembers?.setValue(this._memberCount()),this.kpiSubGroups?.setValue(this._subGroupCount()),this.kpiApiKeys?.setValue(this._apiKeyCount()),this.kpiActivity?.setValue(this._lastActivityLabel()),this.hierarchyTree?.isMounted()&&this.hierarchyTree.render().catch(()=>{})}_refreshActivity(){this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())}_buildActivityItems(){return(this.auditCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=F[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?R(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"])||"";return{tone:s,headline:String(a),detail:o,when:l}})}async onActionViewParent(e){e?.preventDefault?.(),this.emit("navigate:parent")}}class GroupHierarchyTree extends t.View{constructor(e={}){super({className:"group-hierarchy-tree small font-monospace",template:'\n <div class="group-hierarchy-tree-rows">\n {{#hasParent|bool}}\n <a href="#" data-action="view-parent" data-id="{{parentId}}" class="link-secondary">{{parentName}}</a><br>\n {{/hasParent|bool}}\n {{^hasParent|bool}}\n <span class="text-secondary">Top-level group</span><br>\n {{/hasParent|bool}}\n {{{selfLine}}}\n {{#hasSubGroups|bool}}\n <div class="ms-4 mt-1">{{{childLines}}}</div>\n {{/hasSubGroups|bool}}\n </div>\n ',...e}),this.subGroupsCollection=e.subGroupsCollection||null,this.membersCollection=e.membersCollection||null}get _parent(){return this.model?.get?.("parent")||null}get hasParent(){return!!this._parent?.id}get parentId(){return this._parent?.id?String(this._parent.id):""}get parentName(){return this._parent?.id?this._parent.name||`#${this._parent.id}`:""}get _subGroups(){return this.subGroupsCollection?.models||[]}get hasSubGroups(){return this._subGroups.length>0}get _memberCount(){return this.membersCollection?.models?.length??0}get selfLine(){const e=this.model,t=this._subGroups.length,i=this._memberCount,s=1===i?"member":"members",a=1===t?"sub-group":"sub-groups";return`└─ <strong class="text-body">${R(e.get("name")||"—")}</strong> · ${i} ${s} · ${t} ${a}`}get childLines(){const e=this._subGroups;return e.map((t,i)=>{const s=i===e.length-1?"└─":"├─",a=t.get("id");return`${s} <a href="#" data-action="view-subgroup" data-id="${R(String(a))}">${R(t.get("name")||`#${a}`)}</a>`}).join("<br>")}async onActionViewParent(e,t){e?.preventDefault?.(),this.emit("navigate:parent",t?.dataset?.id)}async onActionViewSubgroup(e,t){e?.preventDefault?.(),this.emit("navigate:subgroup",t?.dataset?.id)}}class GroupIdentitySection extends t.View{constructor(e={}){super({className:"group-identity-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Profile</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Name</div>\n <div class="detail-flat-row-value">{{model.name|default:\'—\'}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Kind</div>\n <div class="detail-flat-row-value">\n {{#hasKind|bool}}<span class="badge text-bg-primary">{{kindLabel}}</span>{{/hasKind|bool}}\n {{^hasKind|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasKind|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-kind" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ID</div>\n <div class="detail-flat-row-value"><code>{{model.id}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Parent</div>\n <div class="detail-flat-row-value">\n {{#hasParent|bool}}<a href="#" data-action="view-parent">{{parentName}}</a>{{/hasParent|bool}}\n {{^hasParent|bool}}<span class="text-secondary fst-italic">None — top-level group</span>{{/hasParent|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Settings</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#hasTimezone|bool}}<code>{{timezone}}</code>{{/hasTimezone|bool}}\n {{^hasTimezone|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasTimezone|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">EOD hour</div>\n <div class="detail-flat-row-value">\n {{#hasEodHour|bool}}{{eodHourLabel}}{{/hasEodHour|bool}}\n {{^hasEodHour|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEodHour|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-eod-hour" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Domain</div>\n <div class="detail-flat-row-value">\n {{#hasDomain|bool}}<code>{{domain}}</code>{{/hasDomain|bool}}\n {{^hasDomain|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasDomain|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-domain" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Auth domain</div>\n <div class="detail-flat-row-value">\n {{#hasAuthDomain|bool}}<code>{{authDomain}}</code>{{/hasAuthDomain|bool}}\n {{^hasAuthDomain|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasAuthDomain|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-auth-domain" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Short name</div>\n <div class="detail-flat-row-value">\n {{#hasShortName|bool}}<code>{{shortName}}</code>{{/hasShortName|bool}}\n {{^hasShortName|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasShortName|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-short-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Portal</div>\n <div class="detail-flat-row-value">\n {{#hasPortal|bool}}<a href="{{portal}}" target="_blank" rel="noopener">{{portal}}</a>{{/hasPortal|bool}}\n {{^hasPortal|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasPortal|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-portal" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email template</div>\n <div class="detail-flat-row-value">\n {{#hasEmailTemplate|bool}}<code>{{emailTemplate}}</code>{{/hasEmailTemplate|bool}}\n {{^hasEmailTemplate|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEmailTemplate|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-email-template" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Dates</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Created</div>\n <div class="detail-flat-row-value">\n {{#hasCreated|bool}}<code>{{model.created|epoch|datetime}}</code>{{/hasCreated|bool}}\n {{^hasCreated|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCreated|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Modified</div>\n <div class="detail-flat-row-value">\n {{#hasModified|bool}}<code>{{model.modified|epoch|datetime}}</code>{{/hasModified|bool}}\n {{^hasModified|bool}}<span class="text-secondary fst-italic">—</span>{{/hasModified|bool}}\n </div>\n </div>\n ',...e})}get hasKind(){return!!this.model?.get?.("kind")}get kindLabel(){return N(this.model?.get?.("kind"))}get hasParent(){return!!this.model?.get?.("parent")?.id}get parentName(){const e=this.model?.get?.("parent");return e?.id?e.name||`#${e.id}`:""}get _meta(){return this.model?.get?.("metadata")||{}}get hasTimezone(){return!!this._meta.timezone}get timezone(){return this._meta.timezone||""}get hasEodHour(){const e=this._meta.eod_hour;return null!=e&&""!==e}get eodHourLabel(){const e=this._meta.eod_hour;return this.hasEodHour?`${String(e).padStart(2,"0")}:00`:""}get hasDomain(){return!!this._meta.domain}get domain(){return this._meta.domain||""}get hasAuthDomain(){return!!this._meta.auth_domain}get authDomain(){return this._meta.auth_domain||""}get hasShortName(){return!!this._meta.short_name}get shortName(){return this._meta.short_name||""}get hasPortal(){return!!this._meta.portal}get portal(){return this._meta.portal||""}get hasEmailTemplate(){return!!this._meta.email_template}get emailTemplate(){return this._meta.email_template||""}get hasAnySettings(){return this.hasTimezone||this.hasEodHour||this.hasDomain||this.hasAuthDomain||this.hasShortName||this.hasPortal||this.hasEmailTemplate}get hasCreated(){return null!=this.model?.get?.("created")}get hasModified(){return null!=this.model?.get?.("modified")}async onActionViewParent(e){e?.preventDefault?.(),this.emit("navigate:parent")}async onActionEditName(){const e=await a.Modal.prompt("Group name:","Edit Name",{defaultValue:this.model.get("name")||""});return"string"!=typeof e||!e.trim()||(await this._saveField({name:e.trim()},"Name"),!0)}async onActionEditKind(){const e=Object.entries(t.Group.GroupKinds||{}).map(([e,t])=>({value:e,text:t})),i=await a.Modal.form({title:"Edit Kind",size:"sm",fields:[{name:"kind",type:"select",label:"Kind",cols:12,options:[{value:"",text:"(none)"},...e]}],data:{kind:this.model.get("kind")||""}});return!i||(await this._saveField({kind:i.kind||null},"Kind"),!0)}async onActionEditTimezone(){const e=this.model.get("metadata")||{},t=await a.Modal.form({title:"Edit Timezone",size:"sm",fields:[{name:"timezone",type:"select",label:"Timezone",cols:12,options:[{value:"",text:"(none)"},{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:{timezone:t.timezone||null}},"Timezone"),!0)}async onActionEditEodHour(){const e=this.model.get("metadata")||{},t=await a.Modal.form({title:"Edit EOD Hour",size:"sm",fields:[{name:"eod_hour",type:"number",label:"EOD hour",cols:12,min:0,max:23,placeholder:"0–23",tooltip:"Hour of the day (24h, 0–23) when this group rolls over."}],data:{eod_hour:e.eod_hour??""}});if(!t)return!0;const i=t.eod_hour,s=""===i||null==i?null:Math.max(0,Math.min(23,parseInt(i,10)||0));return await this._saveField({metadata:{eod_hour:s}},"EOD hour"),!0}async onActionEditDomain(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Domain:","Edit Domain",{defaultValue:e.domain||""});return"string"!=typeof t||await this._saveField({metadata:{domain:t.trim()||null}},"Domain"),!0}async onActionEditAuthDomain(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Auth domain (used for white-label login pages):","Edit Auth Domain",{defaultValue:e.auth_domain||"",placeholder:"auth.example.com"});return"string"!=typeof t||await this._saveField({metadata:{auth_domain:t.trim()||null}},"Auth domain"),!0}async onActionEditShortName(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Short name:","Edit Short Name",{defaultValue:e.short_name||""});return"string"!=typeof t||await this._saveField({metadata:{short_name:t.trim()||null}},"Short name"),!0}async onActionEditPortal(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Portal URL:","Edit Portal",{defaultValue:e.portal||"",placeholder:"https://…"});return"string"!=typeof t||await this._saveField({metadata:{portal:t.trim()||null}},"Portal"),!0}async onActionEditEmailTemplate(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Email template name:","Edit Email Template",{defaultValue:e.email_template||""});return"string"!=typeof t||await this._saveField({metadata:{email_template:t.trim()||null}},"Email template"),!0}async _saveField(e,t){const i=await this.model.save(e);i&&200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(i?.message||`Failed to update ${t.toLowerCase()}`)}}class GroupPermissionsSection extends t.View{constructor(e={}){super({className:"group-permissions-section",template:'\n <div class="detail-section-eyebrow">Group permissions</div>\n {{#hasPermissions|bool}}\n {{#permissionRows}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><code>{{key}}</code></div>\n <div class="detail-flat-row-value">\n <div class="form-check form-switch m-0">\n <input class="form-check-input" type="checkbox" disabled {{#enabled|bool}}checked{{/enabled|bool}} aria-label="{{key}}">\n </div>\n </div>\n </div>\n {{/permissionRows}}\n {{/hasPermissions|bool}}\n {{^hasPermissions|bool}}\n <div class="text-center text-body-secondary py-4">\n <i class="bi bi-shield-lock fs-1 d-block mb-2"></i>\n <p class="mb-0 small">No group-scoped permissions defined. Permissions on members and API keys\n are managed from their own records.</p>\n </div>\n {{/hasPermissions|bool}}\n ',...e})}get _perms(){return(this.model?.get?.("metadata")||{}).permissions||this.model?.get?.("permissions")||null}get hasPermissions(){const e=this._perms;return!(!e||"object"!=typeof e||!Object.keys(e).length)}get permissionRows(){const e=this._perms;return e&&"object"==typeof e?Object.keys(e).sort().map(t=>({key:t,enabled:!!e[t]})):[]}}class GroupAuditTimelineSection extends t.View{constructor(e={}){const{auditCollection:t,...i}=e;super({className:"group-audit-section",template:'\n <div class="detail-section-eyebrow">Audit</div>\n <div data-container="group-audit-timeline"></div>\n ',...i}),this.auditCollection=t||null}async onInit(){this.timeline=new n.Timeline({containerId:"group-audit-timeline",emptyText:"No audit entries recorded for this group yet.",items:()=>this._buildItems()}),this.addChild(this.timeline)}async onAfterRender(){await super.onAfterRender(),this.auditCollection&&!this._wired&&(this.auditCollection.on("fetch:success",()=>{this.timeline?.isMounted()&&this.timeline.setItems(()=>this._buildItems())},this),this._wired=!0)}_buildItems(){return(this.auditCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=F[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?R(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"])||"";return{tone:s,headline:String(a),detail:o,when:l}})}}class GroupView extends n.DetailView{constructor(e={}){const i=e.model||new t.Group(e.data||{}),s=i.get("id"),a=new l.MemberList({params:{group:s,size:10}}),n=new t.GroupList({params:{parent:s,size:10}}),o=new ApiKeyList({params:{group:s,size:10}}),r=new c.IncidentEventList({params:{size:10,model_name:"account.Group",model_id:s,sort:"-created"}}),d=new l.LogList({params:{size:25,model_name:"account.Group",model_id:s,sort:"-created"}}),h=new GroupOverviewSection({model:i,membersCollection:a,subGroupsCollection:n,apiKeysCollection:o,auditCollection:d}),u=new GroupIdentitySection({model:i}),m=new l.TableView({collection:a,title:"Members",showFullscreen:!1,searchable:!1,hideActivePillNames:["group"],showAdd:!0,addButtonLabel:"Invite",clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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}]}),p=new l.TableView({collection:n,title:"Sub-Groups",showFullscreen:!1,searchable:!1,hideActivePillNames:["parent"],showAdd:!0,addButtonLabel:"Add Group",clickAction:"view",itemView:GroupView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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 text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"created",label:"Created",formatter:"date",sortable:!0}]}),b=new l.TableView({collection:o,title:"API Keys",showFullscreen:!1,searchable:!1,hideActivePillNames:["group"],showAdd:!0,addButtonLabel:"Create Key",clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},addFormConfig:{...$.create,defaults:{group:s}},columns:[{key:"name",label:"Name",sortable:!0},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}]}),g=new GroupPermissionsSection({model:i}),v=new l.TableView({collection:r,title:"Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),y=new GroupAuditTimelineSection({model:i,auditCollection:d}),w=new AdminMetadataSection({model:i}),f=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:h},{key:"Identity",label:"Identity",icon:"bi-card-text",view:u},{type:"divider",label:"Membership"},{key:"Members",label:"Members",icon:"bi-people",view:m},{key:"SubGroups",label:"Sub-Groups",icon:"bi-diagram-3",view:p},{type:"divider",label:"Access"},{key:"ApiKeys",label:"API Keys",icon:"bi-key",view:b},{key:"Permissions",label:"Permissions",icon:"bi-shield-lock",view:g},{type:"divider",label:"Activity"},{key:"Events",label:"Events",icon:"bi-calendar-event",view:v},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:y,permissions:"view_logs"},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:w}],_=i.get("kind"),k=function(e){const t=String(e||"").toLowerCase();return"org"===t||"organization"===t||"division"===t||"department"===t?"bi-buildings":"region"===t||"location"===t?"bi-geo-alt":"project"===t?"bi-kanban":"merchant"===t||"partner"===t||"client"===t||"reseller"===t?"bi-shop":"iso"===t||"sales"===t?"bi-briefcase":"route"===t?"bi-signpost-2":"inventory"===t?"bi-box-seam":"qa"===t||"test"===t||"testing"===t?"bi-clipboard-check":"bi-people-fill"}(_),x=[{text:e=>N(e.get("kind"))||null,variant:"primary",when:e=>!!e.get("kind")},{icon:"bi-people",text:()=>{const e=a.models?.length??0;return e?`${e} ${1===e?"member":"members"}`:null},variant:"light",when:()=>(a.models?.length??0)>0},{icon:"bi-diagram-3",text:()=>{const e=n.models?.length??0;return e?`${e} ${1===e?"sub-group":"sub-groups"}`:null},variant:"light",when:()=>(n.models?.length??0)>0},{text:e=>e.get("metadata")?.timezone||null,variant:"light",when:e=>!!e.get("metadata")?.timezone},{text:e=>{const t=e.get("metadata")?.eod_hour;return null==t||""===t?null:`EOD ${String(t).padStart(2,"0")}:00`},variant:"light"},{icon:"bi-globe",text:"Has portal",variant:"light",when:e=>!!e.get("metadata")?.portal}],S=N(_)||"Group",C=["groups","manage_groups"],A=["manage_groups"],T=[{label:`Edit ${S}`,action:"edit-group",icon:"bi-pencil",permissions:C},{label:"Invite Member",action:"invite-member",icon:"bi-person-plus",permissions:C},{label:`Add Sub-${S}`,action:"add-child-group",icon:"bi-diagram-3",permissions:C}];i.get("parent")?.id&&T.push({label:"View Parent",action:"view-parent-menu",icon:"bi-arrow-up-right-square"}),T.push({type:"divider"}),T.push(i.get("is_active")?{label:`Deactivate ${S}`,action:"state-toggle",icon:"bi-toggle-off",permissions:A}:{label:`Activate ${S}`,action:"state-toggle",icon:"bi-toggle-on",permissions:A}),T.push({type:"divider"}),T.push({label:`Delete ${S}`,action:"delete-group",icon:"bi-trash",danger:!0,permissions:A}),super({className:"group-view",...e,model:i,header:{icon:k,titleField:"name",subtitlePath:"_subtitle",chips:x,actions:[],auxFn:e=>function(e,i,s){const a=!!e.get("is_active"),n=s?`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${a?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${a?"Active":"Inactive"}</span>\n </label>\n `:"",o=e.get("last_activity"),l=i?.models?.length??0;let r="";if(o){const e=t.dataFormatter.apply(o,["epoch","relative"]);e&&(r=`Last activity ${R(String(e))}`)}else l>0&&(r=`${l} ${1===l?"member":"members"}`);return`\n <div class="dh-aux-top">${n}</div>\n ${r?`<span class="dh-aux-meta">${r}</span>`:""}\n `}(e,a,this.isAdminCallerDestructive),contextMenu:{items:T}},sections:f,activeSection:"Overview"}),this.membersCollection=a,this.subGroupsCollection=n,this.apiKeysCollection=o,this.eventsCollection=r,this.auditCollection=d,this.overviewSection=h,this.identitySection=u,this.membersSection=m,this.subGroupsSection=p,this.apiKeysSection=b,this.permissionsSection=g,this.eventsSection=v,this.auditSection=y,this.metadataSection=w,this._refreshComputedFields()}async onAfterBuild(){this.overviewSection.on("navigate:parent",e=>this._openGroupById(e??this.model.get("parent")?.id)),this.overviewSection.on("navigate:subgroup",e=>this._openGroupById(e)),this.identitySection.on("navigate:parent",()=>this._openGroupById(this.model.get("parent")?.id)),this.membersCollection.on("fetch:success",()=>{const e=this.membersCollection.models?.length??0;this.setBadge("Members",e>0?{text:String(e),variant:"muted"}:null)},this),this.subGroupsCollection.on("fetch:success",()=>{const e=this.subGroupsCollection.models?.length??0;this.setBadge("SubGroups",e>0?{text:String(e),variant:"muted"}:null)},this),this.apiKeysCollection.on("fetch:success",()=>{const e=this.apiKeysCollection.models?.length??0;this.setBadge("ApiKeys",e>0?{text:String(e),variant:"muted"}:null)},this),this.auditCollection.on("fetch:success",()=>{const e=this.auditCollection.models?.length??0;this.setBadge("Audit",e>0?{text:String(e),variant:"muted"}:null)},this);const e=()=>{this.headerView?.isMounted()&&this.headerView.render().catch(()=>{}),this._refreshComputedFields()};this.membersCollection.on("fetch:success",e,this),this.subGroupsCollection.on("fetch:success",e,this),this.membersCollection.fetch().catch(()=>{}),this.subGroupsCollection.fetch().catch(()=>{}),this.apiKeysCollection.fetch().catch(()=>{}),this.eventsCollection.fetch().catch(()=>{}),this.auditCollection.fetch().catch(()=>{})}_refreshComputedFields(){const e=this.model,t=e.get("parent");e.attributes._subtitle=t?.name?String(t.name):""}_kindNoun(){return N(this.model.get("kind"))||"Group"}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["groups","manage_groups"]))}get isAdminCallerDestructive(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.("manage_groups"))}async onActionEditGroup(){return await a.Modal.modelForm({title:`Edit ${this._kindNoun()} — ${this.model.get("name")}`,model:this.model,size:"lg",formConfig:t.GroupForms.detailed})&&await this._fullRefresh(),!0}async onActionInviteMember(e){e?.preventDefault&&(e.preventDefault(),e.stopPropagation());const t=await a.Modal.form({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(),s=await i.rest.POST("/api/group/member/invite",{group:this.model.id,email:t.email});return s.success?(i.toast.success("User invited successfully"),await this.membersCollection.fetch()):i.toast.error(s.message||"Failed to invite user"),!0}async onActionAddChildGroup(){const e=await a.Modal.form({title:`Add Sub-${this._kindNoun()} to ${this.model.get("name")}`,size:"sm",fields:t.GroupForms.create.fields.filter(e=>"parent"!==e.name)});if(!e)return!0;e.parent=this.model.id;const i=new t.Group(e),s=await i.save();return 200===s.status||201===s.status?(this.getApp()?.toast?.success("Sub-group created"),await this.subGroupsCollection.fetch()):this.getApp()?.toast?.error(s.message||"Failed to create sub-group"),!0}async onActionViewParentMenu(){const e=this.model.get("parent");return!e?.id||(await this._openGroupById(e.id),!0)}async _openGroupById(e){if(!e)return;const i=new t.Group({id:e});try{await i.fetch()}catch{}i.id?await a.Modal.detail(new GroupView({model:i})):this.getApp()?.toast?.error("Group not found")}async onActionToggleActive(e,t){const i=!!t.checked;t.disabled=!0;try{this.model.set("is_active",i);const e=await this.model.save({is_active:i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.emit("detail:updated")}catch(s){this.model.set("is_active",!i)}finally{t&&t.isConnected&&(t.disabled=!1)}return!0}async onActionStateToggle(){const e=!this.model.get("is_active"),t=e?"activate":"deactivate",i=e?"Activate":"Deactivate",s=this._kindNoun();if(!(await a.Modal.confirm(`Are you sure you want to ${t} <strong>${R(this.model.get("name")||"")}</strong>?`,`${i} ${s}`)))return!0;try{const t=await this.model.save({is_active:e});if(t&&t.status&&t.status>=400)throw new Error("Save failed");this.getApp()?.toast?.success("Group "+(e?"activated":"deactivated")),await this._fullRefresh()}catch(n){this.getApp()?.toast?.error(`Failed to ${t}: ${n.message}`)}return!0}async onActionDeleteGroup(){const e=this._kindNoun();if(!(await a.Modal.confirm({title:`Delete ${e}`,message:`Are you sure you want to delete <strong>${R(this.model.get("name")||"")}</strong>? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"})))return!0;try{await this.model.destroy(),this.getApp()?.toast?.success("Group deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("group:deleted",{model:this.model})}catch(t){this.getApp()?.toast?.error(`Failed to delete: ${t.message}`)}return!0}async _fullRefresh(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.identitySection?.isMounted()&&await this.identitySection.render(),this.permissionsSection?.isMounted()&&await this.permissionsSection.render()}}GroupView.VIEW_CLASS=GroupView,t.Group.VIEW_CLASS=GroupView,t.Group.MODEL_REF="account.Group",t.Group.VIEW_CLASS=GroupView;class GroupTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_groups",pageName:"Manage Groups",router:"admin/groups",Collection:t.GroupList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-id",is_active:"true"},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Display Name"},{key:"kind|badge",label:"Kind",filter:{type:"select",options:t.Group.GroupKindOptions}},{key:"member_count",label:"Members",sortable:!0,align:"right",visibility:"lg",class:"text-muted"},{key:"is_active|yesnoicon",label:"Enabled",visibility:"xl"},{key:"parent.name",label:"Parent",formatter:"default('-')",visibility:"lg",class:"text-muted fs-8"},{key:"created",label:"Created",className:"text-muted fs-8",formatter:"epoch|datetime",visibility:"xl"},{key:"last_activity",label:"Activity",className:"text-muted fs-8",formatter:"relative",visibility:"xl"}],filters:[{key:"is_active",label:"Active",type:"boolean",trueLabel:"Active",falseLabel:"Inactive"}],searchPlaceholder:"Search name or kind",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 i=this.collection.get(t.dataset.id);this.getApp().setActiveGroup(i)}}t.UserDevice.VIEW_CLASS=DeviceView;class UserDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_user_devices",pageName:"User Devices",router:"admin/user/devices",Collection:t.UserDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search user, IP, or device ID",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"duid",label:"Device ID",sortable:!0,formatter:"truncate_middle(16)",visibility:"xxl"},{key:"user.display_name",label:"User",sortable:!0,formatter:"default('—')"},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')",visibility:"lg"},{key:"device_info.os.family",label:"OS",formatter:"default('—')",visibility:"xl"},{key:"last_ip",label:"Last IP",sortable:!0},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime",visibility:"xl"},{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 l.TableView({collection:new c.LoginEventList({params:{size:20}}),searchable:!0,searchPlaceholder:"Search user, IP, or city",sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showExport:!0,dayRangeFilter:!0,...n.groupByDay("created"),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('—')",visibility:"xl"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0,visibility:"xl"},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px"}]});t.onTabActivated=async()=>{await(t.collection?.fetch())},this.tabView=new o.TabView({containerId:"tabs",tabs:{Map:e,Logins:t},activeTab:"Map"}),this.addChild(this.tabView)}}const O=e=>{if(!e||"string"!=typeof e||2!==e.length)return"";const t=127462,i=e.toUpperCase();return String.fromCodePoint(t+(i.charCodeAt(0)-65))+String.fromCodePoint(t+(i.charCodeAt(1)-65))},B={none:"success",low:"success",medium:"warning",high:"danger",critical:"danger"};class GeoIPOverviewSection extends t.View{constructor(e={}){super({className:"geoip-overview-section",template:'\n <div class="detail-section-eyebrow">Overview</div>\n\n <div data-container="geoip-overview-status"></div>\n\n <div class="detail-kpi-grid">\n <div data-container="geoip-kpi-threat"></div>\n <div data-container="geoip-kpi-events"></div>\n <div data-container="geoip-kpi-lastseen"></div>\n <div data-container="geoip-kpi-logins"></div>\n </div>\n\n <div class="detail-section-eyebrow">Location &amp; network</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Country</div>\n <div class="detail-flat-row-value">\n {{#hasCountry|bool}}{{{countryDisplay}}}{{/hasCountry|bool}}\n {{^hasCountry|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCountry|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Region · City</div>\n <div class="detail-flat-row-value">\n {{#hasLocation|bool}}{{regionCityDisplay}}{{/hasLocation|bool}}\n {{^hasLocation|bool}}<span class="text-secondary fst-italic">—</span>{{/hasLocation|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Coordinates</div>\n <div class="detail-flat-row-value">\n {{#hasCoords|bool}}<code>{{coordsDisplay}}</code>{{/hasCoords|bool}}\n {{^hasCoords|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCoords|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN · ISP</div>\n <div class="detail-flat-row-value">\n {{#hasAsnOrIsp|bool}}{{{asnIspDisplay}}}{{/hasAsnOrIsp|bool}}\n {{^hasAsnOrIsp|bool}}<span class="text-secondary fst-italic">—</span>{{/hasAsnOrIsp|bool}}\n </div>\n </div>\n\n <div data-container="geoip-overview-map"></div>\n {{#hasCoords|bool}}\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-action="open-on-map" data-bs-toggle="tooltip" title="Open on map">\n <i class="bi bi-box-arrow-up-right"></i>\n </button>\n </div>\n {{/hasCoords|bool}}\n ',...e}),this._mapMounted=!1}get hasCountry(){return!(!this.model.get("country_code")&&!this.model.get("country_name"))}get countryDisplay(){const e=this.model.get("country_code")||"",t=this.model.get("country_name")||"",i=O(e);return`${i?`${i} `:""}${this.escapeHtml(t||e||"—")}${e?` <code class="text-secondary small">${this.escapeHtml(e)}</code>`:""}`}get hasLocation(){return!(!this.model.get("region")&&!this.model.get("city"))}get regionCityDisplay(){return[this.model.get("region")||"",this.model.get("city")||""].filter(Boolean).join(" · ")}get hasCoords(){const e=this.model.get("latitude"),t=this.model.get("longitude");return null!=e&&null!=t}get coordsDisplay(){return`${this.model.get("latitude")}, ${this.model.get("longitude")}`}get hasAsnOrIsp(){return!(!this.model.get("asn")&&!this.model.get("isp"))}get asnIspDisplay(){const e=this.model.get("asn"),t=this.model.get("asn_org"),i=this.model.get("isp"),s=[];if(e){const i=`<code>${this.escapeHtml(String(e))}</code>`,a=t?` ${this.escapeHtml(t)}`:"";s.push(`${i}${a}`)}return i&&s.push(this.escapeHtml(i)),s.join(" · ")}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"geoip-overview-status",model:e,tone:e=>this._statusTone(e),state:e=>this._statusState(e),headline:e=>this._statusHeadline(e),meta:e=>this._statusMeta(e),actions:e=>this._statusActions(e)}),this.addChild(this.statusPanel),this.kpiThreat=new n.MetricCard({containerId:"geoip-kpi-threat",label:"Threat score",value:()=>{const e=this.model.get("risk_score");return null!=e?`${e} / 100`:"—"},tone:B[(e.get("threat_level")||"unknown").toLowerCase()]||"default"}),this.kpiEvents=new n.MetricCard({containerId:"geoip-kpi-events",label:"Incident events",value:()=>{const e=this.model.get("event_count")??this.model.get("incident_count");return null!=e?String(e):"—"},tone:(e.get("event_count")??e.get("incident_count")??0)>0?"warning":"default"}),this.kpiLastSeen=new n.MetricCard({containerId:"geoip-kpi-lastseen",label:"Last seen",value:this.model.get("last_seen")?this.model._formatRelative(this.model.get("last_seen")):"—"}),this.kpiLogins=new n.MetricCard({containerId:"geoip-kpi-logins",label:"Login attempts",value:()=>{const e=this.model.get("login_attempts")??this.model.get("login_count");return null!=e?String(e):"—"}}),[this.kpiThreat,this.kpiEvents,this.kpiLastSeen,this.kpiLogins].forEach(e=>this.addChild(e))}_statusTone(e){if(e.get("is_blocked"))return"danger";if(e.get("is_whitelisted"))return"success";const t=(e.get("threat_level")||"").toLowerCase();return e.get("is_threat")||["high","critical"].includes(t)?"danger":e.get("is_suspicious")||"medium"===t?"warning":"success"}_statusState(e){if(e.get("is_blocked"))return"Blocked";if(e.get("is_whitelisted"))return"Whitelisted";const t=(e.get("threat_level")||"").toLowerCase();return e.get("is_threat")||["high","critical"].includes(t)?"Allowed · high risk":e.get("is_suspicious")||"medium"===t?"Allowed · elevated risk":"Allowed"}_statusHeadline(e){if(e.get("is_blocked")){const t=e.get("blocked_reason");return t?`Blocked: ${t}`:"Currently blocked"}if(e.get("is_whitelisted")){const t=e.get("whitelisted_reason");return t?`Whitelisted: ${t}`:"On whitelist"}const t=(e.get("threat_level")||"").toLowerCase();if(e.get("is_threat")||["high","critical"].includes(t))return`Active threat (${t||"high"})`;if(e.get("is_suspicious")||"medium"===t){const i=[];return e.get("is_vpn")&&i.push("VPN"),e.get("is_tor")&&i.push("Tor"),e.get("is_proxy")&&i.push("proxy"),e.get("is_datacenter")&&i.push("datacenter"),i.length?`${i.join(" / ")} signal detected`:"Suspicious"+(t?` · ${t}`:"")}return"No active threat signals"}_statusMeta(e){const t=e.get("risk_score"),i=e.get("last_seen"),s=e=>e?this.model._formatRelative(e):null,a=e=>e?this.model._formatDateTime(e):null;if(e.get("is_blocked")){const t=e.get("blocked_until"),i=e.get("blocked_at");return`Blocked ${t?`until <strong>${this.escapeHtml(a(t)||"")}</strong>`:"permanently"}${i?` · ${this.escapeHtml(s(i)||"")}`:""}`}return e.get("is_whitelisted")?"This IP bypasses the firewall":`Risk score <strong>${this.escapeHtml(String(t??"—"))}</strong>${i?` · last seen ${this.escapeHtml(s(i)||"")}`:""}`}_statusActions(e){const t=[];return e.get("is_blocked")?t.push({label:"Unblock",action:"unblock",icon:"bi-unlock",variant:"outline-success"}):t.push({label:"Block 24h",action:"block",icon:"bi-slash-circle",variant:"danger"}),e.get("is_whitelisted")||t.push({label:"Whitelist",action:"whitelist",icon:"bi-shield-check",variant:"outline-secondary"}),t}async onAfterMount(){await(super.onAfterMount?.());const e=this.model,t=e.get("latitude"),i=e.get("longitude");if(null==t||null==i||this._mapMounted)return;const a=[e.get("city")||"",e.get("region")||"",e.get("country_name")||""].filter(Boolean).join(", ");this.mapView=new s.MapView({containerId:"geoip-overview-map",markers:[{lat:t,lng:i,popup:`<strong>${this.escapeHtml(e.get("ip_address")||"")}</strong><br>${this.escapeHtml(a)}`}],tileLayer:"light",zoom:4,height:200}),this.addChild(this.mapView),await this.mapView.render(),this._mapMounted=!0}async onActionOpenOnMap(){const e=this.model.get("latitude"),t=this.model.get("longitude");null!=e&&null!=t&&window.open(`https://www.google.com/maps/search/?api=1&query=${e},${t}`,"_blank")}}class GeoIPNetworkSection extends t.View{constructor(e={}){super({className:"geoip-network-section",template:'\n <div class="detail-section-eyebrow">Identity</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">IP address</div>\n <div class="detail-flat-row-value"><code>{{model.ip_address|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">IP version</div>\n <div class="detail-flat-row-value">{{model.ip_version|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Subnet</div>\n <div class="detail-flat-row-value">\n {{#model.subnet}}<code>{{model.subnet}}</code>{{/model.subnet}}\n {{^model.subnet}}<span class="text-secondary fst-italic">—</span>{{/model.subnet}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reverse DNS</div>\n <div class="detail-flat-row-value">\n {{#model.reverse_dns}}<code class="small">{{model.reverse_dns}}</code>{{/model.reverse_dns}}\n {{^model.reverse_dns}}<span class="text-secondary fst-italic">—</span>{{/model.reverse_dns}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Carrier · ASN · ISP</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN</div>\n <div class="detail-flat-row-value">\n {{#model.asn}}<code>{{model.asn}}</code>{{/model.asn}}\n {{^model.asn}}<span class="text-secondary fst-italic">—</span>{{/model.asn}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN org</div>\n <div class="detail-flat-row-value">{{model.asn_org|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ISP</div>\n <div class="detail-flat-row-value">{{model.isp|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Connection</div>\n <div class="detail-flat-row-value">{{model.connection_type|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Mobile carrier</div>\n <div class="detail-flat-row-value">{{model.mobile_carrier|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Hosting flags</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Cloud provider</div>\n <div class="detail-flat-row-value">{{{model.is_cloud|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Datacenter</div>\n <div class="detail-flat-row-value">{{{model.is_datacenter|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Mobile</div>\n <div class="detail-flat-row-value">{{{model.is_mobile|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">VPN</div>\n <div class="detail-flat-row-value">{{{model.is_vpn|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Tor exit</div>\n <div class="detail-flat-row-value">{{{model.is_tor|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Proxy</div>\n <div class="detail-flat-row-value">{{{model.is_proxy|yesnoicon}}}</div>\n </div>\n ',...e})}}class GeoIPRiskSection extends t.View{constructor(e={}){super({className:"geoip-risk-section",template:'\n <div class="detail-section-eyebrow">Summary</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Threat level</div>\n <div class="detail-flat-row-value">\n <span class="badge text-bg-{{threatLevelTone}}">{{threatLevelLabel}}</span>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Risk score</div>\n <div class="detail-flat-row-value">\n {{#hasScore|bool}}<strong>{{model.risk_score}}</strong> / 100{{/hasScore|bool}}\n {{^hasScore|bool}}<span class="text-secondary fst-italic">—</span>{{/hasScore|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Provider</div>\n <div class="detail-flat-row-value">{{model.provider|default:\'unknown\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last checked</div>\n <div class="detail-flat-row-value">{{model.last_seen|relative|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Reputation flags</div>\n {{#firedFlags.length}}\n {{#firedFlags}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">{{label}}</div>\n <div class="detail-flat-row-value">\n <span class="badge text-bg-{{tone}}"><i class="bi {{icon}} me-1"></i>{{title}}</span>\n {{#detail}}<span class="text-secondary">· {{detail}}</span>{{/detail}}\n </div>\n </div>\n {{/firedFlags}}\n {{/firedFlags.length}}\n {{^firedFlags.length}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value text-secondary fst-italic">No reputation flags fired.</div>\n </div>\n {{/firedFlags.length}}\n ',...e})}get threatLevelLabel(){return this.model.get("threat_level")||"unknown"}get threatLevelTone(){const e=(this.model.get("threat_level")||"").toLowerCase();return B[e]||"secondary"}get hasScore(){return null!=this.model.get("risk_score")}get firedFlags(){const e=this.model;return[{key:"is_threat",label:"threat",icon:"bi-shield-exclamation",tone:"danger",title:"Active threat",detail:"Marked as an active threat"},{key:"is_suspicious",label:"suspicious",icon:"bi-question-octagon",tone:"warning",title:"Suspicious",detail:"Flagged suspicious by enrichment"},{key:"is_known_attacker",label:"attacker",icon:"bi-exclamation-octagon-fill",tone:"danger",title:"Known attacker",detail:"Recorded in attacker feeds"},{key:"is_known_abuser",label:"abuser",icon:"bi-exclamation-triangle-fill",tone:"danger",title:"Known abuser",detail:"Recorded in abuse feeds"},{key:"is_vpn",label:"vpn",icon:"bi-shield-shaded",tone:"warning",title:"VPN exit",detail:"Detected as a VPN exit node"},{key:"is_tor",label:"tor",icon:"bi-shield-lock",tone:"danger",title:"Tor exit",detail:"Detected as a Tor exit node"},{key:"is_proxy",label:"proxy",icon:"bi-diagram-3",tone:"warning",title:"Open proxy",detail:"Detected as an open proxy"}].filter(t=>!!e.get(t.key))}}class GeoIPBlockSection extends t.View{constructor(e={}){super({className:"geoip-block-section",template:'\n <div class="detail-section-eyebrow">\n Block\n <div class="detail-flat-row-action">\n {{#model.is_blocked|bool}}\n <button type="button" class="detail-section-action" data-action="unblock" data-bs-toggle="tooltip" title="Unblock"><i class="bi bi-unlock"></i></button>\n {{/model.is_blocked|bool}}\n {{^model.is_blocked|bool}}\n <button type="button" class="detail-section-action" data-action="block" data-bs-toggle="tooltip" title="Block IP"><i class="bi bi-slash-circle"></i></button>\n {{/model.is_blocked|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_blocked|bool}}<span class="badge text-bg-danger"><i class="bi bi-slash-circle me-1"></i>Blocked</span>{{/model.is_blocked|bool}}\n {{^model.is_blocked|bool}}<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Allowed</span>{{/model.is_blocked|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reason</div>\n <div class="detail-flat-row-value">{{model.blocked_reason|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Blocked at</div>\n <div class="detail-flat-row-value">\n {{#model.blocked_at}}<code>{{model.blocked_at|datetime}}</code>{{/model.blocked_at}}\n {{^model.blocked_at}}<span class="text-secondary fst-italic">—</span>{{/model.blocked_at}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Blocked until</div>\n <div class="detail-flat-row-value">\n {{#model.blocked_until}}<code>{{model.blocked_until|datetime}}</code>{{/model.blocked_until}}\n {{^model.blocked_until}}<span class="text-secondary fst-italic">Permanent / —</span>{{/model.blocked_until}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Block count</div>\n <div class="detail-flat-row-value">{{blockCountDisplay}}</div>\n </div>\n\n <div class="detail-section-eyebrow">\n Whitelist\n <div class="detail-flat-row-action">\n {{#model.is_whitelisted|bool}}\n <button type="button" class="detail-section-action" data-action="unwhitelist" data-bs-toggle="tooltip" title="Remove whitelist"><i class="bi bi-x-circle"></i></button>\n {{/model.is_whitelisted|bool}}\n {{^model.is_whitelisted|bool}}\n <button type="button" class="detail-section-action" data-action="whitelist" data-bs-toggle="tooltip" title="Whitelist"><i class="bi bi-shield-check"></i></button>\n {{/model.is_whitelisted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_whitelisted|bool}}<span class="badge text-bg-info"><i class="bi bi-shield-check me-1"></i>Whitelisted</span>{{/model.is_whitelisted|bool}}\n {{^model.is_whitelisted|bool}}<span class="badge text-bg-secondary">Not whitelisted</span>{{/model.is_whitelisted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reason</div>\n <div class="detail-flat-row-value">{{model.whitelisted_reason|default:\'—\'}}</div>\n </div>\n ',...e})}get blockCountDisplay(){const e=this.model.get("block_count");return null!=e?String(e):"0"}async onActionBlock(){this.emit("action:block")}async onActionUnblock(){this.emit("action:unblock")}async onActionWhitelist(){this.emit("action:whitelist")}async onActionUnwhitelist(){this.emit("action:unwhitelist")}}class GeoIPActivitySection extends t.View{constructor(e={}){const{eventsTable:t,logsTable:i,...s}=e;super({className:"geoip-activity-section",template:'\n <div class="detail-section-eyebrow">Activity</div>\n <div data-container="geoip-activity-tabs"></div>\n ',...s}),this.eventsTable=t,this.logsTable=i}async onInit(){const e={};this.eventsTable&&(e.Events=this.eventsTable),this.logsTable&&(e.Logs=this.logsTable),this.tabView=new o.TabView({containerId:"geoip-activity-tabs",tabs:e,activeTab:"Events"}),this.addChild(this.tabView)}}class GeoIPMetadataSection extends t.View{constructor(e={}){super({className:"geoip-metadata-section",template:'\n <div class="detail-section-eyebrow">Metadata</div>\n <div data-container="geoip-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"geoip-metadata-card",model:this.model,data:e=>e.attributes||{},knownKeys:[{key:"id",label:"Record ID",formatter:e=>null!=e?`<code>${this.escapeHtml(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"provider",label:"Provider"},{key:"created",label:"Created",formatter:"datetime"},{key:"modified",label:"Modified",formatter:"datetime"},{key:"last_seen",label:"Last seen",formatter:"datetime"},{key:"expires_at",label:"Expires at",formatter:"datetime"}],rawLabel:"Raw record JSON",rawCollapsed:!0}),this.addChild(this.knownFields)}}class GeoIPView extends n.DetailView{constructor(e={}){const t=e.model||new n.GeoLocatedIP(e.data||{}),i=t.get("ip_address");t._formatRelative||(t._formatRelative=e=>{if(null==e)return"";const t="number"==typeof e&&e<1e11?1e3*e:new Date(e).getTime();if(!Number.isFinite(t))return"";const i=Math.round((Date.now()-t)/1e3);return i<0?"just now":i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`},t._formatDateTime=e=>{if(null==e)return"";const t="number"==typeof e&&e<1e11?1e3*e:new Date(e).getTime();return Number.isFinite(t)?new Date(t).toLocaleString():""});const s=new c.IncidentEventList({params:{source_ip:i,size:25,sort:"-created"}}),a=new l.LogList({params:{ip:i,size:25,sort:"-created"}}),o=new GeoIPOverviewSection({model:t}),r=new GeoIPNetworkSection({model:t}),d=new GeoIPRiskSection({model:t}),h=new GeoIPBlockSection({model:t}),u=new GeoIPMetadataSection({model:t}),m=new l.TableView({collection:s,title:"Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["source_ip"],columns:[{key:"id",label:"ID",sortable:!0,width:"60px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),p=new l.TableView({collection:a,title:"Logs",permissions:"view_logs",showFullscreen:!1,searchable:!1,hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",width:"180px",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,width:"90px",filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",width:"120px",filter:{type:"text"}},{key:"log",label:"Log"}]}),b=new GeoIPActivitySection({model:t,eventsTable:m,logsTable:p}),g=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:o},{key:"Network",label:"Network",icon:"bi-diagram-3",view:r},{key:"Risk",label:"Risk & Reputation",icon:"bi-shield-exclamation",view:d},{type:"divider",label:"Enforcement"},{key:"Block",label:"Block & Whitelist",icon:"bi-slash-circle",view:h},{type:"divider",label:"Activity"},{key:"Activity",label:"Activity",icon:"bi-list-ul",view:b,permissions:"view_logs"},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:u}],v=[{text:e=>{const t=e.get("country_code"),i=O(t),s=e.get("country_name");return t||s?`${i?`${i} `:""}${s||t}`:null},variant:"light",when:e=>!(!e.get("country_code")&&!e.get("country_name"))},{icon:"bi-exclamation-triangle-fill",text:e=>`Threat: ${e.get("threat_level")}`,variant:"danger",when:e=>["high","critical"].includes((e.get("threat_level")||"").toLowerCase())},{icon:"bi-exclamation-triangle",text:e=>`Threat: ${e.get("threat_level")}`,variant:"warning",when:e=>"medium"===(e.get("threat_level")||"").toLowerCase()},{icon:"bi-shield-check",text:e=>`Threat: ${e.get("threat_level")}`,variant:"light",when:e=>{const t=(e.get("threat_level")||"").toLowerCase();return t&&!["high","critical","medium"].includes(t)}},{text:e=>null!=e.get("risk_score")?`Risk score ${e.get("risk_score")}`:null,variant:"light",when:e=>null!=e.get("risk_score")},{icon:"bi-shield-shaded",text:"VPN",variant:"warning",when:e=>!!e.get("is_vpn")},{icon:"bi-shield-lock",text:"Tor",variant:"danger",when:e=>!!e.get("is_tor")},{icon:"bi-diagram-3",text:"Proxy",variant:"warning",when:e=>!!e.get("is_proxy")},{icon:"bi-cloud-fill",text:"Cloud",variant:"info",when:e=>!!e.get("is_cloud")},{icon:"bi-hdd-stack",text:"Datacenter",variant:"warning",when:e=>!!e.get("is_datacenter")},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",when:e=>!!e.get("is_blocked")},{icon:"bi-shield-check",text:"Whitelisted",variant:"success",when:e=>!!e.get("is_whitelisted")}];super({className:"geoip-view",...e,model:t,header:{icon:"bi-globe2",iconToneFn:e=>{const t=!!e.get("is_blocked"),i=!!e.get("is_threat"),s=!!e.get("is_suspicious"),a=(e.get("threat_level")||"").toLowerCase();return t||i||["high","critical"].includes(a)?"danger":s||"medium"===a?"warning":"info"},titleFn:e=>e.get("ip_address")||"—",subtitlePath:"_subtitle",chips:v,actions:[],contextMenu:{items:[{label:"Refresh geolocation",action:"refresh-geoip",icon:"bi-arrow-clockwise"},{label:"Refresh threat data",action:"threat-analysis",icon:"bi-shield-exclamation"},{label:"View on map",action:"view-on-map",icon:"bi-map"},{type:"divider"},{label:"Edit Location",action:"edit-location",icon:"bi-geo-alt"},{label:"Edit Network",action:"edit-network",icon:"bi-diagram-3"},{label:"Edit Security",action:"edit-security",icon:"bi-shield-lock"},{type:"divider"},{label:"Block 24h",action:"block-ip",icon:"bi-slash-circle"},{label:"Unblock",action:"unblock-ip",icon:"bi-unlock"},{label:"Whitelist",action:"whitelist-ip",icon:"bi-shield-check"},{label:"Remove whitelist",action:"unwhitelist-ip",icon:"bi-x-circle"},{type:"divider"},{label:"Delete record",action:"delete-geoip",icon:"bi-trash",danger:!0}]}},sections:g,activeSection:"Overview"}),this.eventsCollection=s,this.logsCollection=a,this.overviewSection=o,this.networkSection=r,this.riskSection=d,this.blockSection=h,this.activitySection=b,this.metadataSection=u,this.eventsTable=m,this.logsTable=p,this._refreshComputedFields()}async onAfterBuild(){this.blockSection.on("action:block",()=>this.onActionBlockIp()),this.blockSection.on("action:unblock",()=>this.onActionUnblockIp()),this.blockSection.on("action:whitelist",()=>this.onActionWhitelistIp()),this.blockSection.on("action:unwhitelist",()=>this.onActionUnwhitelistIp());const e=()=>{const e=this.eventsCollection.totalCount??this.eventsCollection.models?.length??0,t=e+(this.logsCollection.totalCount??this.logsCollection.models?.length??0),i=e>10?"warning":"muted";this.setBadge("Activity",t>0?{text:String(t),variant:i}:null)};this.eventsCollection.on("fetch:success",e,this),this.logsCollection.on("fetch:success",e,this),this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{})}async onActionBlock(){return this.onActionBlockIp()}async onActionUnblock(){return this.onActionUnblockIp()}async onActionWhitelist(){return this.onActionWhitelistIp()}_refreshComputedFields(){const e=this.model,t=[],i=[e.get("city"),e.get("region"),e.get("country_name")].filter(Boolean).join(", ");i&&t.push(i);const s=e.get("asn"),a=e.get("isp");if(s||a){const e=s?`ASN ${s}`:"",i=a?` (${a})`:"";t.push(`${e}${i}`.trim())}const n=e.get("reverse_dns");n&&t.push(`reverse ${n}`),e.attributes._subtitle=t.length?t.join(" · "):""}async _refreshFromModel(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.riskSection?.isMounted()&&await this.riskSection.render(),this.blockSection?.isMounted()&&await this.blockSection.render()}async onActionRefreshGeoip(){try{await this.model.save({refresh:!0}),this.getApp()?.toast?.info(`Refresh requested for ${this.model.get("ip_address")}`),await this.model.fetch(),await this._refreshFromModel()}catch(e){this.getApp()?.toast?.error(e.message||"Failed to refresh geolocation")}}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(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to refresh threat data")}finally{t&&(t.disabled=!1)}return!0}async onActionViewOnMap(){const e=this.model.get("latitude"),t=this.model.get("longitude");if(null==e||null==t)return void this.getApp()?.toast?.warning("No coordinates available for this IP");const i=`https://www.google.com/maps/search/?api=1&query=${e},${t}`;window.open(i,"_blank")}async onActionEditLocation(){await a.Modal.modelForm({title:`Edit Location — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_LOCATION_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Location updated"))}async onActionEditSecurity(){await a.Modal.modelForm({title:`Edit Security — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_SECURITY_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Security settings updated"))}async onActionEditNetwork(){await a.Modal.modelForm({title:`Edit Network — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_NETWORK_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Network information updated"))}async onActionBlockIp(){const e=await a.Modal.form({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:0}]});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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await a.Modal.form({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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await a.Modal.form({title:"Whitelist IP",icon:"bi-shield-check",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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}async onActionUnwhitelistIp(){if(!(await a.Modal.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(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to remove from whitelist"),!0}async onActionDeleteGeoip(){if(!(await a.Modal.confirm(`Are you sure you want to delete the GeoIP record for "${this.model.get("ip_address")}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})))return;const e=await this.model.destroy();if(e?.success){this.getApp()?.toast?.success("GeoIP record deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("geoip:deleted",{model:this.model})}}static async show(e){const t=await n.GeoLocatedIP.lookup(e);if(t){const e=new GeoIPView({model:t});return void(await a.Modal.detail(e))}a.Modal.alert({message:`Could not find geolocation data for IP: ${e}`,type:"warning"})}}GeoIPView.VIEW_CLASS=GeoIPView,n.GeoLocatedIP.VIEW_CLASS=GeoIPView,n.GeoLocatedIP.MODEL_REF="account.GeoLocatedIP",n.GeoLocatedIP.VIEW_CLASS=GeoIPView;class GeoLocatedIPTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_system_geoip",pageName:"GeoIP Cache",router:"admin/system/geoip",Collection:n.GeoLocatedIPList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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:"boolean"},{key:"is_vpn",label:"VPN",type:"boolean"},{key:"is_tor",label:"TOR",type:"boolean"}],searchPlaceholder:"Search IP, country, or ISP",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 n.GeoLocatedIP.lookup(e.ip);t&&await this.showItemDialog(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 &lt;token&gt;</code>\n </p>\n </div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("is_active"),i=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(i)}async onActionEditKey(){const e=this.getApp();await e.showModelForm({title:`Edit API Key — ${this.model.get("name")}`,model:this.model,formConfig:$.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=$.create,ApiKey.EDIT_FORM=$.edit;class ApiKeyTablePage extends n.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,searchPlaceholder:"Search name or group",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,i=await e.showForm({model:t,...$.create});if(!i)return;const s=await t.save(i);if(!s?.data?.status)return void e.showError(s?.data?.error||"Failed to create API key");const a=s.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 j=[{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 H(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"}),this.pageSubtitle="AWS CloudWatch resource monitoring"}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 <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h1 class="h3 mb-1">CloudWatch Monitoring</h1>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n </div>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all charts">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n <div class="cw-grid" id="cw-grid">\n ${j.map((e,t)=>`<div id="cw-chart-${t}"></div>`).join("")}\n </div>\n </div>\n `}async onInit(){this.charts=[];for(let e=0;e<j.length;e++){const t=j[e],i=new CloudWatchChart({containerId:`cw-chart-${e}`,account:t.account,category:t.category,title:t.title,height:160,yAxis:H(t.unit),responsive:!0,showGranularity:!0,showDateRange:!0,defaultDateRange:"24h",granularity:"hours"});this.addChild(i,{lazyMount:e>=4}),this.charts.push(i)}}async onEnter(){await super.onEnter(),this.scheduleRefresh(()=>this._refreshMounted(),3e5,{tier:"slow"})}async _refreshMounted(){await Promise.allSettled(this.charts.filter(e=>e&&(!e._lazyMount||e._lazyTriggered)&&"function"==typeof e.refresh).map(e=>e.refresh()))}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}}class StatusStripPanel extends t.View{constructor(e={}){super({...e,className:`sd-status-strip-panel ${e.className||""}`.trim()})}async getTemplate(){return'\n <div class="sd-section-head">\n <h2 class="sd-eyebrow">Pulse</h2>\n <span class="sd-section-meta text-muted small">Today · vs yesterday</span>\n </div>\n <div data-container="strip"></div>\n '}async onInit(){this.strip=new KPIStrip({containerId:"strip",account:"incident",granularity:"days",sparklineDays:7,tiles:[{rest:{endpoint:"/api/incident/incident",params:{status:"new",_mode:"count"}},sparklineSlug:"incidents",key:"new-incidents",label:"New Incidents",severity:"critical",tone:"bad"},{slug:"auth:failures",label:"Failed Auth",tone:"bad",severity:"warn"},{slug:"incidents",label:"Incidents",tone:"bad"},{slug:"incident_events",label:"Events",tone:"bad"},{slug:"firewall:blocks",label:"Firewall Blocks",tone:"bad"},{slug:"bouncer:blocks",label:"Bouncer Blocks",tone:"bad"},{slug:"login:new_country",label:"New-Country Logins",tone:"bad"},{rest:{endpoint:"/api/system/geoip",params:{is_blocked:!0,_mode:"count"}},key:"active-blocks",label:"Active Blocks",tone:"bad"}]}),this.strip.on?.("tile:click",e=>this._onTileClick(e)),this.addChild(this.strip)}refresh(){return this.strip?.refresh()}_onTileClick({slug:e,key:t}){"new-incidents"!==t?"active-blocks"!==t?e&&this._openHistoryDrawer(e):this.getApp()?.showPage?.("system/system/geoip",{is_blocked:"true"}):this.getApp()?.showPage?.("system/incidents",{status:"new"})}_openHistoryDrawer(e){const t=new i.MetricsChart({slugs:[e],account:"incident",granularity:"days",defaultDateRange:"30d",chartType:"line",compactHeader:!0,showGranularity:!1,showTypeSwitch:!1,showDateRange:!1,showLegend:!1,height:280});a.Modal.drawer({eyebrow:"Metric History",title:this._humanizeSlug(e),meta:[{icon:"bi bi-calendar3",text:"Last 30 days"},{icon:"bi bi-bar-chart-line",text:"Daily buckets"}],view:t,size:"lg"})}_humanizeSlug(e){return String(e).split(/[:_]/).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(" ")}}class EventListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item event-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled event\')}}</div>\n <div class="ili-eyebrow">{{model.created|relative}}</div>\n </div>\n <div class="ili-row ili-meta">\n {{#hasCategory|bool}}<span class="ili-chip ili-chip-cat">{{model.category}}</span>{{/hasCategory|bool}}\n {{#hasSource|bool}}<span class="ili-meta-text">{{source}}</span>{{/hasSource|bool}}\n {{#hasScope|bool}}<span class="ili-meta-dim">{{model.scope}}</span>{{/hasScope|bool}}\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get source(){return this.model?.get("hostname")||this.model?.get("source_ip")||""}get hasSource(){return!!this.source}get hasCategory(){return!!this.model?.get("category")}get hasScope(){const e=this.model?.get("scope");return!!e&&e!==this.model?.get("category")}}const U={new:"info",open:"success",in_progress:"warning",pending:"warning",paused:"warning",qa:"info",resolved:"muted",closed:"muted",ignored:"muted"},z={security:"danger",incident:"danger",bug:"warning",qa:"warning",feature:"primary",ticket:"primary",fulfillment:"success",new_user:"muted",new_group:"muted"};class TicketListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item ticket-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled ticket\')}}</div>\n {{#hasStatus|bool}}<span class="ili-chip ili-chip-{{statusTone}}">{{model.status}}</span>{{/hasStatus|bool}}\n </div>\n <div class="ili-row ili-meta">\n {{#hasPriority|bool}}<span class="ili-pri ili-pri-{{priorityTone}}">{{model.priority}}</span>{{/hasPriority|bool}}\n {{#hasCategory|bool}}<span class="ili-meta-text"><span class="ili-dot ili-dot-{{categoryTone}}"></span>{{categoryLabel}}</span>{{/hasCategory|bool}}\n <span>{{model.created|relative}}</span>\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get hasStatus(){return!!this.model?.get("status")}get statusTone(){return U[this.model?.get("status")]||"muted"}get _priority(){return parseInt(this.model?.get("priority"),10)}get hasPriority(){return Number.isFinite(this._priority)}get priorityTone(){const e=this._priority;return Number.isFinite(e)?e>=8?"hi":e>=5?"md":"lo":"lo"}get hasCategory(){return!!this.model?.get("category")}get categoryTone(){return z[this.model?.get("category")]||"muted"}get categoryLabel(){return String(this.model?.get("category")||"").replace(/_/g," ")}}const q={new:"info",open:"success",paused:"warning",qa:"info",resolved:"muted",ignored:"muted"};class IncidentListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item incident-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled incident\')}}</div>\n {{#hasStatus|bool}}<span class="ili-chip ili-chip-{{statusTone}}">{{model.status}}</span>{{/hasStatus|bool}}\n </div>\n <div class="ili-row ili-meta">\n {{#hasPriority|bool}}<span class="ili-pri ili-pri-{{priorityTone}}">{{model.priority}}</span>{{/hasPriority|bool}}\n {{#hasMetaLine|bool}}<span class="ili-meta-text">{{metaLine}}</span>{{/hasMetaLine|bool}}\n <span>{{model.created|relative}}</span>\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get hasStatus(){return!!this.model?.get("status")}get statusTone(){return q[this.model?.get("status")]||"muted"}get _priority(){return parseInt(this.model?.get("priority"),10)}get hasPriority(){return Number.isFinite(this._priority)}get priorityTone(){const e=this._priority;return Number.isFinite(e)?e>=8?"hi":e>=5?"md":"lo":"lo"}get metaLine(){const e=this.model?.get("scope"),t=this.model?.get("category"),i=[e,t].filter(e=>e&&""!==e);return[...new Set(i)].join(" · ")}get hasMetaLine(){return!!this.metaLine}}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 i="";return t.forEach((e,t)=>{if(!e.trim())return void(i+='<div class="stack-trace-line">&nbsp;</div>');if(0===t&&(e.includes("Error:")||e.includes("Exception:")))return void(i+=`<div class="stack-trace-line stack-trace-error">${this.escapeHtml(e)}</div>`);let s=e.match(/(.+?)\s*\(([^:]+):(\d+):(\d+)\)/);if(s){const[,e,t,a,n]=s;return void(i+=`<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(s=e.match(/^\s*at\s+([^:]+):(\d+):(\d+)/),s){const[,e,t,a]=s;return void(i+=`<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(s=e.match(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)/),s){const[,e,t,a]=s;return void(i+=`<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 ")?i+=`<div class="stack-trace-line stack-trace-file">${this.escapeHtml(e)}</div>`:i+=`<div class="stack-trace-line stack-trace-context">${this.escapeHtml(e)}</div>`}),i}updateStackTrace(e){this.stackTrace=e,this.render()}}const G=[{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 ${G.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,i]=e;this.selectedType=t,this.fieldValues={};const s=G.find(e=>e.value===t);if(s)if(i.startsWith("?")){const e=new URLSearchParams(i);for(const t of s.fields){const i=e.get(t.name);null!==i&&(this.fieldValues[t.name]=i)}}else 1===s.fields.length&&(this.fieldValues[s.fields[0].name]=i)}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 i=G.find(e=>e.value===this.selectedType);if(i)for(const s of i.fields)void 0!==s.default&&(this.fieldValues[s.name]=s.default);return this._renderFields(),this._updatePreview(),this._emitChange(),!0}_renderFields(){const e=this.element?.querySelector("#hb-fields");if(!e)return;const t=G.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 i=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">${i}</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,i=t.dataset.field;return i&&(this.fieldValues[i]=t.value,this._updatePreview(),this._emitChange()),!0}_updatePreview(){const e=this.element?.querySelector("#hb-preview");if(!e)return;const t=G.find(e=>e.value===this.selectedType);if(!t)return void(e.style.display="none");const i=t.build(this.fieldValues),s=t.preview(this.fieldValues);e.style.display="block",e.innerHTML=`\n <div class="hb-desc"><i class="bi ${t.icon} me-1"></i>${s}</div>\n <div class="hb-raw"><code>${i}</code></div>\n `}_emitChange(){const e=this.getValue();this.emit("change",e)}getValue(){const e=G.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()}}}const J={block:{label:"Block IP",icon:"bi-slash-circle",tone:"danger"},email:{label:"Email",icon:"bi-envelope",tone:"info"},sms:{label:"SMS",icon:"bi-chat-dots",tone:"info"},notify:{label:"Push notification",icon:"bi-bell",tone:"info"},ticket:{label:"Create ticket",icon:"bi-ticket-detailed",tone:"warning"},job:{label:"Run job",icon:"bi-gear-wide-connected",tone:"primary"},llm:{label:"LLM triage",icon:"bi-stars",tone:"primary"}};function W(e){return e&&"string"==typeof e?e.split(",").map(e=>e.trim()).filter(Boolean).map(e=>{const t=e.match(/^([a-zA-Z]+):\/\/(.*)$/),i=t?t[1].toLowerCase():null,s=i&&J[i]?J[i]:{label:e,icon:"bi-question-circle",tone:"default"};return{raw:e,scheme:i,label:s.label,icon:s.icon,tone:s.tone,detail:t?t[2]:""}}):[]}function K(e){if(!e)return"";const t=Math.floor(Date.now()/1e3)-Number(e);return t<60?"just now":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)}h ago`:`${Math.floor(t/86400)}d ago`}function Y(e){const t=c.BundleByOptions.find(t=>t.value===e);return t?t.label:0===e||void 0===e?"No bundling":String(e)}function Q(e){const t=c.BundleMinutesOptions.find(t=>t.value===e);return t?t.label:null==e?"No limit — bundle forever":`${e} minutes`}class RuleSetOverviewSection extends t.View{constructor(e={}){super({className:"ruleset-overview-section",template:'\n <div class="ruleset-kpi-grid mb-2">\n <div data-container="kpi-status"></div>\n <div data-container="kpi-incidents"></div>\n <div data-container="kpi-last-fired"></div>\n <div data-container="kpi-match"></div>\n </div>\n <div class="ruleset-overview-pair">\n <div class="card ruleset-overview-card">\n <h6><i class="bi bi-funnel me-1"></i>What triggers this rule</h6>\n <ul>\n <li>Event category is <code>{{model.category}}</code></li>\n <li>{{bundleSummary}}</li>\n <li>{{thresholdSummary}}</li>\n <li>{{retriggerSummary}}</li>\n </ul>\n </div>\n <div class="card ruleset-overview-card">\n <h6><i class="bi bi-tools me-1"></i>What happens when it fires</h6>\n <ul id="overview-handler-summary">\x3c!-- filled in onInit --\x3e</ul>\n </div>\n </div>\n ',...e}),this.incidentsCollection=e.incidentsCollection||null}async onInit(){const e=this.model,t=!!e.get("is_active"),i=e.get("trigger_count"),s=e.get("trigger_window"),a=e.get("retrigger_every"),o=e.get("bundle_by"),l=e.get("bundle_minutes");this.bundleSummary=0===o||void 0===o?"Each event creates its own incident (no bundling)":`Bundles by ${Y(o).replace(/^By\s+/i,"").toLowerCase()} for ${Q(l).toLowerCase()}`,this.thresholdSummary=null==i||0===i?"Fires immediately on the first event":null==s?`Fires after ${i} events accumulate`:`Fires after ${i} events within ${s} minutes`,this.retriggerSummary=null==a?"Fires once per bundle (no re-trigger)":`Re-fires every ${a} additional events`;const r=this._readIncidentCount(),c=this._readLastFired();this.kpiStatus=new n.MetricCard({containerId:"kpi-status",label:"Status",value:t?"Active":"Inactive",valueIcon:t?"bi-check-circle-fill":"bi-pause-circle-fill",tone:t?"success":"default"}),this.kpiIncidents=new n.MetricCard({containerId:"kpi-incidents",label:"Incidents (30d)",value:null==r?"—":r,tone:r>0?"warning":"default",action:"view-incidents"}),this.kpiLastFired=new n.MetricCard({containerId:"kpi-last-fired",label:"Last fired",value:c?K(c):"Never"}),this.kpiMatch=new n.MetricCard({containerId:"kpi-match",label:"Match logic",value:this._shortMatchLabel(e.get("match_by"))}),this.addChild(this.kpiStatus),this.addChild(this.kpiIncidents),this.addChild(this.kpiLastFired),this.addChild(this.kpiMatch),this.incidentsCollection&&this.incidentsCollection.on("fetch:success",()=>this._refreshFromCollection(),this)}_shortMatchLabel(e){return 1===e?"ANY rule matches":"ALL rules match"}_readIncidentCount(){return this.incidentsCollection?this.incidentsCollection.totalCount??this.incidentsCollection.models?.length??null:null}_readLastFired(){const e=this.incidentsCollection?.models?.[0];return e?.get?.("created")??null}_refreshFromCollection(){const e=this._readIncidentCount(),t=this._readLastFired();this.kpiIncidents&&this.kpiIncidents.setValue(null==e?"—":e),this.kpiLastFired&&this.kpiLastFired.setValue(t?K(t):"Never")}async onAfterRender(){await super.onAfterRender();const e=this.element?.querySelector("#overview-handler-summary");if(!e)return;const t=W(this.model.get("handler"));if(!t.length)return void(e.innerHTML="<li>No handler configured — incidents are recorded but no action is taken</li>");const i=t.some(e=>"llm"===e.scheme),s=t.map(e=>`<li><i class="bi ${e.icon} me-1 text-body-secondary"></i>${this.escapeHtml(e.label)}${e.detail?` <code class="small">${this.escapeHtml(e.detail)}</code>`:""}</li>`);i||s.push('<li class="text-body-secondary"><i class="bi bi-stars me-1"></i>No LLM triage configured — <a href="#" data-action="go-agent">add an agent prompt</a></li>'),e.innerHTML=s.join("")}async onActionGoAgent(e){e.preventDefault(),this.emit("navigate","Agent")}async onActionViewIncidents(e){e?.preventDefault?.(),this.emit("navigate","Incidents")}}class RuleSetConditionsSection extends t.View{constructor(e={}){super({className:"ruleset-conditions-section",template:'<div data-container="conditions-table"></div>',...e}),this.rulesetId=e.rulesetId,this.collection=e.collection}async onInit(){this.tableView=new l.TableView({containerId:"conditions-table",collection:this.collection,title:"Conditions",eyebrow:this._buildEyebrowLabel(),showFullscreen:!1,searchable:!1,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["parent"],columns:[{key:"id",label:"ID",width:"60px",template:'<span class="text-body-tertiary font-monospace">{{model.id}}</span>'},{key:"name",label:"Name"},{key:"field_name",label:"Field",template:"<code>{{model.field_name}}</code>"}],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.rulesetId}}),this.addChild(this.tableView),this.collection.on("fetch:success",()=>this._updateCount(),this),this.collection.models?.length&&this._updateCount()}_buildEyebrowLabel(){const e=this.collection?.models?.length??0,t=this.parent?.model?.get("match_by")??this.model?.get?.("match_by");return 0===e?"Loading conditions…":`${e} ${1===e?"condition":"conditions"} · ${1===t?"ANY must match":"ALL must match"}`}_updateCount(){this.tableView?.setEyebrow(this._buildEyebrowLabel()),this.emit("count:changed",this.collection?.models?.length??0)}}class RuleSetTriggeringSection extends t.View{constructor(e={}){super({className:"ruleset-triggering-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model,t=e.get("match_by"),i=e.get("bundle_by"),s=e.get("bundle_minutes"),a=e.get("trigger_count"),n=e.get("trigger_window"),o=e.get("retrigger_every"),l=function(e){const t=c.MatchByOptions.find(t=>t.value===e);return t?t.label:String(e)}(t),r=0===i||void 0===i?"No bundling":Y(i).replace(/^By\s+/i,""),d=0===i?"Each event becomes its own incident.":`Group events from the same source over <strong>${this.escapeHtml(Q(s).toLowerCase())}</strong> into one incident.`,h=null==a||0===a,u=h?'<span class="rs-flow-empty">Fires immediately</span>':`After <strong>${this.escapeHtml(String(a))}</strong> events`,m=h?"Handler chain runs as soon as the first matching event arrives.":null==n?"All events counted (no time window). Until the threshold, the incident stays in <code>pending</code>.":`Counted within <strong>${this.escapeHtml(String(n))}</strong> minutes.`,p=null==o,b=p?'<span class="rs-flow-empty">Fire once only</span>':`Every <strong>${this.escapeHtml(String(o))}</strong> events`,g=p?"Handler runs once when the threshold is first crossed; subsequent events are appended silently.":"Re-fires the handler chain after every N additional events.";return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">Match → Bundle → Threshold → Re-trigger</div>\n <h5 class="mb-0">Triggering</h5>\n </div>\n <button class="btn btn-outline-secondary btn-sm" data-action="edit-all"><i class="bi bi-pencil"></i> Edit all</button>\n </div>\n <div class="rs-flow">\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 1</div>\n <div class="rs-flow-title">Match <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="general"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${this.escapeHtml(l)}</div>\n <div class="rs-flow-hint">Each condition under "Conditions" must match the event for the rule to apply.</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 2</div>\n <div class="rs-flow-title">Bundle <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="bundling"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${this.escapeHtml(r)}</div>\n <div class="rs-flow-hint">${d}</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 3</div>\n <div class="rs-flow-title">Threshold <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="thresholds"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${u}</div>\n <div class="rs-flow-hint">${m}</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 4</div>\n <div class="rs-flow-title">Re-trigger <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="thresholds"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${b}</div>\n <div class="rs-flow-hint">${g}</div>\n </div>\n </div>\n <div class="alert alert-secondary small mb-0">\n <i class="bi bi-info-circle me-1 text-primary"></i>\n Events accumulate in <code>pending</code> status. Once the trigger count is crossed, the\n <a href="#" data-action="go-handler">handler chain</a> fires and the incident becomes <code>new</code>.\n Leave Threshold empty to fire on the first event.\n </div>\n `}async onActionEditStep(e,t){const i=t?.dataset?.tab||"general";this.emit("action:edit-step",i)}async onActionEditAll(){this.emit("action:edit-ruleset")}async onActionGoHandler(e){e.preventDefault(),this.emit("navigate","Handler")}}class RuleSetHandlerChainSection extends t.View{constructor(e={}){super({className:"ruleset-handler-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=W(this.model.get("handler")),t=0===e.length;return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">${t?"0 handlers in chain":1===e.length?"1 handler in chain":`${e.length} handlers in chain`}</div>\n <h5 class="mb-0">Handler</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="edit-chain"><i class="bi bi-tools me-1"></i>Edit chain</button>\n </div>\n ${t?'\n <div class="text-center text-body-secondary py-4 border rounded">\n <i class="bi bi-tools fs-1 d-block mb-2"></i>\n <p class="mb-3">No handler configured. Incidents are recorded but no action runs.</p>\n <button class="btn btn-primary" data-action="edit-chain"><i class="bi bi-plus-lg me-1"></i>Configure handler chain</button>\n </div>\n ':`\n <div class="rs-chain">\n ${e.map(e=>`\n <div class="rs-chain-step tone-${e.tone}">\n <div class="rs-chain-icon"><i class="bi ${e.icon}"></i></div>\n <div style="min-width: 0;">\n <div class="rs-chain-label">${this.escapeHtml(e.label)}</div>\n ${e.detail?`<div class="rs-chain-detail">${this.escapeHtml(e.detail)}</div>`:""}\n </div>\n </div>\n `).join("")}\n </div>\n <div class="rs-chain-raw">{{model.handler}}</div>\n `}\n <div class="alert alert-secondary small mt-3 mb-0">\n <strong>Tip:</strong>\n Chain handlers with commas — e.g. <code>block://?ttl=86400, ticket://?priority=8, llm://</code>.\n Add <code>llm://</code> to use the prompt configured in <a href="#" data-action="go-agent">Agent Prompt</a>.\n </div>\n `}async onActionEditChain(){this.emit("action:edit-handler")}async onActionGoAgent(e){e.preventDefault(),this.emit("navigate","Agent")}}class RuleSetAgentPromptSection extends t.View{constructor(e={}){super({className:"ruleset-agent-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model.get("metadata")?.agent_prompt||"",t=W(this.model.get("handler")).some(e=>"llm"===e.scheme),i=e.length;return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">metadata.agent_prompt</div>\n <h5 class="mb-0">Agent Prompt</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="save-prompt" id="rs-prompt-save">\n <i class="bi bi-save me-1"></i>Save prompt\n </button>\n </div>\n ${t?'\n <div class="alert alert-info small d-flex align-items-center mb-3">\n <i class="bi bi-stars me-2"></i>\n <span>Used by the <code>llm://</code> handler in your chain when triaging incidents.</span>\n </div>\n ':'\n <div class="alert alert-warning small d-flex align-items-center mb-3">\n <i class="bi bi-exclamation-triangle me-2"></i>\n <span>Add <code>llm://</code> to your <a href="#" data-action="go-handler">handler chain</a> to use this prompt — it\'s saved either way.</span>\n </div>\n '}\n <textarea class="rs-prompt" data-action="prompt-input" data-action-debounce="200" spellcheck="false" placeholder="You are a security analyst triaging…">${this.escapeHtml(e)}</textarea>\n <div class="d-flex justify-content-between align-items-center mt-2">\n <small class="text-body-secondary">\n <i class="bi bi-info-circle me-1"></i>\n The LLM handler receives this prompt plus a structured incident summary on every fire.\n </small>\n <small class="text-body-secondary font-monospace" id="rs-prompt-counter">${i} chars</small>\n </div>\n `}async onAfterRender(){await super.onAfterRender(),this._lastSaved=this.model.get("metadata")?.agent_prompt||"",this._currentValue=this._lastSaved}async onActionPromptInput(e,t){this._currentValue=t.value;const i=this.element?.querySelector("#rs-prompt-counter");i&&(i.textContent=`${this._currentValue.length} chars`)}async onActionGoHandler(e){e.preventDefault(),this.emit("navigate","Handler")}async onActionSavePrompt(e,t){const i=this._currentValue??"";t.disabled=!0;try{const e=await this.model.save({"metadata.agent_prompt":i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this._lastSaved=i;const t={...this.model.get("metadata")||{},agent_prompt:i};this.model.set("metadata",t),this.getApp()?.toast?.success("Agent prompt saved")}catch(s){this.getApp()?.toast?.error(`Failed to save: ${s.message}`)}finally{t.disabled=!1}}async refresh(){this.isMounted()&&await this.render()}focusTextarea(){const e=this.element?.querySelector(".rs-prompt");e&&e.focus()}}class RuleSetIncidentsSection extends t.View{constructor(e={}){super({className:"ruleset-incidents-section",template:'<div data-container="incidents-table"></div>',...e}),this.rulesetId=e.rulesetId,this.collection=e.collection,this.rangeValue=e.range||"30d"}async onInit(){this.tableView=new l.TableView({containerId:"incidents-table",collection:this.collection,title:"Incidents",eyebrow:this._buildEyebrowLabel(),showFullscreen:!1,showRefresh:!0,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["rule_set","created__gte"],columns:[{key:"created",label:"Created",sortable:!0,width:"140px",template:'<div class="font-monospace small">{{{model.created|epoch|datetime}}}</div><div class="text-body-secondary small">{{{model.created|epoch|relative}}}</div>'},{key:"status",label:"Status",width:"90px",formatter:"badge"},{key:"priority",label:"Pri",width:"60px",template:"{{{priorityPill}}}",formatter:e=>function(e){const t=parseInt(e,10);if(isNaN(t))return'<span class="text-body-tertiary">—</span>';let i,s;return t>=8?(i="bg-danger",s="text-danger-emphasis"):t>=5?(i="bg-warning",s="text-warning-emphasis"):(i="bg-secondary",s="text-secondary-emphasis"),`<span class="badge ${i} ${s} font-monospace">${t}</span>`}(e)},{key:"title",label:"Title",class:"rs-incident-title",formatter:"default('—')"}],showAdd:!1,paginated:!0,size:10,searchable:!1,filterable:!1,dayRangeFilter:{value:this.rangeValue}}),this.tableView.on("range:change",({value:e})=>{this.rangeValue=e,this._updateEyebrow()}),this.addChild(this.tableView),this.collection.on("fetch:success",()=>this._updateEyebrow(),this)}_buildEyebrowLabel(){const e=this.collection?.totalCount??this.collection?.models?.length??0,t="1d"===this.rangeValue?1:"7d"===this.rangeValue?7:"90d"===this.rangeValue?90:30;return`${e} ${1===e?"incident":"incidents"} in last ${t} ${1===t?"day":"days"}`}_updateEyebrow(){this.tableView?.setEyebrow(this._buildEyebrowLabel())}}class RuleSetMetadataSection extends t.View{constructor(e={}){super({className:"ruleset-metadata-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model.get("metadata")||{},t=0===Object.keys(e).length,i=["reasoning","assistant_proposed","delete_on_resolution","agent_prompt"].some(t=>void 0!==e[t]&&null!==e[t]&&""!==e[t]);return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">${t?"No metadata yet":"Every key on ruleset.metadata"}</div>\n <h5 class="mb-0">Metadata</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="edit-metadata">\n <i class="bi bi-pencil me-1"></i>${t?"Add metadata":"Edit JSON"}\n </button>\n </div>\n ${t?'\n <div class="text-center text-body-secondary py-4 border rounded">\n <i class="bi bi-braces fs-1 d-block mb-2"></i>\n <p class="mb-3 small">No metadata is set on this RuleSet. Use metadata for arbitrary configuration the framework doesn\'t know about — runbook URLs, on-call rotations, custom flags, etc.</p>\n <button class="btn btn-primary btn-sm" data-action="edit-metadata">\n <i class="bi bi-plus-lg me-1"></i>Add metadata\n </button>\n </div>\n ':`\n ${i?'\n <h6 class="text-body-secondary small text-uppercase mt-2 mb-2" style="letter-spacing: 0.06em;">Known fields</h6>\n <div data-container="metadata-known"></div>\n ':""}\n <h6 class="text-body-secondary small text-uppercase mt-3 mb-2" style="letter-spacing: 0.06em;">Raw JSON</h6>\n <pre class="bg-body-tertiary border rounded p-3 small mb-0" style="white-space: pre-wrap; word-break: break-word;"><code>{{model.metadata|json}}</code></pre>\n `}\n `}async onInit(){await this._buildKnownView()}async _buildKnownView(){const e=this.model.get("metadata")||{};if(!["reasoning","assistant_proposed","delete_on_resolution","agent_prompt"].some(t=>void 0!==e[t]&&null!==e[t]&&""!==e[t]))return;const t="string"==typeof e.agent_prompt?e.agent_prompt.length:0,i={get:t=>e[t],attributes:e,on(){},off(){}},s=[];void 0!==e.reasoning&&null!==e.reasoning&&""!==e.reasoning&&s.push({name:"reasoning",label:"Reasoning",cols:12}),void 0!==e.assistant_proposed&&s.push({name:"assistant_proposed",label:"Assistant Proposed",formatter:"yesnoicon",cols:6}),void 0!==e.delete_on_resolution&&s.push({name:"delete_on_resolution",label:"Delete on Resolution",formatter:"yesnoicon",cols:6}),void 0!==e.agent_prompt&&null!==e.agent_prompt&&s.push({name:"agent_prompt",label:"Agent Prompt",template:e.agent_prompt?`<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Configured · ${t} chars</span>`:'<span class="badge text-bg-secondary">Not configured</span>',cols:6}),this.knownView=new d.default({containerId:"metadata-known",model:i,columns:2,showEmptyValues:!1,fields:s}),this.addChild(this.knownView)}}class RuleSetView extends n.DetailView{constructor(e={}){const t=e.model||new c.RuleSet(e.data||{}),i=t.get("id"),s=new c.IncidentList({params:{rule_set:i,created__gte:Math.floor(Date.now()/1e3)-2592e3,sort:"-created"}}),a=new c.RuleList({params:{parent:i}}),n=new RuleSetOverviewSection({model:t,incidentsCollection:s}),o=new RuleSetConditionsSection({model:t,rulesetId:i,collection:a}),l=new RuleSetTriggeringSection({model:t}),r=new RuleSetHandlerChainSection({model:t}),d=new RuleSetAgentPromptSection({model:t}),h=new RuleSetIncidentsSection({model:t,rulesetId:i,collection:s}),u=new RuleSetMetadataSection({model:t}),m=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:n},{key:"Conditions",label:"Conditions",icon:"bi-funnel",view:o},{key:"Triggering",label:"Triggering",icon:"bi-stopwatch",view:l},{key:"Handler",label:"Handler",icon:"bi-tools",view:r},{key:"Agent",label:"Agent Prompt",icon:"bi-stars",view:d},{type:"divider",label:"Activity"},{key:"Incidents",label:"Incidents",icon:"bi-shield-exclamation",view:h},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:u}];super({className:"ruleset-view",...e,model:t,header:{icon:"bi-gear-wide-connected",titleField:"name",subtitlePath:"metadata.reasoning",subtitlePlaceholder:"No reasoning provided — click to add one",subtitleEditAction:"edit-header",chips:[{icon:"bi-tag-fill",textPath:"category",variant:"primary"},{icon:"bi-flag",text:e=>`Priority ${e.get("priority")}`,variant:"secondary"},{icon:"bi-hash",text:e=>`ID ${e.get("id")}`,variant:"light"},{icon:"bi-stars",text:"AI-proposed",variant:"warning",when:e=>e.get("metadata")?.assistant_proposed},{icon:"bi-clock-history",text:e=>{const t=e.get("modified");return t?`Modified ${K(t)}`:null},variant:"light"}],activeField:"is_active",actions:[{label:"Edit",icon:"bi-pencil",action:"edit-header",title:"Edit details"}],contextMenu:{items:[{label:"Edit RuleSet",action:"edit-ruleset",icon:"bi-pencil"},{label:"Edit Handler Chain",action:"edit-handler",icon:"bi-tools"},{label:"Edit Agent Prompt",action:"edit-agent-prompt",icon:"bi-stars"},{type:"divider"},{label:"View Incidents",action:"view-incidents",icon:"bi-shield-exclamation"},{type:"divider"},{label:"Delete RuleSet",action:"delete-ruleset",icon:"bi-trash",danger:!0}]}},sections:m,activeSection:"Overview"}),this.incidentsCollection=s,this.conditionsCollection=a,this.overviewSection=n,this.conditionsSection=o,this.triggeringSection=l,this.handlerSection=r,this.agentSection=d,this.incidentsSection=h,this.metadataSection=u,this.incidentsCollection.fetch().catch(()=>{}),this.conditionsCollection.fetch().catch(()=>{})}async onAfterBuild(){const e=e=>this.showSection(e);this.overviewSection.on("navigate",e),this.triggeringSection.on("navigate",e),this.handlerSection.on("navigate",e),this.agentSection.on("navigate",e),this.triggeringSection.on("action:edit-step",e=>this.onActionEditTriggeringStep(e)),this.triggeringSection.on("action:edit-ruleset",()=>this.onActionEditRuleset()),this.handlerSection.on("action:edit-handler",()=>this.onActionEditHandler());const t=()=>{const e=this.conditionsCollection.models?.length??0;this.setBadge("Conditions",e>0?{text:String(e),variant:"muted"}:null)};this.conditionsCollection.on("fetch:success",t,this),this.conditionsCollection.models?.length&&t();const i=()=>{const e=this.incidentsCollection.totalCount??this.incidentsCollection.models?.length??0;this.setBadge("Incidents",e>0?{text:String(e),variant:e>10?"warning":"muted"}:null)};this.incidentsCollection.on("fetch:success",i,this),this.incidentsCollection.models?.length&&i()}async onActionEditHeader(){await a.Modal.modelForm({title:"Edit RuleSet details",model:this.model,size:"md",formConfig:{fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"category",type:"combo",label:"Scope / Category",options:c.CommonCategoryOptions,allowCustom:!0,required:!0,columns:8},{name:"priority",type:"number",label:"Priority",required:!0,columns:4},{name:"metadata.reasoning",type:"textarea",label:"Reasoning",rows:4,columns:12,tooltip:"Why this rule exists — shown as the header subtitle."}]}})&&await this._fullRefresh()}async onActionEditTriggeringStep(e){const t=this._triggeringMiniForm(e);t&&await a.Modal.modelForm({title:t.title,model:this.model,size:"md",formConfig:{fields:t.fields}})&&await this._fullRefresh()}_triggeringMiniForm(e){switch(e){case"general":case"match":return{title:"Edit match logic",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">Controls how multiple <strong>conditions</strong> combine when evaluating an event.</p>'},{name:"match_by",type:"select",label:"Match Logic",options:c.MatchByOptions,columns:12,tooltip:"ALL = every condition must match. ANY = at least one"}]};case"bundling":case"bundle":return{title:"Edit bundling",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">How matched events are grouped into a single incident.</p>'},{name:"bundle_by",type:"select",label:"Bundle By",options:c.BundleByOptions,columns:6,tooltip:"How to group related events into one incident"},{name:"bundle_minutes",type:"select",label:"Bundle Window",options:c.BundleMinutesOptions,columns:6,tooltip:"Events outside this window create a new incident"},{name:"bundle_by_rule_set",type:"switch",label:"Bundle by RuleSet",columns:12,tooltip:"Group events matched by this rule into the same incident"}]};case"thresholds":case"threshold":return{title:"Edit threshold",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">Events accumulate in <code>pending</code> until this threshold is reached. Leave empty to fire on the first event.</p>'},{name:"trigger_count",type:"number",label:"Trigger Count",placeholder:"Empty = fire immediately",columns:6,tooltip:"Number of events before the handler fires"},{name:"trigger_window",type:"number",label:"Trigger Window (min)",placeholder:"Empty = no time limit",columns:6,tooltip:"Only count events within this many minutes"}]};case"retrigger":return{title:"Edit re-trigger",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">After the threshold is crossed, re-fire the handler chain every N additional events.</p>'},{name:"retrigger_every",type:"number",label:"Re-trigger Every",placeholder:"Empty = fire once only",columns:12,tooltip:"Re-fire handler every N additional events after initial trigger"}]};default:return null}}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. Known keys are also editable from their own sections (Reasoning from header Edit, Agent Prompt from its tab) — use this for anything else.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }',tooltip:"Must be a valid JSON object"}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object (e.g. `{}`), not an array or scalar.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{const e=await this.model.save({metadata:s});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._fullRefresh(),this.metadataSection?.isMounted()&&await this.metadataSection.render()}catch(n){this.getApp()?.toast?.error(`Failed to save metadata: ${n.message}`)}}async onActionEditRuleset(){await a.Modal.modelForm({title:`Edit RuleSet — ${this.model.get("name")}`,model:this.model,formConfig:c.RuleSet.EDIT_FORM})&&await this._fullRefresh()}async onActionEditHandler(){const e=new HandlerBuilderView({value:this.model.get("handler")||""});if("save"===await a.Modal.dialog({title:"Configure Handler Chain",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();if(!t)return;const i=await this.model.save({handler:t});if(i&&i.status&&i.status>=400)return void this.getApp()?.toast?.error("Failed to update handler");this.getApp()?.toast?.success("Handler updated"),await this._fullRefresh()}}async onActionEditAgentPrompt(){await this.showSection("Agent"),this.agentSection?.focusTextarea()}async onActionViewIncidents(){await this.showSection("Incidents")}async onActionDeleteRuleset(){if(await a.Modal.confirm({title:"Delete RuleSet",message:`Are you sure you want to delete "${this.model.get("name")}"? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("RuleSet deleted");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: ${e.message}`)}}async _fullRefresh(){await this.headerView.render(),await this.triggeringSection.render(),await this.handlerSection.render(),await this.agentSection.refresh(),this.overviewSection.isMounted()&&await this.overviewSection.render()}}RuleSetView.VIEW_CLASS=RuleSetView,c.RuleSet.MODEL_REF="incident.RuleSet";class IncidentHistoryAdapter{constructor(e){this.incidentId=e,this.collection=new c.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 c.IncidentHistory,i=await t.save({parent:this.incidentId,note:e.text,kind:"comment",media:e.files&&e.files.length>0?e.files[0].id:null});return i.success&&await this.collection.fetch(),i}async _renderMarkdown(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}}class AssistantMessageView extends n.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 i=0;i<this.message.blocks.length;i++){const s=this.message.blocks[i],a=document.createElement("div");a.className="assistant-block mb-3",e.appendChild(a);try{"table"===s.type?await this._renderTableBlock(s,a):"chart"===s.type?await this._renderChartBlock(s,a):"stat"===s.type?this._renderStatBlock(s,a):"action"===s.type?this._renderActionBlock(s,a):"list"===s.type?this._renderListBlock(s,a):"alert"===s.type?this._renderAlertBlock(s,a):"progress"===s.type?this._renderProgressBlock(s,a):"file"===s.type?this._renderFileBlock(s,a):"context"===s.type&&this._renderContextBlock(s,a)}catch(t){console.error("Failed to render block:",s.type,t);const e=document.createElement("div");e.className="alert alert-warning small",e.textContent=`Failed to render ${s.type} block`,a.appendChild(e)}}}_createCollapsibleCard(e,{icon:t,title:i,subtitle:s}){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(i||"Data")}</span>\n ${s?`<span class="assistant-block-toggle-subtitle">${n(s)}</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,i){const s=(e.rows||[]).length,a=(e.columns||[]).length,{body:n}=this._createCollapsibleCard(i,{icon:"bi-table",title:e.title||"Table",subtitle:`${s} rows · ${a} columns`}),o=(e.columns||[]).map(e=>"string"==typeof e?{key:e,label:e}:e),r=o.map(e=>e.key),c=(e.rows||[]).map((e,i)=>{const s={id:i};return r.forEach((t,i)=>{s[t]=void 0!==e[i]?e[i]:""}),new t.Model(s)}),d=new t.Collection({preloaded:!0});d.add(c);const h=new l.TableView({collection:d,columns:o,paginated:!1,sortable:!1,searchable:!1,filterable:!1,showRefresh:!1,showAdd:!1});this._blockViews.push(h),this.addChild(h),n.appendChild(h.element),h.render(!1)}async _renderChartBlock(e,t){const s=e.chart_type||"line",a=(e.series||[]).length,n=e.labels?.length||0,o="pie"===s,{body:l,onShow:r}=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:o?`${n} segments`:`${a} series · ${n} points`}),c=document.createElement("div");c.className="assistant-chart-body",l.appendChild(c);const d={stacked:"stacked",grouped:"grouped",crosshair_tracking:"crosshairTracking",colors:"colors",show_legend:"showLegend",legend_position:"legendPosition"},h={cutout:"cutout",show_labels:"showLabels",show_percentages:"showPercentages",colors:"colors",legend_position:"legendPosition"},u=t=>Object.entries(t).reduce((t,[i,s])=>(void 0!==e[i]&&(t[s]=e[i]),t),{}),m={labels:e.labels||[],datasets:(e.series||[]).map(e=>{const t={label:e.name,data:e.values};return void 0!==e.color&&(t.color=e.color),void 0!==e.fill&&(t.fill=e.fill),void 0!==e.smoothing&&(t.smoothing=e.smoothing),t})};if(o){const e=new i.PieChart({width:180,height:180,legendPosition:"right",...u(h),data:m});this._blockViews.push(e),this.addChild(e),c.appendChild(e.element),e.render(!1)}else{const e=new i.SeriesChart({chartType:"area"===s?"line":s,fill:"area"===s,height:200,legendPosition:"top",...u(d),data:m});this._blockViews.push(e),this.addChild(e),c.appendChild(e.element),e.render(!1)}}_renderStatBlock(e,t){const i=e.items||[],s=document.createElement("div");s.className="d-flex flex-wrap gap-2",i.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 `,s.appendChild(t)}),t.appendChild(s)}_renderActionBlock(e,t){const i=this._escapeHtml.bind(this),s=document.createElement("div");s.className="assistant-action-card",s.innerHTML=`\n <div class="assistant-action-header">${i(e.title||"Action Required")}</div>\n ${e.description?`<div class="assistant-action-desc">${i(e.description)}</div>`:""}\n <div class="assistant-action-buttons"></div>\n `;const a=s.querySelector(".assistant-action-buttons");(e.actions||[]).forEach((t,s)=>{const n=document.createElement("button");n.className=0===s?"btn btn-sm btn-primary":"btn btn-sm btn-outline-secondary",n.textContent=t.label,n.addEventListener("click",()=>{a.innerHTML=`\n <div class="assistant-action-chosen-label">\n <i class="bi bi-check-circle-fill me-1"></i>\n You chose: <strong>${i(t.label)}</strong>\n </div>\n `;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(n)}),t.appendChild(s)}_renderListBlock(e,t){const i=this._escapeHtml.bind(this),s=document.createElement("div");s.className="assistant-list-card";let a="";e.title&&(a+=`<div class="assistant-list-title">${i(e.title)}</div>`),a+='<dl class="assistant-list-items">',(e.items||[]).forEach(e=>{a+=`\n <div class="assistant-list-row">\n <dt>${i(e.label)}</dt>\n <dd>${i(String(e.value??""))}</dd>\n </div>`}),a+="</dl>",s.innerHTML=a,t.appendChild(s)}_renderAlertBlock(e,t){const i=this._escapeHtml.bind(this),s=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"}[s]||"alert-info"}`,n.innerHTML=`\n <i class="bi ${a[s]||a.info} me-2"></i>\n <div class="assistant-alert-content">\n ${e.title?`<strong>${i(e.title)}</strong>`:""}\n <div>${i(e.message||"")}</div>\n </div>\n `,t.appendChild(n)}_renderProgressBlock(e,t){const i=this._escapeHtml.bind(this),s=e.steps||[],a=s.filter(e=>"done"===e.status).length,n=s.length>0?Math.round(a/s.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"};s.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">${i(e.description)}</span>\n ${e.summary?`<span class="step-summary">${i(e.summary)}</span>`:""}\n </div>\n </div>`}),o.innerHTML=`\n <div class="assistant-progress-header">\n <span class="assistant-progress-title">${i(e.title||"Plan")}</span>\n <span class="assistant-progress-counter">${a} of ${s.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)}_renderFileBlock(e,t){if(!e.filename||!e.url)return void console.warn("File block missing required fields (filename, url). type:",e.type);let i;try{const t=new URL(e.url,window.location.href);if("https:"!==t.protocol&&"http:"!==t.protocol)return void console.warn("File block URL rejected (invalid scheme).");i=e.url}catch{return void console.warn("File block URL rejected (unparseable).")}const s=this._escapeHtml.bind(this),a={csv:"bi-filetype-csv",xlsx:"bi-file-earmark-spreadsheet",pdf:"bi-filetype-pdf",json:"bi-filetype-json"}[e.format]||"bi-file-earmark-arrow-down",n=document.createElement("a");n.className="assistant-file-card",n.href=i,n.download=e.filename,n.target="_blank",n.rel="noopener";const o=[];null!=e.size&&o.push(this._formatBytes(e.size)),null!=e.row_count&&o.push(`${Number(e.row_count).toLocaleString()} rows`),n.innerHTML=`\n <span class="assistant-file-icon">\n <i class="bi ${a}"></i>\n </span>\n <div class="assistant-file-info">\n <span class="assistant-file-name">${s(e.filename)}</span>\n ${o.length?`<span class="assistant-file-stats">${o.join(" · ")}</span>`:""}\n ${e.expires_in?`<span class="assistant-file-expiry"><i class="bi bi-clock me-1"></i>${s(e.expires_in)}</span>`:""}\n </div>\n <span class="assistant-file-download" title="Download">\n <i class="bi bi-download"></i>\n </span>\n `,t.appendChild(n)}_renderContextBlock(e,t){const i=e.references;if(!i||0===i.length)return void t.remove();const s=this._escapeHtml.bind(this),a=this.getApp(),n=document.createElement("div");n.className="assistant-context-refs",i.forEach(e=>{const t=`${e.app_name}.${e.model_name}`,i=String(e.pk),o=e.label||`${e.model_name} #${i}`,l=a?.getModelByRef(t),r=l?.VIEW_CLASS,c=document.createElement("span");c.className=r?"assistant-context-chip clickable":"assistant-context-chip",r?(c.setAttribute("data-action","open-context-ref"),c.dataset.ref=t,c.dataset.pk=i,c.innerHTML=`<i class="bi bi-box-arrow-up-right"></i>${s(o)}`):c.textContent=o,n.appendChild(c)}),t.appendChild(n)}async onActionOpenContextRef(e,t){const i=t.dataset.ref,s=t.dataset.pk;if(!i||!/^\d+$/.test(s))return;const n=this.getApp(),o=n?.getModelByRef(i);o?.VIEW_CLASS&&a.Modal.showModelById(o,s)}_formatBytes(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":(e/1048576).toFixed(1)+" MB"}updateProgressStep(e,t,i,s){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-${i}`;const e=o.querySelector(".step-icon");e&&(e.className=`bi ${n[i]||n.pending} step-icon`);let t=o.querySelector(".step-summary");s&&(t||(t=document.createElement("span"),t.className="step-summary",o.querySelector(".step-content").appendChild(t)),t.textContent=s)}const l=a.querySelectorAll(".assistant-progress-step"),r=a.querySelectorAll(".step-done").length,c=a.querySelector(".assistant-progress-counter");c&&(c.textContent=`${r} of ${l.length}`);const d=a.querySelector(".progress-bar");d&&(d.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 i=e.classList.toggle("message-collapsed");t.innerHTML=i?'<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 i=this.getApp(),s=await i.rest.post("/api/docit/render",{markdown:t}),a=s?.data?.data?.html||s?.data?.html;if(a)return void(e.innerHTML=a)}catch(i){}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 i=t.innerHTML;return i=i.replace(/```(\w*)\n([\s\S]*?)```/g,(e,t,i)=>`<pre class="assistant-code-block"><code>${i.trim()}</code></pre>`),i=i.replace(/`([^`]+)`/g,'<code class="assistant-inline-code">$1</code>'),i=i.replace(/^### (.+)$/gm,'<h6 class="assistant-heading mt-3 mb-1">$1</h6>'),i=i.replace(/^## (.+)$/gm,'<h5 class="assistant-heading mt-3 mb-1">$1</h5>'),i=i.replace(/^# (.+)$/gm,'<h4 class="assistant-heading mt-3 mb-2">$1</h4>'),i=i.replace(/^---+$/gm,'<hr class="my-2 opacity-25">'),i=i.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>"),i=i.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>"),i=i.replace(/\*(.+?)\*/g,"<em>$1</em>"),i=i.replace(/((?:^- .+$\n?)+)/gm,e=>`<ul class="assistant-list mb-2">${e.trim().split("\n").map(e=>`<li>${e.replace(/^- /,"")}</li>`).join("")}</ul>`),i=i.replace(/\n{2,}/g,"</p><p>"),i=i.replace(/\n/g,"<br>"),i=`<p>${i}</p>`,i=i.replace(/<p>\s*<\/p>/g,""),i=i.replace(/<p>\s*(<h[456]|<hr|<ul|<pre|<\/ul>|<\/pre>)/g,"$1"),i=i.replace(/(<\/h[456]>|<hr[^>]*>|<\/ul>|<\/pre>)\s*<\/p>/g,"$1"),i}}AssistantMessageView._blockCounter=0;class AssistantConversationListView extends t.View{constructor(e={}){super({className:"assistant-conversation-list",...e}),this.collection=e.collection,this.activeId=null,this._searchTimeout=null}getTemplate(){return'\n <div class="conversation-list-header">\n <div class="conversation-search-wrapper mb-2">\n <input type="text" class="form-control form-control-sm conversation-search-input"\n placeholder="Search conversations..." data-ref="search-input">\n </div>\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();const e=this.element.querySelector('[data-ref="search-input"]');e&&e.addEventListener("input",()=>this._onSearchInput(e))}_onSearchInput(e){this._searchTimeout&&clearTimeout(this._searchTimeout),this._searchTimeout=setTimeout(async()=>{const t=e.value.trim();t?this.collection.params.search=t:delete this.collection.params.search,this.collection.params.start=0,await this.collection.fetch(),this._renderItems()},300)}_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 i=this._groupByDate(t);for(const[s,a]of i){const t=document.createElement("div");t.className="conversation-date-header px-3 py-1 text-muted small fw-semibold text-uppercase",t.textContent=s,e.appendChild(t),a.forEach(t=>{const i=t.get("id"),s=t.get("title")||t.get("summary")||"New conversation",a=t.get("modified")||t.get("created"),n=this._relativeTime(a),o=i===this.activeId,l=t.get("user"),r=l?.display_name||"",c=l?.avatar?.thumbnail||l?.avatar?.url||"",d=document.createElement("div");d.className="conversation-item px-3 py-2"+(o?" active":""),d.dataset.id=i,d.innerHTML=`\n <div class="d-flex align-items-start">\n ${c?`<img src="${this._escapeHtml(c)}" alt="" class="conversation-avatar">`:`<div class="conversation-avatar conversation-avatar-initials">${this._escapeHtml(this._initials(r))}</div>`}\n <div class="flex-grow-1 overflow-hidden">\n <div class="text-truncate conversation-title">${this._escapeHtml(s)}</div>\n <div class="conversation-meta text-muted">\n ${r?`<span>${this._escapeHtml(r)}</span>`:""}\n ${n?`<span>${n}</span>`:""}\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="${i}" title="Delete">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n `,d.addEventListener("click",e=>{e.target.closest('[data-action="delete-conversation"]')||(this.setActive(i),this.emit("conversation:select",{id:i,model:t}))}),e.appendChild(d)})}if(this.collection.hasMore){const t=document.createElement("div");t.className="conversation-load-more text-center py-2",t.innerHTML='<button class="btn btn-sm btn-link text-muted" data-action="load-more">Load more</button>',e.appendChild(t)}}async onActionLoadMore(){await this.collection.nextPage(),this._renderItems()}_groupByDate(e){const i=/* @__PURE__ */new Date,s=new Date(i.getFullYear(),i.getMonth(),i.getDate()),a=new Date(s);a.setDate(a.getDate()-1);const n=/* @__PURE__ */new Map;n.set("Today",[]),n.set("Yesterday",[]),n.set("Earlier",[]),e.forEach(e=>{const i=e.get("created")||e.get("modified"),o=new Date(t.dataFormatter.normalizeEpoch(i)),l=new Date(o.getFullYear(),o.getMonth(),o.getDate());l>=s?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 a.Modal.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 s=this.collection.models.find(e=>String(e.get("id"))===String(i));s&&(await s.destroy(),await this.refresh(),this.emit("conversation:deleted",{id:i}))}async refresh(){await this.collection.fetch(),this._renderItems()}_relativeTime(e){if(!e)return"";const i=new Date(t.dataFormatter.normalizeEpoch(e));if(isNaN(i))return"";const s=Date.now()-i.getTime(),a=Math.floor(s/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`:`${i.toLocaleString("default",{month:"short"})} ${i.getDate()}`}_initials(e){return e?e.split(/\s+/).map(e=>e[0]).join("").toUpperCase().slice(0,2):"?"}_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={},this._requestStartTime=null}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 <img src="https://mojo-verify.s3.amazonaws.com/signatures/14e7aab75c2749cb846f7d57298691ac/mojo_ai_7c0322e9.png" alt="Mojo">\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-status d-none" data-ref="input-status"></div>\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message Mojo..." 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 c.AssistantConversationList,this.conversations.params.user=this.app?.activeUser?.id,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},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 i=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!i)return;const s=this.element.querySelector('[data-ref="input"]');s&&(s.value=i,this._autoResize(s)),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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e),this._setInputStatus(e?null:t),this._responseTimeout&&clearTimeout(this._responseTimeout),e?this._requestStartTime=null:this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4)}_setInputStatus(e){const t=this.element?.querySelector('[data-ref="input-status"]');t&&(e?(t.innerHTML=`${this._escapeHtml(e)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,t.classList.remove("d-none"),t._hasDismiss||(t._hasDismiss=!0,t.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))):(t.classList.add("d-none"),t.innerHTML=""))}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 c.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.chatView.showThinking("Thinking..."),this._requestStartTime=Date.now(),this._setInputEnabled(!1,"Waiting for response…"),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}),i=t?.data?.data||t?.data||t;i.conversation_id&&(this.conversationId=i.conversation_id),i.response&&this.chatView.addMessage(this._transformMessage(i.response)),this._setInputEnabled(!0)}catch(i){this._handleAPIError(i)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),text:e=>this._onText(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_text",this._wsHandlers.text),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_text",this._wsHandlers.text),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_text":this._onText(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,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){if(this._responseTimeout){if(this._requestStartTime&&Date.now()-this._requestStartTime>=3e5)return void this._onResponseTimeout();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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,i=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Mojo"}: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:i,tool_calls:s,_conversationId:this.conversationId}}static _collapseMessages(e){const t=[];for(const i of e){"assistant"===i.role&&i.tool_calls?.length>0&&(i.tool_calls=i.tool_calls.filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name)));const e=!!i.content,s=i.tool_calls?.length>0,a=i.blocks?.length>0;if("assistant"!==i.role||e||s||a){if("assistant"===i.role&&!e&&s&&!a){const e=t[t.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}}t.push(i)}}return t}static _parseBlocks(e){const t=/```assistant_block\s*\n([\s\S]*?)```/g,i=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress","file"]),s=[];let a;for(;null!==(a=t.exec(e));)try{const e=JSON.parse(a[1].trim());e&&i.has(e.type)&&s.push(e)}catch(n){}return{content:e.replace(t,"").replace(/\n{3,}/g,"\n\n").trim(),blocks:s}}_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");e&&(this.ws?.isConnected?(e.className="status-dot connected",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0)):this.ws?.isReconnecting?(e.className="status-dot reconnecting",e.title="Reconnecting...",this._setInputEnabled(!1,"Reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)):(e.className="status-dot disconnected",e.title="Disconnected",this._setInputEnabled(!1,"Disconnected — reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)))}_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)}}AssistantView.INTERNAL_TOOLS=/* @__PURE__ */new Set(["create_plan","update_plan","load_tools"]);class AssistantContextAdapter{constructor({app:e,modelName:t,pk:i,conversationId:s}){this.app=e,this.modelName=t,this.pk=i,this.conversationId=s,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 c.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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=/```assistant_block\s*\n([\s\S]*?)```/g,s=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress","file"]);let n;for(;null!==(n=e.exec(t));)try{const e=JSON.parse(n[1].trim());e&&s.has(e.type)&&i.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:"Mojo"}: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:i,tool_calls:s,_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="assistant-input-status d-none" data-ref="input-status"></div>\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 n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this.adapter}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},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 i={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(i),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Waiting for response…"),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}),i=e?.data?.data||e?.data||e;i.response&&this.chatView.addMessage(this.adapter._transformMessage({id:i.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:i.response,blocks:i.blocks||[],created:/* @__PURE__ */(new Date).toISOString()})),this._setInputEnabled(!0)}catch(s){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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e);const n=this.element?.querySelector('[data-ref="input-status"]');if(n)if(!e&&t){const e=e=>{const t=document.createElement("div");return t.textContent=e,t.innerHTML};n.innerHTML=`${e(t)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,n.classList.remove("d-none"),n._hasDismiss||(n._hasDismiss=!0,n.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))}else n.classList.add("d-none"),n.innerHTML="";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),text:e=>this._onText(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()},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_text",this._wsHandlers.text),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))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_text",this._wsHandlers.text),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._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_text":this._onText(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)}_adoptConversationId(e){e.conversation_id&&this.adapter&&!this.adapter.conversationId&&(this.adapter.conversationId=e.conversation_id)}_onThinking(e){this._isMyConversation(e)&&(this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this.adapter._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");if(e)if(this.ws?.isConnected)e.style.background="#198754",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0);else{e.style.background="#dc3545",e.title="Disconnected";const t=this.element?.querySelector('[data-ref="input"]'),i=this.element?.querySelector('[data-ref="send-btn"]');t&&(t.disabled=!0),i&&i.classList.add("d-none");const s=this.element?.querySelector('[data-ref="input-status"]');s&&(s.textContent="Disconnected — reconnecting…",s.classList.remove("d-none"))}}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}async function Z(e,t){const i=e.getApp();if(!i)return;const s=e.model,n=s.get("id"),o=(s.get("metadata")||{}).assistant_conversation_id||null,l=new AssistantContextAdapter({app:i,modelName:t,pk:n,conversationId:o});l._onConversationCreated=async e=>{try{await s.save({metadata:{assistant_conversation_id:e}})}catch(t){}};const r=new AssistantContextChat({app:i,adapter:l});await a.Modal.show(r,{title:"Mojo",size:"xl"})}const X=t.MOJOUtils.escapeHtml;function ee(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"}function te(e,t){return e.length>t?e.slice(0,t)+"…":e}function ie(e){if(!e)return"";const t=Number(e),i=Math.floor(Date.now()/1e3)-t;return i<60?i<=1?"just now":`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}function se(e){const t=Math.max(0,Math.floor(e||0));return t<60?`${t}s`:t<3600?`${Math.floor(t/60)}m ${t%60}s`:t<86400?`${Math.floor(t/3600)}h ${Math.floor(t%3600/60)}m`:`${Math.floor(t/86400)}d ${Math.floor(t%86400/3600)}h`}function ae(e){const t=e.get("metadata")||{};return t.source_ip||t.ip||t.ip_address||null}const ne={new:{tone:"danger",icon:"bi-bell-fill",label:"New",help:"Unhandled — needs triage"},open:{tone:"danger",icon:"bi-folder2-open",label:"Open",help:"Claimed by an operator for investigation"},investigating:{tone:"warning",icon:"bi-search",label:"Investigating",help:"Actively being investigated"},paused:{tone:"info",icon:"bi-pause-circle-fill",label:"Paused",help:"On hold — waiting for external input"},resolved:{tone:"success",icon:"bi-check-circle-fill",label:"Resolved",help:"Root cause addressed"},closed:{tone:"success",icon:"bi-x-circle-fill",label:"Closed",help:"Closed — archived"},ignored:{tone:"info",icon:"bi-eye-slash-fill",label:"Ignored",help:"Noise — review periodically"},pending:{tone:"warning",icon:"bi-hourglass-split",label:"Pending",help:"Below trigger threshold — accumulating events"}},oe=/* @__PURE__ */new Set(["new","open","investigating","pending"]);function le(e){return ne[(e||"").toLowerCase()]||ne.new}function re(e){const t=parseInt(e,10)||5;return t>=8?{variant:"danger",label:"Critical"}:t>=6?{variant:"danger",label:"High"}:t>=4?{variant:"warning",label:"Medium"}:t>=2?{variant:"secondary",label:"Low"}:{variant:"secondary",label:"Info"}}function ce(e){const t=(e||"").toLowerCase();return"resolved"===t||"closed"===t?{icon:"bi-shield-check",tone:"success"}:"paused"===t||"ignored"===t?{icon:"bi-shield",tone:null}:"investigating"===t?{icon:"bi-shield-exclamation",tone:"warning"}:{icon:"bi-shield-exclamation",tone:"danger"}}class IncidentOverviewSection extends t.View{constructor(e={}){super({className:"incident-overview-section",template:'\n <div data-container="ov-status"></div>\n <div data-container="ov-llm-analysis"></div>\n <div class="detail-kpi-grid">\n <div data-container="ov-kpi-events"></div>\n <div data-container="ov-kpi-sources"></div>\n <div data-container="ov-kpi-last"></div>\n <div data-container="ov-kpi-related"></div>\n </div>\n <div class="detail-pair">\n <div data-container="ov-trigger"></div>\n <div data-container="ov-response"></div>\n </div>\n ',...e})}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"ov-status",model:e,tone:e=>function(e){const t=(e.get("status")||"").toLowerCase(),i=parseInt(e.get("priority"),10)||5;return oe.has(t)?i>=6?"danger":"warning":"resolved"===t||"closed"===t?"success":"paused"===t||"ignored"===t?"info":"primary"}(e),state:e=>this._panelState(e),headline:e=>this._panelHeadline(e),meta:e=>this._panelMeta(e),actions:e=>this._panelActions(e)}),this.addChild(this.statusPanel),this._mountLlmAnalysis();const t=e.get("event_count")??0,i=e.get("source_count")??(ae(e)?1:0),s=e.get("related_count"),a=ie(e.get("modified")||e.get("created"))||"—";this.kpiEvents=new n.MetricCard({containerId:"ov-kpi-events",label:"Events",value:String(t),tone:t>10?"danger":t>0?"warning":"default"}),this.kpiSources=new n.MetricCard({containerId:"ov-kpi-sources",label:"Sources",value:String(i)}),this.kpiLast=new n.MetricCard({containerId:"ov-kpi-last",label:"Last fired",value:a}),this.kpiRelated=new n.MetricCard({containerId:"ov-kpi-related",label:"Related",value:null!=s?String(s):"—"}),[this.kpiEvents,this.kpiSources,this.kpiLast,this.kpiRelated].forEach(e=>this.addChild(e)),this.triggerCard=new IncidentTriggerCard({containerId:"ov-trigger",model:e}),this.triggerCard.on("action:view-ruleset",()=>this.emit("action:view-ruleset")),this.triggerCard.on("action:view-source-ip",()=>this.emit("action:view-source-ip")),this.triggerCard.on("action:view-user",e=>this.emit("action:view-user",e)),this.addChild(this.triggerCard),this.responseCard=new IncidentResponseCard({containerId:"ov-response",model:e}),this.responseCard.on("action:view-ticket",e=>this.emit("action:view-ticket",e)),this.addChild(this.responseCard)}_mountLlmAnalysis(){const e=(this.model.get("metadata")||{}).llm_analysis;e&&!this.llmResultsView&&(this.llmResultsView=new LLMAnalysisResultsView({containerId:"ov-llm-analysis",analysis:e}),this.llmResultsView.on("analyze-llm",()=>this.emit("action:analyze-llm")),this.addChild(this.llmResultsView))}async refreshAnalysis(){this.llmResultsView&&(this.removeChild(this.llmResultsView),this.llmResultsView=null),this.isMounted()&&await this.render()}_panelState(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=Number(e.get("created"))||0,a=s&&oe.has(t)?se(Math.max(0,Math.floor(Date.now()/1e3)-s)):"";return a?`${i.label} · ${a}`:i.label}_panelHeadline(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=Number(e.get("created"))||0,a=Number(e.get("modified"))||0;if(oe.has(t)){const e=s?se(Math.max(0,Math.floor(Date.now()/1e3)-s)):"";return e?`In flight for ${e}`:"In flight"}return"resolved"===t||"closed"===t?`Resolved ${ie(a)}`:i.help||i.label}_panelMeta(e){const t=le(e.get("status")),i=e.get("event_count")??0,s=ae(e),a=e.get("source_count")??(s?1:0),n=Number(e.get("modified"))||0,o=n?ie(n):"",l=[];if(o&&l.push(`Last event <strong>${X(o)}</strong>`),i){const e=1===i?"event":"events",t=1===a?"source":"sources",s=a?` from ${X(String(a))} ${t}`:"";l.push(`${X(String(i))} ${e}${s}`)}return l.length||l.push(X(t.help||"")),l.join(" · ")}_panelActions(e){const t=(e.get("status")||"").toLowerCase(),i=[];return oe.has(t)?(i.push({label:"Resolve",action:"resolve",icon:"bi-check2-circle",variant:"success"}),i.push({label:"Assign",action:"assign",icon:"bi-person",variant:"outline-secondary"})):"resolved"===t||"closed"===t?i.push({label:"Re-open",action:"reopen",icon:"bi-arrow-counterclockwise",variant:"outline-primary"}):i.push({label:"Resolve",action:"resolve",icon:"bi-check2-circle",variant:"success"}),i}async onActionResolve(){this.emit("action:resolve")}async onActionAssign(){this.emit("action:assign")}async onActionReopen(){this.emit("action:reopen")}}class IncidentTriggerCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-funnel"></i>What triggered this</div>\n {{#hasRows|bool}}\n <div class="trigger-rows">\n {{#hasRule|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Rule</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-ruleset">{{ruleLabel}}</a>\n </div>\n </div>\n {{/hasRule|bool}}\n {{#hasCategory|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Category</div>\n <div class="detail-flat-row-value"><code>{{model.category}}</code></div>\n </div>\n {{/hasCategory|bool}}\n {{#hasScope|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Scope</div>\n <div class="detail-flat-row-value"><code>{{model.scope}}</code></div>\n </div>\n {{/hasScope|bool}}\n {{#hasSourceIp|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Source IP</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-source-ip"><code>{{sourceIp}}</code></a>\n </div>\n </div>\n {{/hasSourceIp|bool}}\n {{#hasTargetUser|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Targeted user</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-user" data-user="{{targetUser}}">{{targetUser}}</a>\n </div>\n </div>\n {{/hasTargetUser|bool}}\n {{#hasHostname|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Hostname</div>\n <div class="detail-flat-row-value"><code>{{model.hostname}}</code></div>\n </div>\n {{/hasHostname|bool}}\n {{#hasEventCount|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Events</div>\n <div class="detail-flat-row-value"><strong>{{model.event_count}}</strong></div>\n </div>\n {{/hasEventCount|bool}}\n </div>\n {{/hasRows|bool}}\n {{^hasRows|bool}}\n <div class="text-secondary small">No trigger context recorded.</div>\n {{/hasRows|bool}}\n </div>\n </div>\n ',...e})}get _ruleSet(){return this.model?.get?.("rule_set")}get hasRule(){const e=this._ruleSet;return!(!e||!("object"==typeof e?e.id||e.name:e))}get ruleLabel(){const e=this._ruleSet;if(!e)return"";if("object"==typeof e&&e.name)return e.name;const t="object"==typeof e?e.id:e;return t?`RuleSet #${t}`:""}get hasCategory(){return!!this.model?.get?.("category")}get hasScope(){return!!this.model?.get?.("scope")}get hasHostname(){return!!this.model?.get?.("hostname")}get hasEventCount(){const e=this.model?.get?.("event_count");return null!=e}get sourceIp(){return ae(this.model)||""}get hasSourceIp(){return!!this.sourceIp}get targetUser(){const e=this.model?.get?.("metadata")||{};return e.user||e.email||e.username||""}get hasTargetUser(){return!!this.targetUser}get hasRows(){return this.hasRule||this.hasCategory||this.hasScope||this.hasSourceIp||this.hasTargetUser||this.hasHostname||this.hasEventCount}async onActionViewRuleset(){this.emit("action:view-ruleset")}async onActionViewSourceIp(){this.emit("action:view-source-ip")}async onActionViewUser(e,t){const i=t?.dataset?.user;this.emit("action:view-user",i)}}class IncidentResponseCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-tools"></i>What happened next</div>\n <div data-container="response-timeline"></div>\n </div>\n </div>\n ',...e})}async onInit(){this.timeline=new n.Timeline({containerId:"response-timeline",model:this.model,emptyText:"No handler activity recorded yet.",items:e=>this._buildItems(e)}),this.addChild(this.timeline)}_buildItems(e){const t=e||this.model,i=t.get("metadata")||{},s=[],a=i.handler_chain||i.handler||(t.get("rule_set")&&"object"==typeof t.get("rule_set")?t.get("rule_set").handler:null);if(a){const e=String(a).split(",").map(e=>`<code>${X(e.trim())}</code>`).join(" → ");s.push({tone:"danger",headline:"Handler chain fired",detail:e,when:ie(t.get("created"))})}if(i.blocked_ip||i.ip_blocked){const e=i.blocked_ip||ae(t),a=i.block_ttl?` · expires in ${X(se(i.block_ttl))}`:"";s.push({tone:"warning",headline:"Source IP blocked",detail:`<code>${X(String(e||""))}</code>${a}`,when:ie(i.blocked_at||t.get("created"))})}const n=i.ticket_id||i.ticket;n&&s.push({tone:"warning",headline:"Ticket created",detail:`<a href="#" data-action="view-ticket" data-ticket="${X(String(n))}">#${X(String(n))}</a>`,when:ie(i.ticket_created_at||t.get("created"))});const o=i.llm_analysis;if(o){const e=o.verdict||o.classification||"",a=null!=o.confidence?` · confidence ${X(String(o.confidence))}`:"";s.push({tone:"info",headline:"LLM triage completed",detail:e?`Verdict: <strong>${X(String(e))}</strong>${a}`:"Analysis complete",when:ie(i.llm_analyzed_at||t.get("modified"))})}else i.analysis_in_progress&&s.push({tone:"info",headline:"LLM triage in progress",detail:"Polling for results…",when:"just now"});return s}async onActionViewTicket(e,t){e.preventDefault();const i=t?.dataset?.ticket;i&&this.emit("action:view-ticket",i)}}class LLMAnalysisResultsView extends t.View{constructor(e={}){const{analysis:t={},...i}=e;super({className:"llm-analysis-results mb-3",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-secondary small ms-2">AI-generated triage</span>\n </div>\n <button class="btn btn-outline-info btn-sm" data-action="re-analyze">\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-secondary mb-2"><i class="bi bi-chat-left-text me-1"></i>Summary</h6>\n <div class="bg-body-tertiary rounded 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-secondary 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 </div>\n {{/hasProposedRule|bool}}\n </div>\n </div>\n ',...i}),this.analysis=t,this.summary=t.summary||"",this.summaryHtml="",this.hasProposedRule=!!t.proposed_ruleset_id,this.proposedRulesetId=t.proposed_ruleset_id,this.mergedCount=(t.merged_incidents||[]).length,this.mergedIds=(t.merged_incidents||[]).join(", ")}async onBeforeRender(){this.summary&&!this.summaryHtml&&(this.summaryHtml=await async function(e,t){if(!t)return"";try{const i=e.getApp(),s=await i.rest.post("/api/docit/render",{markdown:t}),a=s?.data?.data?.html||s?.data?.html;if(a)return a}catch(i){}return`<pre class="detail-error-block">${X(t)}</pre>`}(this,this.summary))}async onActionReAnalyze(){this.emit("analyze-llm")}async onActionViewProposedRule(){if(this.proposedRulesetId)try{const e=new c.RuleSet({id:this.proposedRulesetId});await e.fetch(),await a.Modal.detail(new RuleSetView({model:e}))}catch(e){this.getApp()?.toast?.error("Could not load proposed RuleSet")}}}class IncidentSourceSection extends t.View{constructor(e={}){const{sourceIP:t,ipInfo:i,...s}=e;super({className:"incident-source-section",template:'\n {{^hasSourceIp|bool}}\n <div class="text-center text-secondary py-5">\n <i class="bi bi-globe fs-1 d-block mb-2"></i>\n <p class="mb-0">No source IP available for this incident.</p>\n </div>\n {{/hasSourceIp|bool}}\n {{#hasSourceIp|bool}}\n {{^hasGeoData|bool}}\n <div class="text-secondary py-3">\n <i class="bi bi-globe me-2"></i>No GeoIP data available for {{sourceIP}}\n </div>\n {{/hasGeoData|bool}}\n {{#hasGeoData|bool}}\n <div class="detail-section-eyebrow">\n Source IP\n <span class="ms-auto">\n {{{badgesHtml}}}\n </span>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Address</div>\n <div class="detail-flat-row-value">\n <a role="button" class="font-monospace fw-semibold text-decoration-none" data-action="view-geoip">{{sourceIP}}</a>\n </div>\n </div>\n {{#hasGeoLine|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Location</div>\n <div class="detail-flat-row-value">{{geoLine}}</div>\n </div>\n {{/hasGeoLine|bool}}\n {{#hasIspLine|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ISP</div>\n <div class="detail-flat-row-value">{{ispLine}}</div>\n </div>\n {{/hasIspLine|bool}}\n {{#hasRiskScore|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Risk score</div>\n <div class="detail-flat-row-value"><code>{{riskScore}}</code></div>\n </div>\n {{/hasRiskScore|bool}}\n\n <div class="incident-source-actions d-flex flex-wrap gap-2">\n {{{actionsHtml}}}\n </div>\n\n <div data-container="src-network"></div>\n <div data-container="src-threat"></div>\n <div data-container="src-flags"></div>\n <div data-container="src-block"></div>\n {{/hasGeoData|bool}}\n {{/hasSourceIp|bool}}\n ',...s}),this.sourceIP=t,this.ipInfo=i||null,this.geoData=null,this.threatLevel="Unknown",this.threatBadgeClass="bg-secondary",this.isBlocked=!1,this.isWhitelisted=!1,this.blockedReason="",this.geoModel=null}get hasSourceIp(){return!!this.sourceIP}get hasGeoData(){return!!this.geoData}get geoLine(){if(!this.geoData)return"";const e=this.geoData;return[e.city,e.country_name,e.country_code?`(${e.country_code})`:null].filter(Boolean).join(" · ")}get hasGeoLine(){return!!this.geoLine}get ispLine(){if(!this.geoData)return"";const e=this.geoData;return[e.isp,e.asn,e.connection_type].filter(Boolean).join(" · ")}get hasIspLine(){return!!this.ispLine}get riskScore(){return null!=this.geoData?.risk_score?String(this.geoData.risk_score):""}get hasRiskScore(){return null!=this.geoData?.risk_score}get badgesHtml(){const e=this.geoData||{};return`${[e.is_tor&&'<span class="badge bg-danger-subtle text-danger" title="TOR Exit Node">TOR</span>',e.is_vpn&&'<span class="badge bg-warning-subtle text-warning" title="VPN Detected">VPN</span>',e.is_proxy&&'<span class="badge bg-info-subtle text-info" title="Proxy">Proxy</span>',e.is_datacenter&&'<span class="badge bg-secondary-subtle text-secondary" title="Datacenter">DC</span>',e.is_known_attacker&&'<span class="badge bg-danger" title="Known Attacker">Attacker</span>',e.is_known_abuser&&'<span class="badge bg-danger-subtle text-danger" title="Known Abuser">Abuser</span>'].filter(Boolean).join(" ")} <span class="badge ${this.threatBadgeClass}">${X(this.threatLevel)}</span>${this.isBlocked?`<span class="badge bg-danger ms-1" title="${X(this.blockedReason)}"><i class="bi bi-slash-circle me-1"></i>Blocked</span>`:""}${this.isWhitelisted?'<span class="badge bg-success ms-1"><i class="bi bi-check-circle me-1"></i>Whitelisted</span>':""}`.trim()}get actionsHtml(){const e=this.geoData||{},t=[];if(this.isBlocked?t.push('<button class="btn btn-outline-success btn-sm" data-action="unblock-ip"><i class="bi bi-unlock me-1"></i>Unblock IP</button>'):t.push('<button class="btn btn-outline-danger btn-sm" data-action="block-ip"><i class="bi bi-slash-circle me-1"></i>Block IP</button>'),this.isWhitelisted||t.push('<button class="btn btn-outline-primary btn-sm" data-action="whitelist-ip"><i class="bi bi-check-circle me-1"></i>Whitelist</button>'),t.push('<button class="btn btn-outline-secondary btn-sm" data-action="view-geoip"><i class="bi bi-box-arrow-up-right me-1"></i>Open GeoIP details</button>'),null!=e.latitude&&null!=e.longitude){const i=encodeURIComponent(e.latitude),s=encodeURIComponent(e.longitude);t.push(`<a class="btn btn-outline-secondary btn-sm" target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=${i}&mlon=${s}#map=10/${i}/${s}"><i class="bi bi-geo-alt me-1"></i>View on map</a>`)}return t.join("")}async onInit(){if(this.sourceIP){if(this.ipInfo)this.geoData=this.ipInfo,this.geoModel=new n.GeoLocatedIP(this.ipInfo);else try{this.geoModel=await n.GeoLocatedIP.lookup(this.sourceIP),this.geoModel&&(this.geoData=this.geoModel.attributes)}catch(e){}this.geoData&&(this.threatLevel=(this.geoData.threat_level||"unknown").toUpperCase(),this.threatBadgeClass=this._threatBadgeClass(this.geoData.threat_level),this.isBlocked=!!this.geoData.is_blocked,this.isWhitelisted=!!this.geoData.is_whitelisted,this.blockedReason=this.geoData.blocked_reason||"Blocked")}}async onAfterRender(){if(await super.onAfterRender(),!this.geoData)return;const e=this.geoData,t={get:t=>e[t],attributes:e,on(){},off(){}};this._detailsBuilt||(this.networkView=new d.default({containerId:"src-network",model:t,columns:2,showEmptyValues:!1,title:"Network",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),await this.networkView.render(),this.threatView=new d.default({containerId:"src-threat",model:t,columns:2,showEmptyValues:!1,title:"Threat assessment",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),await this.threatView.render(),this.flagsView=new d.default({containerId:"src-flags",model:t,columns:2,showEmptyValues:!1,title:"Threat flags",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),await this.flagsView.render(),this.blockView=new d.default({containerId:"src-block",model:t,columns:2,showEmptyValues:!1,title:"Block status",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),await this.blockView.render(),this._detailsBuilt=!0)}_threatBadgeClass(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 a.Modal.form({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:0}]});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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await a.Modal.form({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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await a.Modal.form({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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}}class IncidentRequestSection extends t.View{constructor(e={}){const{metadata:t={},...i}=e;super({className:"incident-request-section",template:'\n <div class="detail-section-eyebrow">Request</div>\n {{#hasAnyField|bool}}\n {{#hasMethod|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Method</div>\n <div class="detail-flat-row-value"><span class="badge bg-info text-dark">{{httpMethod}}</span></div>\n </div>\n {{/hasMethod|bool}}\n {{#hasStatus|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value"><code>{{httpStatus}}</code></div>\n </div>\n {{/hasStatus|bool}}\n {{#hasHost|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Host</div>\n <div class="detail-flat-row-value"><code>{{httpHost}}</code></div>\n </div>\n {{/hasHost|bool}}\n {{#hasPath|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Path</div>\n <div class="detail-flat-row-value"><code>{{httpPath}}</code></div>\n </div>\n {{/hasPath|bool}}\n {{#hasUrl|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">URL</div>\n <div class="detail-flat-row-value"><code>{{httpUrl}}</code></div>\n </div>\n {{/hasUrl|bool}}\n {{#hasProtocol|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Protocol</div>\n <div class="detail-flat-row-value"><code>{{httpProtocol}}</code></div>\n </div>\n {{/hasProtocol|bool}}\n {{#hasQueryString|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Query string</div>\n <div class="detail-flat-row-value"><code>{{httpQueryString}}</code></div>\n </div>\n {{/hasQueryString|bool}}\n {{#hasUserAgent|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">User agent</div>\n <div class="detail-flat-row-value"><code>{{httpUserAgent}}</code></div>\n </div>\n {{/hasUserAgent|bool}}\n {{/hasAnyField|bool}}\n {{^hasAnyField|bool}}\n <div class="text-secondary small">No HTTP request fields recorded.</div>\n {{/hasAnyField|bool}}\n\n {{#hasHeaders|bool}}\n <div class="detail-section-eyebrow">Headers</div>\n <pre class="detail-payload-block"><code>{{headersText}}</code></pre>\n {{/hasHeaders|bool}}\n\n {{#hasBody|bool}}\n <div class="detail-section-eyebrow">Body</div>\n <pre class="detail-payload-block"><code>{{bodyText}}</code></pre>\n {{/hasBody|bool}}\n ',...i}),this.metadata=t}get httpMethod(){return this.metadata.http_method||""}get httpStatus(){return null!=this.metadata.http_status?String(this.metadata.http_status):""}get httpHost(){return this.metadata.http_host||""}get httpPath(){return this.metadata.http_path||""}get httpUrl(){return this.metadata.http_url||""}get httpProtocol(){return this.metadata.http_protocol||""}get httpQueryString(){return this.metadata.http_query_string||""}get httpUserAgent(){return this.metadata.http_user_agent||""}get hasMethod(){return!!this.httpMethod}get hasStatus(){return null!=this.metadata.http_status}get hasHost(){return!!this.httpHost}get hasPath(){return!!this.httpPath}get hasUrl(){return!!this.httpUrl}get hasProtocol(){return!!this.httpProtocol}get hasQueryString(){return!!this.httpQueryString}get hasUserAgent(){return!!this.httpUserAgent}get hasAnyField(){return this.hasMethod||this.hasStatus||this.hasHost||this.hasPath||this.hasUrl||this.hasProtocol||this.hasQueryString||this.hasUserAgent}get hasHeaders(){return!!this.metadata.http_headers}get headersText(){const e=this.metadata.http_headers;return"string"==typeof e?e:JSON.stringify(e,null,2)}get hasBody(){return!!this.metadata.http_body}get bodyText(){const e=this.metadata.http_body;return"string"==typeof e?e:JSON.stringify(e,null,2)}}class IncidentStackTraceSection extends t.View{constructor(e={}){const{stackTrace:t="",...i}=e;super({className:"incident-stack-trace-section",template:'\n <div class="detail-section-eyebrow">Stack Trace</div>\n <div data-container="stack-trace-body"></div>\n ',...i}),this.stackTrace=t}async onInit(){try{this.body=new StackTraceView({containerId:"stack-trace-body",stackTrace:this.stackTrace}),this.addChild(this.body)}catch(e){this.body=new t.View({containerId:"stack-trace-body",template:'<pre class="detail-error-block">{{stackTrace}}</pre>'}),this.body.stackTrace=this.stackTrace,this.addChild(this.body)}}}class RuleEngineSection extends t.View{constructor(e={}){const{incident:t,...i}=e;super({className:"rule-engine-section",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.</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="detail-section-eyebrow">\n Linked RuleSet\n <span class="ms-auto 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\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>Open\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>New rule\n </button>\n </span>\n </div>\n <div data-container="ruleset-data"></div>\n <div class="detail-section-eyebrow">Rule Conditions</div>\n <div data-container="ruleset-rules"></div>\n {{/hasRuleset|bool}}\n {{^hasRuleset|bool}}\n <div class="text-center py-5">\n <div class="text-secondary mb-3"><i class="bi bi-gear fs-1"></i></div>\n <h6 class="text-secondary">No RuleSet Linked</h6>\n <p class="text-secondary small mb-3">\n This incident was not created by a rule engine match.<br>\n Create a new rule 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 ',...i}),this.incident=t;const s=this.incident.get("rule_set");this.rulesetId=s&&"object"==typeof s?s.id:s,this.rulesetModel=null,this.hasRuleset=!1,this.autoDeleteEnabled=!1,this.incidentProtected=!!this.incident.get("metadata")?.do_not_delete}async onInit(){if(!this.rulesetId)return;try{this.rulesetModel=new c.RuleSet({id:this.rulesetId}),await this.rulesetModel.fetch(),this.hasRuleset=!0,this.autoDeleteEnabled=!!this.rulesetModel.get("metadata")?.delete_on_resolution}catch(n){return}const e=this.rulesetModel.get("match_by"),t=c.MatchByOptions.find(t=>t.value===e),i=this.rulesetModel.get("bundle_by"),s=c.BundleByOptions.find(e=>e.value===i);this.rulesetDataView=new d.default({containerId:"ruleset-data",model:this.rulesetModel,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:s?s.label:String(i),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 a=new c.RuleList({params:{parent:this.rulesetId}});this.rulesTable=new l.TableView({containerId:"ruleset-rules",collection:a,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 a.Modal.modelForm({title:`Edit RuleSet — ${this.rulesetModel.get("name")}`,model:this.rulesetModel,formConfig:c.RuleSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("RuleSet updated"))}async onActionViewLinkedRuleset(){this.rulesetModel&&await a.Modal.detail(new RuleSetView({model:this.rulesetModel}))}async onActionCreateRuleFromIncident(){const e=this.incident,t=e.get("category")||"",i=e.get("scope")||"",s=e.get("metadata")||{},n=await a.Modal.form({title:"Create RuleSet from Incident",icon:"bi-gear-wide-connected",formConfig:c.RuleSetForms.create,size:"lg",data:{name:`Rule: ${t||"custom"} (from incident #${e.get("id")})`,category:i||t,priority:10,is_active:!1,bundle_by:s.source_ip?4:0,bundle_minutes:30,match_by:0}});if(!n)return;const o=new c.RuleSet,l=await o.save({...n,bundle_by:parseInt(n.bundle_by),bundle_minutes:parseInt(n.bundle_minutes)||30,match_by:parseInt(n.match_by)||0});if(!l.success&&200!==l.status)return void this.getApp()?.toast?.error("Failed to create RuleSet");await this.incident.save({rule_set:o.id}),this.rulesetId=o.id,this.rulesetModel=o,this.hasRuleset=!0;const r=await this._showMetadataRulePicker(o,s);this.getApp()?.toast?.success(r?`RuleSet created with ${r} rule condition(s)`:"RuleSet created — add rule conditions to activate"),await a.Modal.detail(new RuleSetView({model:o}))}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"]),s=Object.entries(t).filter(([e,t])=>!i.has(e)&&null!==t&&""!==t&&"object"!=typeof t).map(([e,t])=>({key:e,value:t,type:ee(t)}));if(!s.length)return 0;const n=[{type:"html",columns:12,html:'<div class="text-secondary small mb-3">\n Select metadata fields to create as rule conditions.\n Each selected field becomes an <code>==</code> match rule.\n </div>'},...s.map(e=>({name:`rule__${e.key}`,type:"switch",label:`${e.key} = ${te(String(e.value),30)}`,tooltip:`${e.type}: ${String(e.value)}`,value:!1,columns:6}))],o=await a.Modal.form({title:"Create Rules from Metadata",icon:"bi-list-check",size:"lg",fields:n,submitText:"Create Rules",cancelText:"Skip"});if(!o)return 0;const l=s.filter(e=>o[`rule__${e.key}`]);if(!l.length)return 0;const r=this.getApp();try{await Promise.all(l.map((t,i)=>(new c.Rule).save({parent:e.id,name:`Match ${t.key}`,field_name:t.key,comparator:"==",value:String(t.value),value_type:t.type,index:i})))}catch(d){r?.toast?.warning("Some rule conditions failed to save")}return l.length}}class IncidentTicketsSection extends t.View{constructor(e={}){const{incident:t,collection:i,...s}=e;super({className:"incident-tickets-section",template:'\n <div class="detail-section-eyebrow">\n Related Tickets\n <span class="ms-auto">\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 </span>\n </div>\n <div data-container="tickets-table"></div>\n ',...s}),this.incident=t,this.collection=i||new c.TicketList({params:{incident:this.incident.get("id"),sort:"-created"}})}async onInit(){this.ticketsTable=new r.ListView({containerId:"tickets-table",collection:this.collection,itemClass:TicketListItem,clickAction:"view",rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},paginated:!0,pageSize:10,emptyMessage:"No tickets linked to this incident."}),this.addChild(this.ticketsTable)}async onActionCreateTicket(){this.emit("action:create-ticket")}}class RelatedIncidentsSection extends t.View{constructor(e={}){const{collection:t,...i}=e;super({className:"related-incidents-section",template:'\n <div class="detail-section-eyebrow">Related Incidents</div>\n <div class="text-secondary small mb-2">Incidents sharing the same source IP, rule, host, or category.</div>\n <div data-container="related-table"></div>\n ',...i}),this.collection=t}async onInit(){this.relatedTable=new r.ListView({containerId:"related-table",collection:this.collection,itemClass:IncidentListItem,clickAction:"view",rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},paginated:!0,pageSize:10,emptyMessage:"No related incidents found."}),this.addChild(this.relatedTable)}}class IncidentHistorySection extends t.View{constructor(e={}){const{incidentId:t,...i}=e;super({className:"incident-history-section",template:'\n <div class="detail-section-eyebrow">History</div>\n <div data-container="history-chat"></div>\n ',...i}),this.incidentId=t}async onInit(){this.adapter=new IncidentHistoryAdapter(this.incidentId),this.chatView=new n.ChatView({containerId:"history-chat",adapter:this.adapter}),this.addChild(this.chatView)}}class IncidentMetadataSection extends t.View{constructor(e={}){super({className:"incident-metadata-section",template:'\n <div class="detail-section-eyebrow">\n Metadata\n <button class="detail-section-action" data-action="edit-metadata" data-bs-toggle="tooltip" title="{{#hasMetadata|bool}}Edit JSON{{/hasMetadata|bool}}{{^hasMetadata|bool}}Add metadata{{/hasMetadata|bool}}">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n {{^hasMetadata|bool}}\n <div class="text-secondary small">No metadata is set on this incident.</div>\n {{/hasMetadata|bool}}\n {{#hasMetadata|bool}}\n <div data-container="metadata-card"></div>\n {{/hasMetadata|bool}}\n ',...e})}get hasMetadata(){const e=this.model?.get?.("metadata")||{};return Object.keys(e).length>0}async onInit(){this.metadataCard=new n.KnownFieldsCard({containerId:"metadata-card",model:this.model,data:e=>e.get("metadata")||{},knownKeys:[{key:"source_ip",label:"Source IP",formatter:e=>`<code>${X(String(e))}</code>`},{key:"hostname",label:"Hostname",formatter:e=>`<code>${X(String(e))}</code>`},{key:"user_agent",label:"User agent"},{key:"http_url",label:"URL",formatter:e=>`<code>${X(String(e))}</code>`},{key:"http_method",label:"HTTP method",formatter:e=>`<span class="badge bg-info text-dark">${X(String(e))}</span>`},{key:"http_status",label:"HTTP status",formatter:e=>`<code>${X(String(e))}</code>`},{key:"country_code",label:"Country"},{key:"region",label:"Region"},{key:"city",label:"City"},{key:"request_path",label:"Request path",formatter:e=>`<code>${X(String(e))}</code>`},{key:"user",label:"User"},{key:"component",label:"Component"},{key:"component_id",label:"Component ID"},{key:"error_class",label:"Error class",formatter:e=>`<code>${X(String(e))}</code>`},{key:"error_message",label:"Error message"},{key:"rule_id",label:"Rule ID"},{key:"risk_score",label:"Risk score"},{key:"action",label:"Action"},{key:"trigger",label:"Trigger"},{key:"do_not_delete",label:"Protected",formatter:"yesnoicon",hideEmpty:!0}],rawLabel:"Raw metadata"}),this.addChild(this.metadataCard)}async onActionEditMetadata(){this.emit("action:edit-metadata")}}class IncidentView extends n.DetailView{constructor(e={}){const t=e.model||new c.Incident(e.data||{}),i=t.get("id"),s=ae(t),a=t.get("metadata")||{},n=new c.IncidentEventList({params:{incident:i}}),o=new c.TicketList({params:{incident:i,sort:"-created"}}),l=new c.RelatedIncidentsList({sourceIp:s||void 0,ruleSet:(t.get("rule_set")&&"object"==typeof t.get("rule_set")?t.get("rule_set").id:t.get("rule_set"))||void 0,group:t.get("group")||void 0,hostname:t.get("hostname")||void 0,category:s?void 0:t.get("category")||void 0,params:{id__not:i,size:10}}),d=new IncidentOverviewSection({model:t}),h=new r.ListView({collection:n,itemClass:EventListItem,clickAction:"view",rowStripe:e=>{const t=Number(e.get("level"));return t>=5?"danger":t>=4?"warning":null},paginated:!0,pageSize:10,emptyMessage:"No events on this incident."}),u=s?new IncidentSourceSection({sourceIP:s,ipInfo:t.get("ip_info")}):null,m=a.http_method||a.http_path||a.http_url?new IncidentRequestSection({metadata:a}):null,p=a.stack_trace||a.traceback||"",b=p?new IncidentStackTraceSection({stackTrace:p}):null,g=new RuleEngineSection({incident:t}),v=new IncidentTicketsSection({incident:t,collection:o}),y=new IncidentHistorySection({incidentId:i}),w=new RelatedIncidentsSection({collection:l}),f=new IncidentMetadataSection({model:t}),_=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:d},{key:"Events",label:"Events",icon:"bi-list-ul",view:h}];(u||m||b)&&(_.push({type:"divider",label:"Investigation"}),u&&_.push({key:"Source",label:"Source",icon:"bi-globe2",view:u}),m&&_.push({key:"Request",label:"Request",icon:"bi-funnel",view:m}),b&&_.push({key:"StackTrace",label:"Stack Trace",icon:"bi-code-square",view:b})),_.push({type:"divider",label:"Response"}),_.push({key:"RuleEngine",label:"Rule Engine",icon:"bi-tools",view:g}),_.push({key:"Tickets",label:"Tickets",icon:"bi-ticket-detailed",view:v}),_.push({key:"History",label:"History",icon:"bi-chat-left-text",view:y}),_.push({type:"divider",label:"Related"}),_.push({key:"Related",label:"Related",icon:"bi-diagram-2",view:w}),_.push({key:"Metadata",label:"Metadata",icon:"bi-braces",view:f});const k=[{icon:"bi-flag-fill",text:e=>{const t=parseInt(e.get("priority"),10);return t?`P${t} · ${re(t).label}`:null},variant:e=>re(parseInt(e.get("priority"),10)||5).variant},{icon:"bi-circle-fill",text:e=>le(e.get("status")).label,variant:e=>{const t=le(e.get("status")).tone;return"success"===t?"success":"danger"===t?"danger":"warning"===t?"warning":"light"}},{icon:"bi-tag-fill",textPath:"category",variant:"light",when:e=>!!e.get("category")},{textPath:"scope",variant:"light",when:e=>!!e.get("scope")&&e.get("scope")!==e.get("category")},{icon:"bi-hdd-network",textPath:"hostname",variant:"light",when:e=>!!e.get("hostname")},{icon:"bi-list-ul",text:e=>{const t=e.get("event_count");return null!=t?`${t} ${1===t?"event":"events"}`:null},variant:"light"},{icon:"bi-shield-fill-check",text:"Protected",variant:"warning",when:e=>!!e.get("metadata")?.do_not_delete},{icon:"bi-shield-lock",text:"TOR",variant:"danger",tooltip:"Source IP is a Tor exit node",when:e=>!!e.get("_sourceGeo")?.is_tor},{icon:"bi-shield-shaded",text:"VPN",variant:"warning",tooltip:"Source IP is a VPN exit node",when:e=>!!e.get("_sourceGeo")?.is_vpn},{icon:"bi-diagram-3",text:"Proxy",variant:"warning",tooltip:"Source IP is an open proxy",when:e=>!!e.get("_sourceGeo")?.is_proxy},{icon:"bi-hdd-stack",text:"Datacenter",variant:"warning",tooltip:"Source IP belongs to a datacenter range",when:e=>!!e.get("_sourceGeo")?.is_datacenter},{icon:"bi-cloud-fill",text:"Cloud",variant:"info",tooltip:"Source IP belongs to a cloud-provider range",when:e=>!!e.get("_sourceGeo")?.is_cloud},{icon:"bi-phone",text:"Mobile",variant:"light",tooltip:"Source IP is a mobile carrier range",when:e=>!!e.get("_sourceGeo")?.is_mobile},{icon:"bi-bug-fill",text:"Known attacker",variant:"danger",tooltip:"Source IP appears in attacker feeds",when:e=>!!e.get("_sourceGeo")?.is_known_attacker},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",tooltip:"Source IP is currently blocked",when:e=>!!e.get("_sourceGeo")?.is_blocked},{icon:"bi-shield-check",text:"Whitelisted",variant:"success",tooltip:"Source IP is on the whitelist",when:e=>!!e.get("_sourceGeo")?.is_whitelisted}],x=[];x.push({label:"Re-run handler chain",action:"rerun-handler",icon:"bi-arrow-clockwise"}),s&&x.push({label:`View source IP (${s})`,action:"view-source-geoip",icon:"bi-globe"});const S=a.user||a.email||a.username;S&&x.push({label:`View user (${te(String(S),30)})`,action:"view-user",icon:"bi-person"}),t.get("rule_set")&&x.push({label:"View ruleset",action:"view-ruleset",icon:"bi-gear-wide-connected"}),x.push({type:"divider"}),x.push({label:"Mark as duplicate",action:"mark-duplicate",icon:"bi-files"}),x.push({label:"Snooze",action:"snooze",icon:"bi-moon"}),x.push({label:"Edit Incident",action:"edit-incident",icon:"bi-pencil"}),x.push({label:"Change Priority",action:"change-priority",icon:"bi-arrow-up-circle"}),a.do_not_delete?x.push({label:"Remove Protection",action:"remove-protection",icon:"bi-shield"}):x.push({label:"Protect from Deletion",action:"protect-incident",icon:"bi-shield-fill-check"}),x.push({label:"Create Ticket",action:"create-ticket",icon:"bi-ticket-perforated"}),x.push({label:"Merge Incidents",action:"merge-incidents",icon:"bi-union"}),x.push({type:"divider"}),x.push({label:"Ask AI",action:"ask-ai",icon:"bi-chat-dots"}),x.push({label:"LLM Analyze",action:"analyze-llm",icon:"bi-robot"}),x.push({type:"divider"}),x.push({label:a.do_not_delete?"Delete Incident (protected)":"Delete Incident",action:"delete-incident",icon:"bi-trash",danger:!0,disabled:!!a.do_not_delete}),super({className:"incident-view",...e,model:t,header:{icon:ce(t.get("status")).icon,iconToneFn:e=>ce(e.get("status")).tone,titleFn:e=>e.get("title")||e.get("category")||`Incident #${e.get("id")||""}`.trim(),subtitleFn:e=>function(e){const t=[],i=e.get("rule_set"),s=i&&"object"==typeof i?i.name:null,a=e.get("category");return s?t.push(`Triggered by rule ${s}`):i&&t.push(`Rule #${"object"==typeof i?i.id:i}`),a&&t.push(`scope ${a}`),e.get("id")&&t.push(`#${e.get("id")}`),t.join(" · ")}(e),chips:IncidentView._adaptChips(k),auxFn:e=>function(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=ce(t).tone||"secondary",a=Number(e.get("created"))||0,n=Number(e.get("modified"))||0;let o=i.label,l="";if(oe.has(t)){const e=a?se(Math.max(0,Math.floor(Date.now()/1e3)-a)):"";l=e?`in flight ${e}`:""}else"resolved"===t||"closed"===t?l=n?`${ie(n)}`:"":n&&(l=`updated ${ie(n)}`);return o?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${"default"!==s?` dh-aux-dot-${s}`:""}"></span>\n <span>${X(o)}</span>\n </span>\n ${l?`<span class="dh-aux-meta">${X(l)}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:x}},sections:_,activeSection:"Overview",navWidth:200,minWidth:600}),this.eventsCollection=n,this.ticketsCollection=o,this.relatedCollection=l,this.overviewSection=d,this.eventsSection=h,this.sourceSection=u,this.requestSection=m,this.stackTraceSection=b,this.ruleEngineSection=g,this.ticketsSection=v,this.historySection=y,this.relatedSection=w,this.metadataSection=f,this._sourceIP=s}static _adaptChips(e){return e.map(e=>{if("function"!=typeof e.variant)return e;const t={...e},i=e.variant;let s;const a=e.text;return t.text=e=>(s=e,"function"==typeof a?a(e):a),Object.defineProperty(t,"variant",{get:()=>s?i(s):"light",enumerable:!0}),t})}async onAfterBuild(){this.overviewSection.on("action:resolve",()=>this.onActionResolve()),this.overviewSection.on("action:assign",()=>this.onActionAssign()),this.overviewSection.on("action:reopen",()=>this.onActionReopen()),this.overviewSection.on("action:view-ruleset",()=>this.onActionViewRuleset()),this.overviewSection.on("action:view-source-ip",()=>this.onActionViewSourceGeoip()),this.overviewSection.on("action:view-user",e=>this._openUser(e)),this.overviewSection.on("action:view-ticket",e=>this._openTicket(e)),this.overviewSection.on("action:analyze-llm",()=>this.onActionAnalyzeLlm()),this.ticketsSection.on("action:create-ticket",()=>this._handleCreateTicket()),this.metadataSection.on("action:edit-metadata",()=>this.onActionEditMetadata()),this._updateBadges(),this.eventsCollection.on("fetch:success",()=>this._updateBadges(),this),this.ticketsCollection.on("fetch:success",()=>this._updateBadges(),this),this.relatedCollection.on("fetch:success",()=>this._updateBadges(),this);try{this.getApp()?.showLoading?.(),await this.model.fetch({params:{graph:"detailed"}})}catch(e){}finally{this.getApp()?.hideLoading?.()}if(this.eventsCollection.fetch().catch(()=>{}),this.ticketsCollection.fetch().catch(()=>{}),this.relatedCollection.fetch().catch(()=>{}),this._sourceIP)try{const e=await n.GeoLocatedIP.lookup(this._sourceIP);e?.attributes&&(this.model.attributes._sourceGeo=e.attributes)}catch(e){}this.headerView?.isMounted()&&await this.headerView.render()}_updateBadges(){const e=this.eventsCollection.totalCount??this.eventsCollection.models?.length??0,t=this.ticketsCollection.totalCount??this.ticketsCollection.models?.length??0,i=this.relatedCollection.totalCount??this.relatedCollection.models?.length??0;e>0&&this.setBadge("Events",{text:String(e),variant:e>10?"danger":"muted"}),t>0&&this.setBadge("Tickets",{text:String(t),variant:"warning"}),i>0&&this.setBadge("Related",{text:String(i),variant:"muted"})}async _refreshFromModel(){this.headerView.icon=ce(this.model.get("status")).icon,this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render()}async onActionResolve(){await this._setStatus("resolved")}async onActionAssign(){const e=await a.Modal.form({title:`Assign Incident #${this.model.get("id")}`,icon:"bi-person",size:"sm",fields:[{name:"assignee",type:"text",label:"Assignee (username or email)",required:!0}]});e&&(await this.model.save({"metadata.assignee":e.assignee,status:"investigating"}),this.getApp()?.toast?.success(`Assigned to ${e.assignee}`),await this._refreshFromModel(),this.emit("detail:updated"))}async onActionReopen(){await this._setStatus("open")}async _setStatus(e){await this.model.save({status:e}),this.getApp()?.toast?.success(`Status changed to ${e}`),await this._refreshFromModel(),this.emit("detail:updated")}async onActionRerunHandler(){if(await a.Modal.confirm("Re-run the handler chain for this incident? The configured handlers will fire again as if the incident just triggered.","Re-run handler chain"))try{const e=await this.model.save({rerun_handler:1});e.success||200===e.status?(this.getApp()?.toast?.success("Handler chain re-run requested"),await this.model.fetch({params:{graph:"detailed"}}),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to re-run handler chain")}catch(e){this.getApp()?.toast?.error(e.message||"Failed to re-run handler chain")}}async onActionViewSourceGeoip(){this._sourceIP&&await GeoIPView.show(this._sourceIP)}async onActionViewUser(){const e=this.model.get("metadata")||{},t=e.user||e.email||e.username;await this._openUser(t)}async _openUser(e){if(e)try{const{default:i}=await Promise.resolve().then(()=>E),{User:s}=await Promise.resolve().then(()=>require("./chunks/User-B1rsVKZn.js")).then(e=>e.User$1),n=new s({email:e});try{await n.fetch({params:{email:e}})}catch(t){}await a.Modal.detail(new i({model:n}))}catch(i){this.getApp()?.toast?.info(`User: ${e}`)}else this.getApp()?.toast?.warning("No user attached to this incident")}async _openTicket(e){if(e)try{const t=new c.Ticket({id:e});await t.fetch();const{default:i}=await Promise.resolve().then(()=>me);await a.Modal.show(new i({model:t}),{size:"lg"})}catch(t){this.getApp()?.toast?.error("Could not load ticket")}}async onActionViewRuleset(){const e=this.model.get("rule_set"),t=e&&"object"==typeof e?e.id:e;if(t)try{const e=new c.RuleSet({id:t});await e.fetch(),await a.Modal.detail(new RuleSetView({model:e}))}catch(i){this.getApp()?.toast?.error("Could not load RuleSet")}}async onActionMarkDuplicate(){const e=await a.Modal.form({title:"Mark as duplicate",icon:"bi-files",size:"sm",fields:[{name:"parent_id",type:"number",label:"Parent incident ID",required:!0,help:"Events from this incident will be merged into the parent."}]});if(!e)return;const t=parseInt(e.parent_id,10);if(t&&t!==this.model.id)try{const e=new c.Incident({id:t}),i=await e.save({merge:[this.model.id]});i.success||200===i.status?(this.getApp()?.toast?.success(`Marked as duplicate of #${t}`),this.emit("incident:deleted",{model:this.model}),this._closeModal()):this.getApp()?.toast?.error("Failed to mark as duplicate")}catch(i){this.getApp()?.toast?.error(i.message||"Failed to mark as duplicate")}}async onActionSnooze(){const e=await a.Modal.form({title:"Snooze incident",icon:"bi-moon",size:"sm",fields:[{name:"until",type:"select",label:"Snooze for",options:[{value:3600,label:"1 hour"},{value:14400,label:"4 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"}],value:86400}]});if(!e)return;const t=Math.floor(Date.now()/1e3)+parseInt(e.until,10);await this.model.save({status:"paused","metadata.snooze_until":t}),this.getApp()?.toast?.success("Incident snoozed"),await this._refreshFromModel()}async onActionEditIncident(){await a.Modal.modelForm({title:`Edit Incident #${this.model.id}`,model:this.model,formConfig:c.IncidentForms.edit})&&(await this._refreshFromModel(),this.emit("detail:updated"))}async onActionChangePriority(){const e=await a.Modal.form({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}`),await this._refreshFromModel())}async onActionProtectIncident(){await this.model.save({"metadata.do_not_delete":!0}),this.getApp()?.toast?.success("Incident protected from deletion"),await this._refreshFromModel()}async onActionRemoveProtection(){await this.model.save({"metadata.do_not_delete":!1}),this.getApp()?.toast?.success("Deletion protection removed"),await this._refreshFromModel()}async onActionCreateTicket(){await this._handleCreateTicket()}async _handleCreateTicket(){const e=this.model,t=`Incident #${e.get("id")}: ${e.get("category")||e.get("title")||"Investigation"}`,i=await a.Modal.form({...c.TicketForms.create,fields:c.TicketForms.create.fields.map(i=>"title"===i.name?{...i,value:t}:"category"===i.name?{...i,value:"incident"}:"priority"===i.name?{...i,value:e.get("priority")||5}:"incident"===i.name?{...i,value:e.get("id"),type:"hidden"}:i)});if(!i)return;const s=new c.Ticket,n=await s.save({...i,incident:e.get("id")});n.success||200===n.status?(this.getApp()?.toast?.success("Ticket created"),this.ticketsCollection.fetch()):this.getApp()?.toast?.error("Failed to create ticket")}async onActionMergeIncidents(){const e=await a.Modal.form({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."}]});if(!e)return;const t=e.merge_ids.split(",").map(e=>parseInt(e.trim())).filter(e=>e&&e!==this.model.id);if(!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)`),await this._refreshFromModel()):this.getApp()?.toast?.error("Merge failed")}async onActionAskAi(){await Z(this,"incident.Incident")}async onActionAnalyzeLlm(){if(!(await a.Modal.confirm('Run LLM analysis on this incident? The AI agent will review all events, attempt to merge related incidents, and propose a new rule. 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)return void e?.toast?.error(t.data?.error||t.error||"Failed to start analysis");e?.toast?.success("LLM analysis started — polling for results…"),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._refreshFromModel();t()}).catch(()=>t())},5e3)};t()}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. Backend auto-merges keys.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }'}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{const e=await this.model.save({metadata:s});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._refreshFromModel(),this.metadataSection?.isMounted()&&await this.metadataSection.render()}catch(n){this.getApp()?.toast?.error(`Failed to save metadata: ${n.message}`)}}async onActionDeleteIncident(){this.model.get("metadata")?.do_not_delete?this.getApp()?.toast?.warning("Remove protection before deleting"):await a.Modal.confirm(`Are you sure you want to delete incident #${this.model.id}? This cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&(this.emit("incident:deleted",{model:this.model}),this._closeModal())}_closeModal(){const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}}c.Incident.VIEW_CLASS=IncidentView,c.Incident.MODEL_REF="incident.Incident";class PriorityQueueView extends t.View{constructor(e={}){super({...e,className:`sd-priority-queue ${e.className||""}`.trim()}),this.allowActions=!1!==e.allowActions,this.size=e.size||8,this.collection=new c.IncidentList({params:{priority__gte:8,status__in:"new",sort:"-created",size:this.size}}),this.items=[],this.isLoading=!0,this.hasError=!1,this.error="",this.isEmpty=!1,this.showActions=this.allowActions}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Needs Attention</h3>\n <span class="card-subtitle text-muted small">Top critical &amp; high-priority incidents</span>\n </div>\n <a class="text-muted small" href="?page=system/incidents">All incidents <i class="bi bi-arrow-right-short"></i></a>\n </div>\n <div class="card-body p-0" data-region="list">\n {{#isLoading|bool}}<div class="p-4 text-center text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading…</div>{{/isLoading|bool}}\n {{#hasError|bool}}<div class="p-3"><div class="alert alert-warning small mb-0">{{error}}</div></div>{{/hasError|bool}}\n {{#isEmpty|bool}}<div class="p-4 text-center text-success small"><i class="bi bi-check-circle me-1"></i>Nothing critical right now.</div>{{/isEmpty|bool}}\n <ol class="sd-priority-list list-unstyled mb-0">\n {{#items}}\n <li class="sd-pri-row" data-action="open-incident" data-id="{{id}}">\n <span class="sd-pri sd-pri-{{severityClass}}">{{severityLabel}}&nbsp;{{priority}}</span>\n <div class="sd-pri-body">\n <span class="sd-pri-title">{{title}}</span>\n <span class="sd-pri-meta">{{ageLabel}} · <span class="text-body sd-mono">{{sourceIp}}</span> · {{eventCount}} events</span>\n </div>\n {{#showActions|bool}}\n <span class="sd-pri-actions">\n <button type="button" class="btn btn-sm btn-link text-success p-1" data-action="resolve-incident" data-id="{{id}}" title="Resolve"><i class="bi bi-check2"></i></button>\n <button type="button" class="btn btn-sm btn-link text-secondary p-1" data-action="pause-incident" data-id="{{id}}" title="Pause"><i class="bi bi-pause"></i></button>\n </span>\n {{/showActions|bool}}\n </li>\n {{/items}}\n </ol>\n </div>\n </div>\n '}async onInit(){await this._fetchSafely()}async _fetchSafely(){try{await this.collection.fetch();const e=this.collection.models||[];this.items=e.map(e=>this._rowFor(e)),this.isLoading=!1,this.hasError=!1,this.error="",this.isEmpty=0===this.items.length}catch(e){console.warn("[PriorityQueueView] fetch failed:",e),this.items=[],this.isLoading=!1,this.hasError=!0,this.error="Could not load incidents.",this.isEmpty=!1}}_rowFor(e){const t=parseInt(e.get("priority"),10)||0,i=t>=12?"critical":t>=8?"high":t>=4?"warn":"info",s="critical"===i?"CRIT":i.toUpperCase();return{id:e.id,title:e.get("title")||e.get("details")||`Incident #${e.id}`,priority:t,severityClass:i,severityLabel:s,ageLabel:this._relativeTime(e.get("created")),sourceIp:e.get("source_ip")||e.get("metadata")?.source_ip||"—",eventCount:e.get("event_count")??e.get("metadata")?.event_count??"—"}}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async refresh(){await this._fetchSafely(),this.isMounted()&&await this.render()}async onActionOpenIncident(e,t){if(e.target.closest('[data-action="resolve-incident"], [data-action="pause-incident"]'))return;const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});if(await s.fetch(),!s.id)return;const n=new IncidentView({model:s});await a.Modal.detail(n)}async onActionResolveIncident(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;if(!(await a.Modal.confirm("Mark this incident as resolved?")))return;const s=new c.Incident({id:i});await s.save({status:"resolved"}),await this.refresh()}async onActionPauseIncident(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});await s.save({status:"paused"}),await this.refresh()}}const de=["new","open","in_progress","pending","resolved","qa","closed","ignored"],he={new:"new",open:"open",in_progress:"prog",pending:"prog",resolved:"resolved",qa:"open",closed:"closed",ignored:"closed"},ue=[{value:10,label:"P10 — Critical"},{value:9,label:"P9 — Severe"},{value:8,label:"P8 — High"},{value:7,label:"P7 — Elevated"},{value:5,label:"P5 — Normal"},{value:3,label:"P3 — Low"},{value:1,label:"P1 — Info"}];class TicketView extends t.View{constructor(e={}){super({className:"ticket-view",...e}),this.model=e.model||new c.Ticket(e.data||{})}async onBeforeRender(){const e=this.model.get("status")||"new";this.statusPillClass=he[e]||"closed",this.statusLabel=e.replace(/_/g," ");const i=this.model.get("priority")||5;this.priorityLabel=`P${i}`,this.priorityClass=i>=8?"text-danger":i>=7?"text-warning":"text-secondary";const s=this.model.get("assignee");this.assigneeName=s?.display_name||("string"==typeof s?s:null)||"Unassigned",this.categoryLabel=this.model.get("category")||"ticket",this.groupName=this.model.get("group.name")||this.model.get("group")||"None",this.hasDescription=!!this.model.get("description"),this.descriptionHtml=await async function(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}(this.model.get("description")||""),this.noteCount=this.model.get("note_count")||0,this.hasPanelSupport=!!this.getApp()?.openTicketPanel;const a=this.model.get("incident");a&&"object"==typeof a&&a.id?(this.hasLinkedIncident=!0,this.linkedIncident={id:a.id,title:a.title||"Untitled",status:a.status||"unknown",priority:a.priority}):this.hasLinkedIncident=!1,this.template='\n <div class="tv-header">\n <div class="tv-title-row">\n <div class="tv-title-block">\n <div class="tv-id-row">\n <span class="tv-id">#{{model.id}}</span>\n <span class="tv-pill tv-pill-{{statusPillClass}}" data-action="change-status" title="Change status">\n {{statusLabel}} <i class="bi bi-chevron-down"></i>\n </span>\n {{#model.created}}\n <span class="tv-time"><i class="bi bi-clock"></i>{{model.created|relative}}</span>\n {{/model.created}}\n <span class="tv-time"><i class="bi bi-chat-left-text"></i>{{noteCount}} note{{#noteCount}}{{/noteCount}}s</span>\n </div>\n <div class="tv-title">{{model.title}}</div>\n <div class="tv-fields">\n <span class="tv-field" data-action="change-priority" title="Change priority">\n <i class="bi bi-flag-fill tv-field-icon {{priorityClass}}"></i>{{priorityLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-assignee" title="Assign user">\n <i class="bi bi-person tv-field-icon"></i>{{assigneeName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-category" title="Change category">\n <i class="bi bi-tag tv-field-icon"></i>{{categoryLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-group" title="Change group">\n <i class="bi bi-people tv-field-icon"></i>{{groupName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n </div>\n </div>\n <div class="tv-btns">\n <button class="tv-btn" data-action="ask-ai" title="Ask AI">\n <i class="bi bi-robot"></i>\n </button>\n {{#hasPanelSupport|bool}}\n <button class="tv-btn" data-action="open-panel" title="Open in side panel">\n <i class="bi bi-layout-sidebar-reverse"></i>\n </button>\n {{/hasPanelSupport|bool}}\n <div data-container="ticket-context-menu"></div>\n </div>\n </div>\n </div>\n\n <div class="tv-body">\n {{#hasLinkedIncident|bool}}\n <div class="tv-linked" data-action="open-incident" title="Open linked incident">\n <i class="bi bi-exclamation-triangle-fill tv-linked-icon"></i>\n <span class="ltitle">Incident #{{linkedIncident.id}} &middot; {{linkedIncident.title}}</span>\n <span class="lpill">{{linkedIncident.status}}</span>\n {{#linkedIncident.priority}}\n <span class="ltrail">P{{linkedIncident.priority}}</span>\n {{/linkedIncident.priority}}\n <i class="bi bi-box-arrow-up-right ltrail"></i>\n </div>\n {{/hasLinkedIncident|bool}}\n\n <div class="tv-desc">\n <button class="tv-desc-edit" data-action="edit-description" title="Edit description">\n <i class="bi bi-pencil me-1"></i>Edit\n </button>\n {{#hasDescription|bool}}\n <div class="tv-desc-body">{{{descriptionHtml}}}</div>\n {{/hasDescription|bool}}\n {{^hasDescription|bool}}\n <div class="tv-desc-empty tv-desc-add" data-action="edit-description">\n <i class="bi bi-plus-circle me-1"></i>Add description\n </div>\n {{/hasDescription|bool}}\n </div>\n </div>\n '}async onInit(){const t=new e.ContextMenu({containerId:"ticket-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",btnClass:"tv-btn",items:[{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{label:"Edit Description",action:"edit-description",icon:"bi-file-text"},{type:"divider"},{label:"Close Ticket",action:"close-ticket",icon:"bi-x-circle",class:"text-danger"}]}});this.addChild(t)}async _saveAndSync(e){await this.model.save(e);try{await this.model.fetch()}catch(t){}this.render()}async _showInlineSelect(t,i){return new Promise(s=>{let a=!1;const n=new e.ContextMenu({config:{items:t.map((e,t)=>({label:e.label,action:`pick-${t}`,class:e.active?"fw-bold":"",handler:()=>{a=!0,this.removeChild(n),s(e.value)}}))}}),o=n.closeDropdown.bind(n);n.closeDropdown=()=>{o(),a||(this.removeChild(n),s(null))},this.addChild(n),n.openAt(i.clientX,i.clientY)})}async onActionChangeStatus(e){const t=de.map(e=>({label:e.replace(/_/g," "),value:e,active:e===this.model.get("status")})),i=await this._showInlineSelect(t,e);i&&await this._saveAndSync({status:i})}async onActionChangePriority(e){const t=ue.map(e=>({label:e.label,value:e.value,active:e.value===this.model.get("priority")})),i=await this._showInlineSelect(t,e);null!=i&&await this._saveAndSync({priority:parseInt(i)})}async onActionChangeCategory(e){const t=Object.entries(c.TicketCategories).map(([e,t])=>({label:t,value:e,active:e===this.model.get("category")})),i=await this._showInlineSelect(t,e);i&&await this._saveAndSync({category:i})}async onActionChangeAssignee(){const e=await a.Modal.form({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:t.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this._saveAndSync({assignee:e.assignee}),this.getApp()?.toast?.success("Ticket assigned"))}async onActionChangeGroup(){const e=await a.Modal.form({title:"Change Group",icon:"bi-people",size:"sm",fields:[{name:"group",type:"collection",label:"Group",Collection:t.GroupList,labelField:"name",valueField:"id",required:!1,cols:12,value:this.model.get("group")}]});e&&await this._saveAndSync({group:e.group})}async onActionEditDescription(){const e=`\n <textarea data-ref="desc-textarea" rows="14" placeholder="Description (markdown supported)..."\n style="width:100%; font-family: var(--bs-font-monospace); font-size: 0.85rem; padding: 10px 12px; border: 1px solid var(--bs-border-color); border-radius: 8px; background: var(--bs-body-bg); color: var(--bs-body-color); resize: vertical; outline: none;">${(this.model.get("description")||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</textarea>\n <div class="text-muted small mt-1">Markdown supported</div>\n `,t=await a.Modal.dialog({title:`Ticket #${this.model.get("id")} — Edit Description`,body:e,size:"lg",buttons:[{text:"Cancel",class:"btn-secondary",value:null},{text:"Save",class:"btn-primary",handler:()=>{const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');return e?e.value:null}}]});null!=t&&await this._saveAndSync({description:t})}async onActionOpenPanel(){const e=this.getApp();if(!e?.openTicketPanel)return;const t=this.element?.closest(".modal");if(t){const e=window.bootstrap?.Modal?.getInstance(t);e&&e.hide()}e.openTicketPanel(this.model)}async onActionOpenIncident(){if(this.linkedIncident?.id)try{const e=new c.Incident({id:this.linkedIncident.id});await e.fetch({params:{graph:"detailed"}});const t=new IncidentView({model:e});await a.Modal.detail(t)}catch(e){this.getApp()?.toast?.error("Failed to load incident")}}async onActionAskAi(){await Z(this,"incident.Ticket")}async onActionEditTicket(){await a.Modal.modelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:c.TicketForms.edit.fields})&&this.render()}async onActionCloseTicket(){await a.Modal.confirm(`Close ticket #${this.model.get("id")}?`,"Close Ticket",{confirmText:"Close",confirmClass:"btn-warning"})&&(await this._saveAndSync({status:"closed"}),this.getApp()?.toast?.success("Ticket closed"))}}c.Ticket.VIEW_CLASS=TicketView,c.Ticket.MODEL_REF="incident.Ticket";const me=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,default:TicketView},Symbol.toStringTag,{value:"Module"}));class TicketsQueueView extends t.View{constructor(e={}){super({...e,className:`sd-tickets-queue ${e.className||""}`.trim()}),this.allowActions=!1!==e.allowActions,this.size=e.size||8,this.collection=new c.TicketList({params:{status__in:"new,open",sort:"-priority,-created",size:this.size}}),this.items=[],this.isLoading=!0,this.hasError=!1,this.error="",this.isEmpty=!1,this.showActions=this.allowActions}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Open Tickets</h3>\n <span class="card-subtitle text-muted small">Human-actionable items</span>\n </div>\n <a class="text-muted small" href="?page=system/tickets">All tickets <i class="bi bi-arrow-right-short"></i></a>\n </div>\n <div class="card-body p-0" data-region="list">\n {{#isLoading|bool}}<div class="p-4 text-center text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading…</div>{{/isLoading|bool}}\n {{#hasError|bool}}<div class="p-3"><div class="alert alert-warning small mb-0">{{error}}</div></div>{{/hasError|bool}}\n {{#isEmpty|bool}}<div class="p-4 text-center text-success small"><i class="bi bi-check-circle me-1"></i>No open tickets.</div>{{/isEmpty|bool}}\n <ol class="sd-priority-list list-unstyled mb-0">\n {{#items}}\n <li class="sd-pri-row" data-action="open-ticket" data-id="{{id}}">\n <span class="sd-pri sd-pri-{{severityClass}}">{{severityLabel}}&nbsp;{{priority}}</span>\n <div class="sd-pri-body">\n <span class="sd-pri-title">{{title}}</span>\n <span class="sd-pri-meta">{{ageLabel}} · {{assigneeLabel}}</span>\n </div>\n {{#showActions|bool}}\n <span class="sd-pri-actions">\n <button type="button" class="btn btn-sm btn-link text-success p-1" data-action="resolve-ticket" data-id="{{id}}" title="Resolve"><i class="bi bi-check2"></i></button>\n <button type="button" class="btn btn-sm btn-link text-secondary p-1" data-action="pause-ticket" data-id="{{id}}" title="Pause"><i class="bi bi-pause"></i></button>\n </span>\n {{/showActions|bool}}\n </li>\n {{/items}}\n </ol>\n </div>\n </div>\n '}async onInit(){await this._fetchSafely()}async _fetchSafely(){try{await this.collection.fetch();const e=this.collection.models||[];this.items=e.map(e=>this._rowFor(e)),this.isLoading=!1,this.hasError=!1,this.error="",this.isEmpty=0===this.items.length}catch(e){console.warn("[TicketsQueueView] fetch failed:",e),this.items=[],this.isLoading=!1,this.hasError=!0,this.error="Could not load tickets.",this.isEmpty=!1}}_rowFor(e){const t=parseInt(e.get("priority"),10)||0,i=t>=12?"critical":t>=8?"high":t>=4?"warn":"info",s="critical"===i?"CRIT":i.toUpperCase(),a=e.get("assignee"),n=a?.display_name||a?.username;return{id:e.id,title:e.get("title")||`Ticket #${e.id}`,priority:t,severityClass:i,severityLabel:s,ageLabel:this._relativeTime(e.get("created")),assigneeLabel:n?`assigned to ${n}`:"unassigned"}}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async refresh(){await this._fetchSafely(),this.isMounted()&&await this.render()}async onActionOpenTicket(e,t){if(e.target.closest('[data-action="resolve-ticket"], [data-action="pause-ticket"]'))return;const i=t.dataset.id;if(!i)return;const s=new c.Ticket({id:i});if(await s.fetch(),!s.id)return;const n=new TicketView({model:s});await a.Modal.show(n,{size:"xl",header:!1})}async onActionResolveTicket(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;if(!(await a.Modal.confirm("Mark this ticket as resolved?")))return;const s=new c.Ticket({id:i});await s.save({status:"resolved"}),await this.refresh()}async onActionPauseTicket(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;const s=new c.Ticket({id:i});await s.save({status:"paused"}),await this.refresh()}}const pe=["incident_events","firewall:blocks","bouncer:blocks","auth:failures"];class ThreatCompositionChart extends t.View{constructor(e={}){super({...e,className:`sd-composition ${e.className||""}`.trim()}),this.range=e.range||"30d",this._reflectRange()}_reflectRange(){this.is7d="7d"===this.range,this.is30d="30d"===this.range,this.is90d="90d"===this.range}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Threat Composition</h3>\n <span class="card-subtitle text-muted small">Daily, stacked · click any day to drill in</span>\n </div>\n <div class="btn-group btn-group-sm" role="group" aria-label="Time range">\n <button type="button" class="btn btn-outline-secondary {{#is7d|bool}}active{{/is7d|bool}}" data-action="set-range" data-range="7d">7D</button>\n <button type="button" class="btn btn-outline-secondary {{#is30d|bool}}active{{/is30d|bool}}" data-action="set-range" data-range="30d">30D</button>\n <button type="button" class="btn btn-outline-secondary {{#is90d|bool}}active{{/is90d|bool}}" data-action="set-range" data-range="90d">90D</button>\n </div>\n </div>\n <div class="card-body" data-container="chart-host"></div>\n </div>\n '}async onInit(){this.chart=new i.MetricsChart({containerId:"chart-host",slugs:pe,account:"incident",granularity:"days",chartType:"bar",defaultDateRange:this.range,compactHeader:!0,showGranularity:!1,showTypeSwitch:!1,showDateRange:!1,legendPosition:"bottom",height:280,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number:0"},colors:["rgba(13, 202, 240, 0.85)","rgba(220, 53, 69, 0.85)","rgba(253, 126, 20, 0.85)","rgba(179, 136, 255, 0.85)"],title:""}),this.addChild(this.chart),this.chart.on?.("chart:click",e=>{this.emit?.("composition:bar-click",e),this._openDayDrawer(e)})}async onActionSetRange(e,t){const i=t.dataset.range;i&&i!==this.range&&(this.range=i,this._reflectRange(),this.chart?.setQuickRange?.(i),await(this.chart?.fetchData?.()),await this.render())}async refresh(){return this.chart?.fetchData?.()}_openDayDrawer({x:e,datasets:t}={}){const i=e?`Day Detail · ${e}`:"Day Detail",s=(t||[]).map(e=>`<div class="d-flex justify-content-between border-bottom py-1">\n <span class="text-muted small">${this._esc(e.label||"")}</span>\n <span class="sd-mono">${Number(e.value||0).toLocaleString()}</span>\n </div>`).join("");a.Modal.drawer({eyebrow:"Composition",title:i,meta:[{icon:"bi bi-bar-chart-line",text:pe.length+" series"}],body:`\n <p class="small text-muted mb-2">Per-series totals for this bucket. To drill into the underlying events, open the Events table.</p>\n ${s||'<div class="text-muted small">No breakdown available.</div>'}\n <div class="mt-3">\n <a href="?page=system/events" class="btn btn-sm btn-outline-primary"><i class="bi bi-list-ul me-1"></i>Open Events</a>\n </div>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}const be=[{key:"events",label:"Events",category:"incident_events_by_country",account:"incident"},{key:"incidents",label:"Incidents",category:"incidents_by_country",account:"incident"},{key:"firewall",label:"Firewall",category:"firewall",account:"incident"},{key:"bouncer",label:"Bouncer",category:"bouncer",account:"incident"},{key:"logins",label:"Logins",category:null,account:null}];class GeographyPanel extends t.View{constructor(e={}){super({...e,className:`sd-geography ${e.className||""}`.trim()}),this.activeFamily=e.family||"events",this.inlineMap=!0===e.inlineMap,this._reflectFamilies(),this.leaderboard=[],this.leaderboardEmpty=!0}_reflectFamilies(){this.families=be.map(e=>({...e,active:e.key===this.activeFamily}))}async getTemplate(){const e=this.inlineMap?`\n <div class="sd-geo-grid">\n <div class="sd-geo-map-cell" data-container="inline-map"></div>\n ${this._leaderboardHtml()}\n </div>`:`\n ${this._leaderboardHtml()}`;return`\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Geography</h3>\n <span class="card-subtitle text-muted small">Activity by country, last 7 days</span>\n </div>\n <div class="d-flex align-items-center gap-2">\n <div class="btn-group btn-group-sm" role="group" aria-label="Slug family">\n {{#families}}\n <button type="button"\n class="btn btn-outline-secondary {{#active|bool}}active{{/active|bool}}"\n data-action="set-family"\n data-family="{{key}}">{{label}}</button>\n {{/families}}\n </div>\n ${this.inlineMap?"":'\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="show-map" title="Show map">\n <i class="bi bi-globe-americas"></i>\n </button>'}\n </div>\n </div>\n <div class="card-body p-0">\n ${e}\n </div>\n </div>\n `}_leaderboardHtml(){return'\n <ol class="sd-geo-leader sd-geo-leader-full list-unstyled mb-0">\n {{#leaderboardEmpty|bool}}\n <li class="px-3 py-3 text-muted small">No country activity in window.</li>\n {{/leaderboardEmpty|bool}}\n {{#leaderboard}}\n <li class="sd-geo-leader-row" data-action="open-country" data-cc="{{cc}}" data-name="{{name}}" data-total="{{total}}">\n <span class="sd-geo-cc sd-mono">{{cc}}</span>\n <span class="sd-geo-name">{{name}}</span>\n <span class="sd-geo-num sd-mono">{{total}}</span>\n </li>\n {{/leaderboard}}\n </ol>\n '}async onInit(){if(this.inlineMap){if("logins"===this.activeFamily){const e=/* @__PURE__ */(new Date).toISOString().slice(0,10),t=new Date(Date.now()-6048e5).toISOString().slice(0,10);this.map=new LoginLocationMapView({containerId:"inline-map",height:360,mapStyle:"dark",drStart:t,drEnd:e})}else this.map=new s.MetricsCountryMapView({containerId:"inline-map",category:this._currentCategory(),account:this._currentAccount(),granularity:"days",maxCountries:20,metricLabel:this._currentLabel(),height:360,mapStyle:"dark",mapOptions:{interactive:!1}});this.addChild(this.map)}await this._fetchLeaderboard()}async onActionShowMap(){let e;if("logins"===this.activeFamily){const t=/* @__PURE__ */(new Date).toISOString().slice(0,10),i=new Date(Date.now()-6048e5).toISOString().slice(0,10);e=new LoginLocationMapView({height:560,mapStyle:"dark",drStart:i,drEnd:t})}else e=new s.MetricsCountryMapView({category:this._currentCategory(),account:this._currentAccount(),granularity:"days",maxCountries:30,metricLabel:this._currentLabel(),height:560,mapStyle:"dark",mapOptions:{interactive:!0}});await a.Modal.drawer({eyebrow:"Geography",title:this._currentLabel()+" by Country",meta:[{icon:"bi bi-calendar3",text:"Last 7 days"},{icon:"bi bi-cursor",text:"Drag, zoom, click markers"}],view:e,size:"xl"})}async _fetchLeaderboard(){const e=this.getApp()?.rest;if(!e)return this.leaderboard=[],void(this.leaderboardEmpty=!0);try{"logins"===this.activeFamily?await this._fetchLoginsLeaderboard(e):await this._fetchMetricsLeaderboard(e)}catch(t){console.warn("[GeographyPanel] leaderboard fetch failed:",t),this.leaderboard=[]}this.leaderboardEmpty=0===this.leaderboard.length}async _fetchMetricsLeaderboard(e){const t=be.find(e=>e.key===this.activeFamily),i=Math.floor((Date.now()-6048e5)/1e3),s=await e.GET("/api/metrics/fetch",{category:t.category,account:t.account,granularity:"days",with_labels:!0,dr_start:i,_:Date.now()}),a=s?.data?.data?.data||{};this.leaderboard=this._buildLeaderboard(a)}async _fetchLoginsLeaderboard(e){const t=new Date(Date.now()-6048e5).toISOString().slice(0,10),i=await e.GET("/api/account/logins/summary",{dr_start:t});if(!i?.success||!i.data?.status)return void(this.leaderboard=[]);const s=i.data.data||[];this.leaderboard=s.filter(e=>e.country_code&&e.count>0).sort((e,t)=>t.count-e.count).slice(0,10).map(e=>({cc:e.country_code,name:e.country_code,total:e.count}))}_buildLeaderboard(e){const t=[];for(const[i,s]of Object.entries(e)){const e=String(i).split(":").pop()?.toUpperCase();if(!e||2!==e.length)continue;const a=(Array.isArray(s)?s:[]).reduce((e,t)=>e+(Number(t)||0),0);a<=0||t.push({cc:e,name:e,total:a})}return t.sort((e,t)=>t.total-e.total),t.slice(0,10)}async onActionSetFamily(e,t){const i=t.dataset.family;i&&i!==this.activeFamily&&(this.activeFamily=i,this._reflectFamilies(),this.map&&(this.map.category=this._currentCategory(),this.map.metricLabel=this._currentLabel()),await this._fetchLeaderboard(),await this.render(),this.map&&await this.map.refresh())}async refresh(){if(await this._fetchLeaderboard(),this.isMounted()&&await this.render(),this.map)return this.map.refresh()}async onActionOpenCountry(e,t){const i=t.dataset.cc,s=t.dataset.name,a=t.dataset.total;i&&this.openCountryDrawer({cc:i,name:s,total:a})}_currentCategory(){return be.find(e=>e.key===this.activeFamily)?.category||be[0].category}_currentAccount(){return be.find(e=>e.key===this.activeFamily)?.account||be[0].account}_currentLabel(){return be.find(e=>e.key===this.activeFamily)?.label||be[0].label}openCountryDrawer({cc:e,name:t,total:i}){const s=this._esc(e);a.Modal.drawer({eyebrow:"Country Detail",title:`${e} · ${t||e}`,meta:[{icon:"bi bi-graph-up",text:`${i??"—"} ${this._currentLabel().toLowerCase()} / 7d`}],body:`\n <p class="small text-muted">\n For a fuller breakdown of events from this country, open the Events\n table filtered by country code.\n </p>\n <div class="mt-2">\n <a href="?page=system/events&country_code=${encodeURIComponent(e)}"\n class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events from ${s}\n </a>\n </div>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}const ge={new:"rgba(13, 202, 240, 0.85)",open:"rgba(13, 110, 253, 0.85)",investigating:"rgba(255, 193, 7, 0.85)",paused:"rgba(108, 117, 125, 0.85)",resolved:"rgba(25, 135, 84, 0.85)",closed:"rgba(73, 80, 87, 0.85)",ignored:"rgba(108, 117, 125, 0.6)",pending:"rgba(173, 181, 189, 0.85)"},ve=[{key:"critical",label:"Critical",range:"12+",color:"rgba(220, 53, 69, 0.85)",match:e=>e>=12},{key:"high",label:"High",range:"8–11",color:"rgba(253, 126, 20, 0.85)",match:e=>e>=8&&e<12},{key:"warn",label:"Warn",range:"4–7",color:"rgba(255, 193, 7, 0.85)",match:e=>e>=4&&e<8},{key:"info",label:"Info",range:"0–3",color:"rgba(13, 202, 240, 0.85)",match:e=>e<4}];class DistributionStrip extends t.View{constructor(e={}){super({...e,className:`sd-distributions ${e.className||""}`.trim()}),this._statusData=[],this.priorityRows=[],this.priorityEmpty=!0,this.funnelRows=[],this.funnelEmpty=!0}async getTemplate(){return'\n <div class="row g-3">\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Incidents by Status</h3>\n </div>\n <div class="card-body" data-container="status-donut"></div>\n </div>\n </div>\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Priority Buckets</h3>\n </div>\n <div class="card-body">\n {{#priorityEmpty|bool}}\n <div class="text-muted small">No incidents in window.</div>\n {{/priorityEmpty|bool}}\n {{^priorityEmpty|bool}}\n <ul class="list-unstyled mb-0 sd-bucket-list">\n {{#priorityRows}}\n <li class="sd-bucket-row" data-action="open-priority" data-bucket="{{key}}">\n <span class="sd-bucket-label" style="color:{{color}};">\n {{label}}<span class="sd-bucket-range">{{range}}</span>\n </span>\n <span class="sd-bucket-bar"><span style="width:{{percent}}%; background:{{color}};"></span></span>\n <span class="sd-bucket-num sd-mono">{{value}}</span>\n </li>\n {{/priorityRows}}\n </ul>\n {{/priorityEmpty|bool}}\n </div>\n </div>\n </div>\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Bouncer Funnel</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <div class="card-body">\n <div class="sd-funnel">\n {{#funnelRows}}\n <div class="sd-funnel-row">\n <div class="sd-funnel-bar">\n <span class="sd-funnel-fill" style="width:{{percent}}%; background:{{color}};">{{label}}</span>\n </div>\n <span class="sd-funnel-num sd-mono">{{value}}</span>\n </div>\n {{/funnelRows}}\n </div>\n {{#funnelEmpty|bool}}\n <div class="text-muted small">No bouncer activity in window.</div>\n {{/funnelEmpty|bool}}\n </div>\n </div>\n </div>\n </div>\n '}async onInit(){this.statusDonut=new i.PieChart({containerId:"status-donut",data:[],cutout:.6,width:200,height:200,legendPosition:"bottom",centerLabel:({total:e})=>e,centerSubLabel:"TOTAL"}),this.statusDonut.on?.("chart:click",({slice:e})=>this._openStatusDrawer(e)),this.addChild(this.statusDonut),await this._fetch()}async _fetch(){const e=this.getApp()?.rest;if(!e)return;const[t,i]=await Promise.all([this._fetchTopByField("status"),this._fetchTopByField("priority")]),s=t.reduce((e,t)=>e+t.value,0);this._statusData=this._buildStatusSlices(t),this.priorityRows=this._bucketByPriority(i),this.priorityEmpty=0===s;try{const t=Math.floor((Date.now()-6048e5)/1e3),i=await e.GET("/api/metrics/fetch",{slugs:"bouncer:assessments,bouncer:monitors,bouncer:blocks",account:"incident",granularity:"days",with_labels:!0,dr_start:t,_:Date.now()}),s=i?.data?.data?.data||{},a={};for(const[e,n]of Object.entries(s))a[e]=(Array.isArray(n)?n:[]).reduce((e,t)=>e+(Number(t)||0),0);this.funnelRows=this._buildFunnel(a)}catch(a){console.warn("[DistributionStrip] bouncer fetch failed:",a),this.funnelRows=[]}this.funnelEmpty=0===this.funnelRows.length||this.funnelRows.every(e=>0===e.value),this.statusDonut?.setData(this._statusData)}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetchTopByField(e){const t=this.getApp()?.rest;if(!t)return[];try{const i=await t.GET("/api/incident/incident",{_mode:"top",_field:e,_size:50,_:Date.now()}),s=i?.data?.data;return Array.isArray(s)?s.map(e=>({key:String(e.key??""),value:Number(e.value)||0})):[]}catch(i){return console.warn(`[DistributionStrip] _mode=top fetch failed for ${e}:`,i),[]}}_buildStatusSlices(e){return e.filter(e=>e.key).map(e=>{const t=e.key.toLowerCase();return{label:t.charAt(0).toUpperCase()+t.slice(1),value:e.value,color:ge[t]||"rgba(108, 117, 125, 0.6)"}}).sort((e,t)=>t.value-e.value)}_bucketByPriority(e){const t=ve.map(e=>({...e,value:0}));for(const s of e){const e=parseInt(s.key,10);if(!Number.isFinite(e))continue;const i=t.find(t=>t.match(e));i&&(i.value+=s.value)}const i=Math.max(1,...t.map(e=>e.value));return t.map(e=>({key:e.key,label:e.label,range:e.range,color:e.color,value:e.value,percent:Math.round(e.value/i*100)}))}_buildFunnel(e){const t=[{key:"bouncer:assessments",label:"Assessments",color:"rgba(76, 201, 240, 0.95)"},{key:"bouncer:monitors",label:"Monitors",color:"rgba(245, 165, 36, 0.95)"},{key:"bouncer:blocks",label:"Blocks",color:"rgba(255, 90, 90, 0.95)"}].map(t=>({...t,value:Number(e[t.key]??0)}));if(t.every(e=>0===e.value))return[];const i=Math.max(1,...t.map(e=>e.value));return t.map(e=>({...e,value:e.value.toLocaleString(),percent:Math.max(12,Math.round(e.value/i*100))}))}_openStatusDrawer(e){if(!e)return;const t=String(e.label).toLowerCase(),i=this._esc(e.label);a.Modal.drawer({eyebrow:"Status Filter",title:`Incidents · ${e.label}`,meta:[{icon:"bi bi-pie-chart",text:`${e.value} (${e.pct.toFixed(1)}%)`}],body:`\n <p class="small text-muted">View the full incident list filtered by this status.</p>\n <a href="?page=system/incidents&status=${encodeURIComponent(t)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Incidents (${i})\n </a>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}async onActionOpenPriority(e,t){const i=t.dataset.bucket;if(!i)return;const s=ve.find(e=>e.key===i);if(!s)return;const[n,o]="12+"===s.range?[12,null]:s.range.split("–").map(Number),l=`priority__gte=${n}`+(null!=o?`&priority__lte=${o}`:"");a.Modal.drawer({eyebrow:"Priority Bucket",title:`Incidents · ${s.label} (${s.range})`,view:null,body:`\n <p class="small text-muted">View the full incident list filtered by priority.</p>\n <a href="?page=system/incidents&${l}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Incidents\n </a>\n `,size:"sm"})}}const ye=e=>{const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML},we=["ossec"];class TopSourcesPanel extends t.View{constructor(e={}){super({...e,className:`sd-top-sources ${e.className||""}`.trim()}),this.allowBlock=!1!==e.allowBlock,this.excludeCategories=Array.isArray(e.excludeCategories)?e.excludeCategories:we,this.ips=[],this.cats=[],this.ipsEmpty=!0,this.catsEmpty=!0}async getTemplate(){return'\n <div class="row g-3">\n <div class="col-lg-6">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Top Source IPs</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <ul class="list-unstyled mb-0 sd-rank-list">\n {{#ipsEmpty|bool}}<li class="px-3 py-4 text-muted small">No source IPs in window.</li>{{/ipsEmpty|bool}}\n {{#ips}}\n <li class="d-flex align-items-center gap-2 px-3 py-2 border-top sd-rank-row" data-action="open-ip" data-ip="{{name}}">\n <span class="text-muted small sd-mono" style="width:1.5rem; text-align:right;">{{rank}}</span>\n <span class="flex-grow-1 sd-mono">{{name}}</span>\n <div class="progress sd-progress" style="height:6px; width:96px;">\n <div class="progress-bar bg-danger" style="width:{{percent}}%"></div>\n </div>\n <span class="sd-mono small">{{value}}</span>\n {{#allowBlock|bool}}\n <button type="button" class="btn btn-sm btn-link text-danger p-1" data-action="block-ip" data-ip="{{name}}" title="Block IP">\n <i class="bi bi-shield-fill-x"></i>\n </button>\n {{/allowBlock|bool}}\n </li>\n {{/ips}}\n </ul>\n </div>\n </div>\n <div class="col-lg-6">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Top Categories</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <ul class="list-unstyled mb-0 sd-rank-list">\n {{#catsEmpty|bool}}<li class="px-3 py-4 text-muted small">No category activity in window.</li>{{/catsEmpty|bool}}\n {{#cats}}\n <li class="d-flex align-items-center gap-2 px-3 py-2 border-top sd-rank-row" data-action="open-category" data-cat="{{name}}">\n <span class="text-muted small sd-mono" style="width:1.5rem; text-align:right;">{{rank}}</span>\n <span class="flex-grow-1 sd-mono">{{name}}</span>\n <div class="progress sd-progress" style="height:6px; width:96px;">\n <div class="progress-bar" style="width:{{percent}}%; background-color: rgba(179, 136, 255, 0.85);"></div>\n </div>\n <span class="sd-mono small">{{value}}</span>\n </li>\n {{/cats}}\n </ul>\n </div>\n </div>\n </div>\n '}_withRank(e){const t=Math.max(1,...e.map(e=>e.value));return e.map((e,i)=>({...e,rank:i+1,percent:Math.round(e.value/t*100)}))}async onInit(){await this._fetch()}async _fetch(){const e=Math.floor((Date.now()-6048e5)/1e3),t=this.excludeCategories.length?{category__not_in:this.excludeCategories.join(",")}:{},[i,s]=await Promise.all([this._fetchTop("source_ip",e),this._fetchTop("category",e,t)]);this.ips=this._withRank(i),this.cats=this._withRank(s),this.ipsEmpty=0===this.ips.length,this.catsEmpty=0===this.cats.length}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetchTop(e,t,i={}){const s=this.getApp()?.rest;if(!s)return[];try{const a=await s.GET("/api/incident/event",{_mode:"top",_field:e,_size:10,dr_start:t,...i,_:Date.now()}),n=a?.data?.data;return Array.isArray(n)?n.filter(e=>e.key&&"—"!==e.key).map(e=>({name:String(e.key),value:Number(e.value)||0})):[]}catch(a){return console.warn(`[TopSourcesPanel] _mode=top fetch failed for ${e}:`,a),[]}}async onActionOpenIp(e,t){if(e.target.closest('[data-action="block-ip"]'))return;const i=t.dataset.ip;if(!i)return;const s=ye(i);a.Modal.drawer({eyebrow:"Source IP",title:i,meta:[{icon:"bi bi-clock",text:"Last 7 days"}],body:`\n <p class="small text-muted">Open the events table filtered by this source IP.</p>\n <a href="?page=system/events&source_ip=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events from ${s}\n </a>\n `,size:"sm"})}async onActionOpenCategory(e,t){const i=t.dataset.cat;if(!i)return;const s=ye(i);a.Modal.drawer({eyebrow:"Category",title:i,meta:[{icon:"bi bi-clock",text:"Last 7 days"}],body:`\n <p class="small text-muted">Open the events table filtered by this category.</p>\n <a href="?page=system/events&category=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events · ${s}\n </a>\n `,size:"sm"})}async onActionBlockIp(e,t){e.stopPropagation();const i=t.dataset.ip;if(!i)return;if(!(await a.Modal.confirm(`Add ${ye(i)} to the firewall block list?`)))return;const s=this.getApp()?.rest;if(s)try{await s.POST("/api/system/geoip",{ip:i,is_blocked:!0}),this.getApp()?.toast?.success?.(`Blocked ${i}`)}catch(n){this.getApp()?.toast?.error?.(n?.message||"Failed to block IP")}}}const fe=[{key:"password_reset",label:"Pwd Resets"},{key:"totp:login_failed",label:"TOTP Fails"},{key:"sessions:revoked",label:"Revoked"},{key:"account:deactivated",label:"Deactivated"}];class AuthFailuresPanel extends t.View{constructor(e={}){super({...e,className:`sd-auth-failures ${e.className||""}`.trim()}),this.tiles=fe.map(e=>({...e,value:null,display:"—"}))}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Auth Failures</h3>\n <span class="card-subtitle text-muted small">Aggregate slug <code>auth:failures</code> · last 30 days</span>\n </div>\n <div class="card-body">\n <div data-container="chart-host" class="mb-3"></div>\n <div class="row g-2">\n {{#tiles}}\n <div class="col-6 col-lg-3">\n <button type="button"\n class="card w-100 text-start border sd-auth-tile"\n data-action="open-sub-tile"\n data-category="{{key}}">\n <div class="card-body py-2 px-3">\n <div class="sd-auth-tile-label">{{label}} <span class="sd-auth-tile-suffix">24H</span></div>\n <div class="sd-mono sd-auth-tile-value">{{display}}</div>\n </div>\n </button>\n </div>\n {{/tiles}}\n </div>\n </div>\n </div>\n '}async onInit(){this.chart=new i.MetricsChart({containerId:"chart-host",slugs:["auth:failures"],account:"incident",granularity:"days",defaultDateRange:"30d",chartType:"bar",compactHeader:!0,showDateRange:!1,showGranularity:!1,showTypeSwitch:!1,showLegend:!1,height:200,colors:["rgba(179, 136, 255, 0.85)"],yAxis:{label:"Failures",beginAtZero:!0},tooltip:{y:"number:0"},title:""}),this.addChild(this.chart),await this._fetchSubTiles()}async refresh(){await Promise.allSettled([this.chart?.fetchData?.(),this._fetchSubTiles()]),this.isMounted()&&await this.render()}async _fetchSubTiles(){const e=this.getApp()?.rest;if(!e)return;const t=Math.floor((Date.now()-864e5)/1e3),i=await Promise.all(this.tiles.map(async i=>{try{const s=await e.GET("/api/incident/event",{category:i.key,dr_start:t,size:0,_:Date.now()}),a=s?.data?.count??s?.data?.data?.count??null,n="number"==typeof a?a:0;return{...i,value:n,display:String(n)}}catch(s){return{...i,value:null,display:"—"}}}));this.tiles=i}async onActionOpenSubTile(e,t){const i=t.dataset.category;if(!i)return;const s=this._esc(i);a.Modal.drawer({eyebrow:"Auth Failure Category",title:i,meta:[{icon:"bi bi-clock",text:"Last 24 hours"}],body:`\n <p class="small text-muted">Open the events table filtered by this category.</p>\n <a href="?page=system/events&category=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events · ${s}\n </a>\n `,size:"sm"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}class HealthStrip extends t.View{constructor(e={}){super({...e,tagName:"div",className:`sd-health ${e.className||""}`.trim()}),this.rows=[],this.dots=[{dot:"good"}],this.summary="Loading…",this.empty=!1,this._fetchedOnce=!1}async getTemplate(){return'\n <details class="card sd-card sd-health-card" open>\n <summary class="card-header bg-transparent border-0 d-flex justify-content-between align-items-center sd-health-summary">\n <div class="d-flex align-items-center gap-3">\n <span class="sd-eyebrow">System Health</span>\n <span class="text-muted small d-inline-flex align-items-center gap-2">\n {{#dots}}<span class="sd-dot sd-dot-{{dot}}"></span>{{/dots}}\n <span class="ms-1">{{summary}}</span>\n </span>\n </div>\n <i class="bi bi-chevron-up sd-health-toggle"></i>\n </summary>\n <ul class="list-unstyled mb-0 sd-health-list">\n {{#empty|bool}}\n <li class="px-3 py-3 text-success small"><i class="bi bi-check-circle me-1"></i>All systems healthy.</li>\n {{/empty|bool}}\n {{#rows}}\n <li class="px-3 py-2 border-top d-flex align-items-center gap-3 sd-health-row {{#hasIncident|bool}}sd-health-row-link{{/hasIncident|bool}}"\n {{#hasIncident|bool}}data-action="open-incident" data-id="{{incidentId}}"{{/hasIncident|bool}}>\n <span class="sd-dot sd-dot-{{dot}}"></span>\n <div class="flex-grow-1 min-w-0">\n <div class="sd-mono small">{{category}}</div>\n <div class="text-muted small text-truncate">{{details}}</div>\n </div>\n <span class="text-muted small">{{when}}</span>\n <span class="badge text-bg-light sd-mono">level {{level}}</span>\n </li>\n {{/rows}}\n </ul>\n </details>\n '}async onInit(){await this._fetch()}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetch(){const e=this.getApp()?.rest;if(!e)return this.rows=[],this._fetchedOnce=!0,void this._reflectState();try{const t=await e.GET("/api/incident/health/summary",{_:Date.now()}),i=t?.data?.data||[];this.rows=i.map(e=>this._normalize(e))}catch(t){console.warn("[HealthStrip] fetch failed:",t),this.rows=[]}finally{this._fetchedOnce=!0,this._reflectState()}}_reflectState(){this.empty=0===this.rows.length&&this._fetchedOnce,this.dots=this.rows.length?this.rows.map(e=>({dot:e.dot})):[{dot:"good"}],this.summary=this._buildSummary()}_normalize(e){const t=parseInt(e.level,10)||0,i=t>=10?"crit":t>=6?"warn":"good";return{category:e.category||"—",details:e.details||e.title||"",level:t,dot:i,when:this._relativeTime(e.last_seen),incidentId:e.incident_id||null,hasIncident:!!e.incident_id}}_buildSummary(){if(!this.rows.length)return this._fetchedOnce?"All systems healthy":"Loading…";const e=this.rows.filter(e=>"crit"===e.dot).length,t=this.rows.filter(e=>"warn"===e.dot).length,i=this.rows.filter(e=>"good"===e.dot).length,s=[];return e&&s.push(`${e} critical`),t&&s.push(`${t} warning${t>1?"s":""}`),i&&s.push(`${i} healthy`),s.join(" · ")}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async onActionOpenIncident(e,t){const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});if(await s.fetch(),!s.id)return;const n=new IncidentView({model:s});await a.Modal.detail(n)}}class SecurityDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Security Dashboard",className:"security-dashboard-page"})}async getTemplate(){return'\n <div class="security-dashboard">\n <header class="sd-page-head">\n <div>\n <span class="sd-eyebrow">Security</span>\n <h1 class="sd-page-title">Security Dashboard</h1>\n </div>\n <div class="sd-page-controls">\n <span class="sd-updated text-muted small me-2">\n <i class="bi bi-circle-fill text-success me-1" style="font-size:0.5rem;"></i>\n Live\n </span>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all panels">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n </header>\n\n <section class="sd-section">\n <div data-container="status-strip"></div>\n </section>\n\n <section class="sd-section sd-grid sd-grid-2">\n <div data-container="priority-queue"></div>\n <div data-container="tickets-queue"></div>\n </section>\n\n <section class="sd-section sd-grid sd-grid-2-3">\n <div data-container="geography"></div>\n <div data-container="composition"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="distributions"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="top-sources"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="auth-failures"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="health-strip"></div>\n </section>\n </div>\n '}async onInit(){const e=this.getApp(),t=!!e?.activeUser?.hasPermission?.("manage_security");this.statusStrip=new StatusStripPanel({containerId:"status-strip"}),this.addChild(this.statusStrip),this.priorityQueue=new PriorityQueueView({containerId:"priority-queue",allowActions:t}),this.addChild(this.priorityQueue),this.ticketsQueue=new TicketsQueueView({containerId:"tickets-queue",allowActions:t}),this.addChild(this.ticketsQueue),this.composition=new ThreatCompositionChart({containerId:"composition"}),this.addChild(this.composition),this.geography=new GeographyPanel({containerId:"geography"}),this.addChild(this.geography),this.distributions=new DistributionStrip({containerId:"distributions"}),this.addChild(this.distributions),this.topSources=new TopSourcesPanel({containerId:"top-sources",allowBlock:t}),this.addChild(this.topSources),this.authFailures=new AuthFailuresPanel({containerId:"auth-failures"}),this.addChild(this.authFailures),this.healthStrip=new HealthStrip({containerId:"health-strip"}),this.addChild(this.healthStrip)}async onEnter(){const e=this._wasExited;await super.onEnter();const t={tier:"fast",immediate:e},i={tier:"slow",immediate:e};this.scheduleRefresh(()=>this.statusStrip?.refresh(),6e4,t),this.scheduleRefresh(()=>this.priorityQueue?.refresh(),6e4,t),this.scheduleRefresh(()=>this.ticketsQueue?.refresh(),6e4,t),this.scheduleRefresh(()=>this.composition?.refresh(),3e5,i),this.scheduleRefresh(()=>this._refreshLazyMounted(),3e5,i)}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}async _refreshLazyMounted(){const e=[this.geography,this.distributions,this.topSources,this.authFailures,this.healthStrip];await Promise.allSettled(e.filter(e=>e?._lazyTriggered&&"function"==typeof e.refresh).map(e=>e.refresh()))}}c.Incident.VIEW_CLASS=IncidentView;class IncidentTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_incidents",pageName:"Manage Incidents",router:"admin/incidents",Collection:c.IncidentList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-id",status:"new"},dayRangeFilter:!0,searchPlaceholder:"Search title, message, or ID",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,visibility:"lg",filter:{type:"text"}},{key:"category",label:"Category",sortable:!0,visibility:"lg",filter:{type:"text"}},{key:"priority",label:"Priority",visibility:"xl",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"}}],rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},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}})}onActionBatchResolve(){return this.batchAction({field:"status",value:"resolved",label:"Resolve"})}onActionBatchOpen(){return this.batchAction({field:"status",value:"open",label:"Open"})}onActionBatchPause(){return this.batchAction({field:"status",value:"paused",label:"Pause"})}onActionBatchIgnore(){return this.batchAction({field:"status",value:"ignored",label:"Ignore"})}onActionBatchProtect(){return this.batchAction({label:"Protect",message:`Protect ${this.tableView.getSelectedItems().length} incident(s) from deletion?`,handler:e=>e.save({metadata:{do_not_delete:!0}})})}async onActionBatchMerge(e,t){const i=this.tableView.getSelectedItems();if(!i.length)return;const s=this.getApp(),a=await s.showForm({title:`Merge ${i.length} incidents`,fields:[{name:"merge",type:"select",label:"Select Parent Incident",options:i.map(e=>({value:e.model.id,label:e.model.id})),required:!0}]});if(!a)return;const n=i.find(e=>e.model.id==a.merge)?.model;if(!n)return;const o=i.map(e=>e.model.id).filter(e=>e!=a.merge);await n.save({merge:o}),this.tableView.clearSelection(),await this.tableView.refresh()}}const _e={user:t.User,userdevice:t.UserDevice,userdevicelocation:t.UserDeviceLocation,geolocatedip:n.GeoLocatedIP,member:l.Member,incident:c.Incident,incidentevent:c.IncidentEvent,ticket:c.Ticket,job:c.Job,log:l.Log,apikey:ApiKey};class EventView extends t.View{constructor(e={}){super({className:"event-view",template:'\n <div class="event-view-container">\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-secondary small">\n Category: {{model.category|capitalize|default(\'—\')}}\n </div>\n <div class="text-secondary small mt-1">\n {{{model.created|epoch|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 <div data-container="event-tabs"></div>\n </div>\n ',...e}),this.model=e.model||new c.IncidentEvent(e.data||{}),this.eventIcon=function(e){const t=Number(e)||0;return t>=40?{icon:"bi-exclamation-octagon-fill",color:"text-danger"}:t>=30?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:t>=20?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-bell-fill",color:"text-secondary"}}(this.model.get("level"))}async onInit(){this.overviewView=new d.default({model:this.model,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:"created",label:"Created",formatter:"epoch|datetime"},{name:"details",label:"Details",columns:12}]});const t={Overview:this.overviewView},i=this.model.get("metadata")||{};i.stack_trace&&(this.stackTraceView=new StackTraceView({stackTrace:i.stack_trace}),t["Stack Trace"]=this.stackTraceView),Object.keys(i).length>0&&(this.metadataView=new n.KnownFieldsCard({model:this.model,data:e=>e.get("metadata")||{},knownKeys:[{key:"source_ip",label:"Source IP"},{key:"request_ip",label:"Request IP"},{key:"http_method",label:"Method"},{key:"http_host",label:"Host"},{key:"http_path",label:"Path"},{key:"http_protocol",label:"Protocol"},{key:"http_query_string",label:"Query string"},{key:"http_status",label:"HTTP status"},{key:"http_user_agent",label:"User agent"},{key:"server",label:"Server"},{key:"city",label:"City"},{key:"region",label:"Region"},{key:"country_name",label:"Country"},{key:"country_code",label:"Country code"},{key:"timezone",label:"Timezone"},{key:"latitude",label:"Latitude"},{key:"longitude",label:"Longitude"},{key:"scope",label:"Scope"},{key:"category",label:"Category"},{key:"title",label:"Title"},{key:"details",label:"Details"},{key:"level",label:"Level"},{key:"model_name",label:"Model"},{key:"model_id",label:"Model ID"},{key:"error_class",label:"Error class"},{key:"error_message",label:"Error message"},{key:"hostname",label:"Hostname"},{key:"user_agent",label:"User agent (legacy)"},{key:"http_url",label:"URL (legacy)"},{key:"request_path",label:"Request path (legacy)"}],rawLabel:"Raw metadata"}),t.Metadata=this.metadataView),this.tabView=new o.TabView({containerId:"event-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const s=[{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}],a=new e.ContextMenu({containerId:"event-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:s}});this.addChild(a)}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 c.Incident({id:e}),i=new IncidentView({model:t});await a.Modal.detail(i)}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 i=e.toLowerCase().replace(/[^a-z]/g,""),s=_e[i];return s?s.VIEW_CLASS?void(await a.Modal.showModelById(s,t)):(this.getApp()?.toast?.warning(`No detail view available for ${e}`),!0):(this.getApp()?.toast?.warning(`Unknown model type: ${e}`),!0)}async onActionDeleteEvent(){await a.Modal.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})}}c.IncidentEvent.VIEW_CLASS=EventView,c.IncidentEvent.MODEL_REF="incident.Event",c.IncidentEvent.VIEW_CLASS=EventView;class EventTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_events",pageName:"System Events",router:"admin/events",Collection:c.IncidentEventList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, message, or ID",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",visibility:"lg",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",visibility:"lg",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,visibility:"lg",filter:{type:"text"}},{key:"metadata.server",label:"Server",sortable:!0,visibility:"xl",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"}}],rowStripe:e=>{const t=Number(e.get("level"));return t>=5?"danger":t>=4?"warning":null},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}})}}c.Ticket.VIEW_CLASS=TicketView;const ke={new:"tt-pill-new",open:"tt-pill-open",in_progress:"tt-pill-prog",pending:"tt-pill-prog",resolved:"tt-pill-resolved",qa:"tt-pill-open",closed:"tt-pill-closed",ignored:"tt-pill-closed"},xe={security:"tt-dot-security",incident:"tt-dot-security",bug:"tt-dot-amber",qa:"tt-dot-amber",feature:"tt-dot-accent",ticket:"tt-dot-accent",fulfillment:"tt-dot-green",new_user:"tt-dot-muted",new_group:"tt-dot-muted"};function Se(e){const t=(e||"new").toString();return`<span class="tt-pill ${ke[t]||"tt-pill-closed"}">${t.replace(/_/g," ")}</span>`}function Ce(e){const t=(e||"ticket").toString();return`<span class="tt-cat"><span class="tt-cat-dot ${xe[t]||"tt-dot-muted"}"></span>${t.replace(/_/g," ")}</span>`}function Ae(e){return`<span class="tt-id">${e??""}</span>`}function Te(e){const t=parseInt(e);return Number.isFinite(t)?`<span class="tt-pri ${t>=8?"tt-pri-hi":t>=5?"tt-pri-md":"tt-pri-lo"}">${t}</span>`:""}class TicketTablePage extends n.TablePage{constructor(e={}){super({name:"admin_tickets",pageName:"Tickets",router:"admin/tickets",Collection:c.TicketList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-priority",status__in:"new,open"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,formatter:Ae},{key:"title",label:"Title",sortable:!0},{key:"status",label:"Status",sortable:!0,width:"100px",formatter:Se,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:"Pri",sortable:!0,width:"50px",formatter:Te},{key:"category",label:"Category",sortable:!0,width:"110px",formatter:Ce,editable:!0,editableOptions:{type:"select",options:[...Object.keys(c.TicketCategories)]},filter:{type:"multiselect",placeHolder:"Select Category",options:[...Object.keys(c.TicketCategories)]}},{key:"created",label:"Created",sortable:!0,width:"80px",formatter:"relative",class:"tt-time"},{key:"modified",label:"Activity",sortable:!0,width:"80px",formatter:"relative",class:"tt-time"}],rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No tickets found.",tableOptions:{striped:!1,bordered:!1,hover:!0,responsive:!1},...e})}buildTemplate(){return'\n <style>\n .ticket-table-page table { font-size: 0.82rem; }\n .ticket-table-page table thead th { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; color: var(--bs-secondary-color); padding: 8px 10px; border-bottom: 1px solid var(--bs-border-color); border-top: none; background: transparent; }\n .ticket-table-page table tbody td { padding: 10px 10px; border-bottom: 1px solid var(--bs-border-color-translucent); border-top: none; vertical-align: middle; color: var(--bs-body-color); }\n .ticket-table-page table tbody tr { cursor: pointer; transition: background 0.1s; }\n .ticket-table-page table tbody tr:hover { background: var(--bs-tertiary-bg); }\n .ticket-table-page table tbody tr.selected { background: rgba(var(--bs-primary-rgb), 0.08); }\n .ticket-table-page table { border-collapse: collapse; }\n\n .tt-id { color: var(--bs-secondary-color); font-family: var(--bs-font-monospace); font-size: 0.78rem; }\n .tt-time { color: var(--bs-secondary-color); font-size: 0.78rem; white-space: nowrap; }\n\n .tt-pill { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.68rem; font-weight: 500; letter-spacing: 0.01em; text-transform: lowercase; }\n .tt-pill-new { background: rgba(var(--bs-info-rgb), 0.1); color: var(--bs-info); }\n .tt-pill-open { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tt-pill-prog { background: rgba(var(--bs-warning-rgb), 0.12); color: var(--bs-warning); }\n .tt-pill-resolved { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tt-pill-closed { background: var(--bs-secondary-bg); color: var(--bs-secondary-color); }\n\n .tt-pri { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; border-radius: 4px; font-size: 0.74rem; font-weight: 500; padding: 0 6px; }\n .tt-pri-hi { background: rgba(var(--bs-danger-rgb), 0.1); color: var(--bs-danger); }\n .tt-pri-md { background: rgba(var(--bs-warning-rgb), 0.12); color: var(--bs-warning); }\n .tt-pri-lo { color: var(--bs-secondary-color); }\n\n .tt-cat { display: inline-flex; align-items: center; gap: 6px; font-size: 0.78rem; color: var(--bs-body-color); text-transform: capitalize; }\n .tt-cat-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }\n .tt-dot-security { background: var(--bs-danger); }\n .tt-dot-accent { background: var(--bs-primary); }\n .tt-dot-amber { background: var(--bs-warning); }\n .tt-dot-green { background: var(--bs-success); }\n .tt-dot-muted { background: var(--bs-secondary-color); }\n\n .ticket-table-page .table-toolbar,\n .ticket-table-page .toolbar { padding: 6px 0 10px; border-bottom: none; }\n .ticket-table-page .pagination-container { padding-top: 8px; }\n </style>\n <div class="ticket-table-page">\n <div class="table-container" data-container="table"></div>\n </div>\n '}}function Me(e){return String(e??"").replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[e]))}c.RuleSet.VIEW_CLASS=RuleSetView;class RuleSetTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_rulesets",pageName:"Rule Engine",router:"admin/rulesets",Collection:c.RuleSetList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"priority",size:50},...n.groupByField("priority",{format:e=>`Priority ${e}`}),columns:[{key:"name",label:"Rule",sortable:!0,formatter:(e,t)=>function(e){const t=e.get("id"),i=e.get("name")||"(unnamed)",s=!!e.get("is_active"),a=s?"bi-check-circle-fill":"bi-circle",n=s?"Active":"Inactive",o=s?"is-active":"is-inactive";return`\n <div class="rs-row-identity">\n ${function(e){const t=parseInt(e,10);if(Number.isNaN(t))return'<span class="text-body-tertiary">—</span>';let i;return i=t<=3?"text-bg-primary":t<=7?"text-bg-secondary":"bg-body-tertiary text-body-tertiary border",`<span class="badge ${i} font-monospace" title="Lower priority runs first">${t}</span>`}(e.get("priority"))}\n <div class="rs-row-identity-text">\n <div class="rs-row-identity-name">${Me(i)}</div>\n <div class="rs-row-identity-meta">\n <span class="rs-row-identity-active ${o}"><i class="bi ${a}"></i>${n}</span>\n <span class="rs-row-identity-id">#${Me(String(t??""))}</span>\n </div>\n </div>\n </div>\n `}(t.model)},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"combobox",options:c.CommonCategoryOptions}},{key:"handler",label:"Behavior",visibility:"lg",formatter:(e,t)=>function(e){const t=W(e.get("handler")),i=0===t.length?'<span class="rs-row-chip rs-row-chip-empty"><i class="bi bi-dash-circle"></i>Record only</span>':t.map(e=>`<span class="rs-row-chip tone-${e.tone}" title="${Me(e.label)}${e.detail?" — "+Me(e.detail):""}"><i class="bi ${e.icon}"></i>${Me(e.label)}</span>`).join(""),s=e.get("trigger_count"),a=e.get("trigger_window");let n;n=s?a?`<i class="bi bi-stopwatch"></i>${s} events / ${a} min`:`<i class="bi bi-stopwatch"></i>${s} events`:'<i class="bi bi-lightning-charge"></i>Fires immediately';const o=function(e){if(!e)return null;const t=c.BundleByOptions.find(t=>t.value===e);return t?t.label.replace(/^By\s+/i,""):String(e)}(e.get("bundle_by"));return`\n <div class="rs-row-behavior">\n <div class="rs-row-chips">${i}</div>\n <div class="rs-row-meta">${n}<span class="rs-row-sep">·</span>${o?`<i class="bi bi-collection"></i>${Me(o)}`:'<i class="bi bi-collection"></i>No bundling'}</div>\n </div>\n `}(t.model)}],filters:[{key:"is_active",label:"Active",filter:{type:"boolean",trueLabel:"Active",falseLabel:"Inactive"}}],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}})}onActionBatchEnable(){return this.batchAction({field:"is_active",value:!0,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"is_active",value:!1,label:"Disable"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}class EmailDomainTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_domains",pageName:"Email Domains",router:"admin/email/domains",Collection:c.EmailDomainList,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('—')",visibility:"lg"},{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",visibility:"lg"},{key:"created",label:"Created",formatter:"epoch|datetime",visibility:"xl"}],searchPlaceholder:"Search domain or region",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 a.Modal.modelForm({model:i,formConfig:EmailDomainForms.credentials}),!0}async onActionOnboard(e,t){const i=this.collection.get(t.dataset.id),s=new c.EmailDomain({id:i.id}),n=await a.Modal.form(EmailDomainForms.onboard);if(n)try{const e=await s.onboard(n);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(o){console.error("Onboard error:",o),this.showError(o.message||"Failed to onboard domain")}}async onActionAudit(e,i){const s=this.collection.get(i.dataset.id),n=new c.EmailDomain({id:s.id});try{const e=await n.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 a.Modal.dialog({title:`Audit Report - ${s.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(o){console.error("Audit error:",o),this.showError(o.message||"Failed to audit domain")}}async onActionReconcile(e,t){const i=this.collection.get(t.dataset.id),s=new c.EmailDomain({id:i.id});try{const e=await s.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(a){console.error("Reconcile error:",a),this.showError(a.message||"Failed to reconcile domain")}}}class EmailMailboxTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_mailboxes",pageName:"Mailboxes",router:"admin/email/mailboxes",Collection:c.MailboxList,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('—')",visibility:"lg"},{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",visibility:"xl"},{key:"is_domain_default",label:"Domain Default",formatter:"boolean|badge",visibility:"xl"}],searchPlaceholder:"Search email or domain",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),s=await a.Modal.form({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}]});s.from_email=i.get("email");const n=await c.Mailbox.sendEmail(s);if(n.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";n.data.details?e=n.data.details:n.data.error&&(e=n.data.error),this.getApp().toast.error(e)}}async onActionSendTemplateEmail(e,t){const i=this.collection.get(t.dataset.id),s=await a.Modal.form({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}]});s.from_email=i.get("email");const n=await c.Mailbox.sendEmail(s);if(n.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";n.data.details?e=n.data.details:n.data.error&&(e=n.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")||"",i=e.contentDocument||e.contentWindow.document;i.open(),i.write(t),i.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 c.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 o.TabView({containerId:"template-tabs",tabs:e,activeTab:Object.keys(e)[0]||""}),this.addChild(this.tabView)}}c.EmailTemplate.VIEW_CLASS=EmailTemplateView;class EmailTemplateTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_templates",pageName:"Email Templates",router:"admin/email/templates",Collection:c.EmailTemplateList,clickAction:"edit",dayRangeFilter:!0,searchPlaceholder:"Search template name",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 c.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 o.TabView({containerId:"email-tabs",tabs:e,activeTab:this.hasHtml?"HTML":this.hasText?"Text":"Context"}),this.addChild(this.tabView)}}c.SentMessage.VIEW_CLASS=EmailView,c.SentMessage.VIEW_CLASS=EmailView;class SentMessageTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_sent",pageName:"Sent Messages",router:"admin/email/sent",Collection:c.SentMessageList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search recipient, subject, or status",defaultQuery:{sort:"-created"},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,visibility:"xl"},{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('—')",visibility:"xl"},{key:"created",label:"Created",formatter:"datetime"}],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}})}}const Ie={open:"bg-warning text-dark",closed:"bg-secondary"};function Pe(e){return String(e||"").replace(/[_-]+/g," ").replace(/\s+/g," ").trim().replace(/\b\w/g,e=>e.toUpperCase())}class PublicMessageView extends t.View{constructor(e={}){super({className:"public-message-view",...e}),this.model=e.model||new c.PublicMessage(e.data||{}),this.template='\n <div class="public-message-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n <span class="badge bg-primary">{{kindLabel}}</span>\n <span class="badge {{statusBadgeClass}}">{{statusLabel}}</span>\n {{#model.created}}\n <span class="text-muted small"><i class="bi bi-clock me-1"></i>{{model.created|relative}}</span>\n {{/model.created}}\n </div>\n <h5 class="mb-1">{{#model.subject}}{{model.subject}}{{/model.subject}}{{^model.subject}}<span class="text-muted fst-italic">No subject</span>{{/model.subject}}</h5>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-person-fill me-1"></i>{{model.name}}</span>\n <span><i class="bi bi-envelope me-1"></i><a href="mailto:{{safeMailtoEmail}}">{{model.email}}</a></span>\n {{#model.group.name}}\n <span><i class="bi bi-diagram-3 me-1"></i>{{model.group.name}}</span>\n {{/model.group.name}}\n </div>\n </div>\n </div>\n\n \x3c!-- Submitter --\x3e\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-person-lines-fill me-1"></i>Submitter</h6></div>\n <div data-container="submitter"></div>\n </div>\n\n \x3c!-- Details (metadata) --\x3e\n {{#hasMetadata|bool}}\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-tags me-1"></i>Details</h6></div>\n <div data-container="details"></div>\n </div>\n {{/hasMetadata|bool}}\n\n \x3c!-- Message body --\x3e\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-chat-left-text me-1"></i>Message</h6></div>\n <div class="card-body">\n <pre class="mb-0" style="white-space: pre-wrap; word-wrap: break-word; font-family: inherit;">{{model.message}}</pre>\n </div>\n </div>\n\n \x3c!-- Actions --\x3e\n <div class="d-flex align-items-center gap-2">\n <button class="btn {{toggleBtnClass}} btn-sm" data-action="toggle-status">\n <i class="bi {{toggleBtnIcon}} me-1"></i>{{toggleBtnLabel}}\n </button>\n <a class="btn btn-outline-secondary btn-sm" href="mailto:{{safeMailtoEmail}}?subject={{replySubject}}">\n <i class="bi bi-reply me-1"></i>Reply via Email\n </a>\n </div>\n </div>\n '}async onBeforeRender(){const e=this.model.get("kind")||"",t=c.PublicMessageKindOptions.find(t=>t.value===e);this.kindLabel=t?t.label:e;const i=this.model.get("status")||"open";this.statusLabel=i.charAt(0).toUpperCase()+i.slice(1),this.statusBadgeClass=Ie[i]||"bg-secondary";const s=this.model.get("metadata")||{};this.hasMetadata=Object.keys(s).length>0,"open"===i?(this.toggleBtnClass="btn-success",this.toggleBtnIcon="bi-check-circle",this.toggleBtnLabel="Mark Closed"):(this.toggleBtnClass="btn-outline-warning",this.toggleBtnIcon="bi-arrow-counterclockwise",this.toggleBtnLabel="Mark Open");const a=this.model.get("subject")||"";this.replySubject=encodeURIComponent(a?`Re: ${a}`:"Re: your message"),this.safeMailtoEmail=encodeURIComponent(this.model.get("email")||"")}async onInit(){this.submitterView=new d.default({containerId:"submitter",model:this.model,className:"p-3",columns:2,showEmptyValues:!1,fields:[{name:"name",label:"Name",colSize:6},{name:"email",label:"Email",type:"email",colSize:6},{name:"ip_address",label:"IP",colSize:6},{name:"user_agent",label:"User Agent",colSize:12},{name:"group.name",label:"Group",colSize:6}]}),this.addChild(this.submitterView);const e=this.model.get("metadata")||{},t=Object.keys(e);if(t.length){const i=t.map(e=>({name:e,label:c.PublicMessageMetadataLabels[e]||Pe(e),colSize:6}));this.detailsView=new d.default({containerId:"details",data:e,className:"p-3",columns:2,showEmptyValues:!1,fields:i}),this.addChild(this.detailsView)}}async onActionToggleStatus(){const e="open"===this.model.get("status")?"closed":"open";try{await this.model.save({status:e}),this.getApp()?.toast?.success(`Message marked ${e}`),await this.render()}catch(t){this.getApp()?.toast?.error("Failed to update status")}}}c.PublicMessage.VIEW_CLASS=PublicMessageView,c.PublicMessage.VIEW_CLASS=PublicMessageView;class PublicMessageTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_public_messages",pageName:"Contact Messages",router:"admin/messaging/public-messages",Collection:c.PublicMessageList,viewDialogOptions:{header:!1,size:"lg",scrollable:!0},defaultQuery:{sort:"-created"},dayRangeFilter:!0,searchPlaceholder:"Search name, email, or subject",columns:[{key:"status",label:"Status",sortable:!0,formatter:"badge",filter:{type:"multiselect",placeHolder:"Select Status",options:c.PublicMessageStatusOptions.map(e=>e.value)}},{key:"kind",label:"Kind",sortable:!0,formatter:"badge",filter:{type:"multiselect",placeHolder:"Select Kind",options:c.PublicMessageKindOptions.map(e=>e.value)}},{key:"name",label:"Name",sortable:!0,formatter:"truncate(30)"},{key:"email",label:"Email",sortable:!0},{key:"subject",label:"Subject",sortable:!0,formatter:"truncate(60)|default('—')"},{key:"group.name",label:"Group",sortable:!0,formatter:"default('—')"},{key:"created",label:"Created",sortable:!0,formatter:"relative"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No contact or support messages yet. Visitors submit these through the bouncer-gated /contact page.",batchBarLocation:"top",batchActions:[{label:"Mark Closed",icon:"bi bi-check-circle",action:"mark-closed"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}onActionBatchMarkClosed(){return this.batchAction({field:"status",value:"closed",label:"Mark Closed"})}}class PhoneNumberView extends t.View{constructor(e={}){super({className:"phone-number-view",...e}),this.model=e.model||new c.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 d.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 d.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 d.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 d.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 o.TabView({containerId:"phone-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const i=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(i)}async onActionRefreshLookup(){const e=this.model.get("phone_number");if(e)try{this.getApp()?.toast?.info?.("Refreshing lookup...");const t=await c.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 a.Modal.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 c.PhoneNumber.lookup(e);if(t?.model){const e=new PhoneNumberView({model:t.model});return void(await a.Modal.show(e,{size:"lg",header:!1}))}a.Modal.alert({message:`Could not find phone data for number: ${e}`,type:"warning"})}}PhoneNumberView.MODEL_CLASS=c.PhoneNumber,c.PhoneNumber.VIEW_CLASS=PhoneNumberView;class PhoneNumberTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_numbers",pageName:"Phone Numbers",router:"admin/phonehub/numbers",Collection:c.PhoneNumberList,viewDialogOptions:{header:!1},columns:[{key:"phone_number",label:"Phone Number",sortable:!0},{key:"carrier",label:"Carrier",sortable:!0,formatter:"default('—')",visibility:"lg"},{key:"line_type",label:"Line Type",sortable:!0,formatter:"capitalize"},{key:"is_mobile",label:"Mobile",formatter:"yesnoicon",visibility:"lg"},{key:"is_voip",label:"VOIP",formatter:"yesnoicon",visibility:"xl"},{key:"is_valid",label:"Valid",formatter:"yesnoicon"},{key:"registered_owner",label:"Owner",sortable:!0,formatter:"default('—')",visibility:"xl"},{key:"owner_type",label:"Owner Type",formatter:"capitalize",visibility:"xl"},{key:"last_lookup_at|relative",label:"Last Lookup",sortable:!0}],searchPlaceholder:"Search number or owner",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 c.PhoneNumber.lookup(e.number);t.model&&await this.showItemDialog(t.model)}}}class SMSView extends t.View{constructor(e={}){super({className:"sms-view",...e}),this.model=e.model||new c.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 d.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 d.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 d.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 o.TabView({containerId:"sms-tabs",tabs:t,activeTab:"Message"}),this.addChild(this.tabView);const i=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(i)}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 a.Modal.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=c.SMS,c.SMS.VIEW_CLASS=SMSView;class SMSTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_sms",pageName:"SMS Messages",router:"admin/phonehub/sms",Collection:c.SMSList,dayRangeFilter:!0,searchPlaceholder:"Search number, body, or provider",defaultQuery:{sort:"-created"},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('—')",visibility:"lg"},{key:"body",label:"Message",formatter:"default('—')"},{key:"sent_at",label:"Sent At",sortable:!0,formatter:"datetime",visibility:"xl"},{key:"delivered_at",label:"Delivered At",sortable:!0,formatter:"datetime",visibility:"xl"},{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 l.TableView({containerId:"recent-deliveries",title:"Recent Deliveries",Collection:new c.PushDeliveryList({params:{_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"status",label:"Status",formatter:"badge"}]}),this.addChild(this.recentDeliveries),this.failedDeliveries=new l.TableView({containerId:"failed-deliveries",title:"Failed Deliveries",Collection:new c.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 n.TablePage{constructor(e={}){super({...e,name:"admin_push_configs",pageName:"Push Configurations",router:"admin/push/configs",Collection:c.PushConfigList,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",formatter:"boolean"}],actions:["edit","delete"],emptyMessage:"No push configurations found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0})}}class PushTemplateTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_templates",pageName:"Push Templates",router:"admin/push/templates",Collection:c.PushTemplateList,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",formatter:"boolean"}],actions:["edit","delete"],emptyMessage:"No push templates found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0})}}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 '}}c.PushDelivery.VIEW_CLASS=PushDeliveryView;class PushDeliveryTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_deliveries",pageName:"Push Deliveries",router:"admin/push/deliveries",Collection:c.PushDeliveryList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, user, or device",defaultQuery:{sort:"-created"},viewDialogOptions:{header:!1,size:"md"},columns:[{key:"id",label:"ID",width:"70px"},{key:"created",label:"Timestamp",formatter:"datetime"},{key:"user.display_name",label:"User",visibility:"lg"},{key:"device.device_name",label:"Device",visibility:"lg"},{key:"title",label:"Title"},{key:"category",label:"Category",visibility:"xl"},{key:"status",label:"Status",formatter:"badge"}],actions:["view"],emptyMessage:"No deliveries found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0})}}class PushDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_devices",pageName:"Registered Devices",router:"admin/push/devices",Collection:c.PushDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search user or device name",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",visibility:"lg"},{key:"app_version",label:"App Version",visibility:"xl"},{key:"push_enabled",label:"Push Enabled",formatter:"boolean",visibility:"lg"},{key:"last_seen",label:"Last Seen",formatter:"datetime"}],actions:["view","delete"],emptyMessage:"No devices found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0})}}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 JobsRunnersStrip extends t.View{constructor(e={}){super({...e,tagName:"div",className:`jobs-runners-strip ${e.className||""}`.trim()}),this.rows=[],this.summary="Loading runners…",this.summaryClass="text-muted",this.empty=!1,this._fetchedOnce=!1}async getTemplate(){return'\n <details class="card shadow-sm" open>\n <summary class="card-header bg-transparent d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-cpu me-1"></i>\n <strong>Runners</strong>\n <span class="ms-2 small {{summaryClass}}">{{summary}}</span>\n </div>\n <i class="bi bi-chevron-down"></i>\n </summary>\n <ul class="list-group list-group-flush mb-0">\n {{#empty|bool}}\n <li class="list-group-item text-danger small"><i class="bi bi-exclamation-triangle me-2"></i>No runners registered — jobs will not be processed.</li>\n {{/empty|bool}}\n {{#rows}}\n <li class="list-group-item d-flex align-items-center gap-3">\n <i class="bi bi-circle-fill {{dotClass}}" style="font-size:0.55rem;"></i>\n <div class="flex-grow-1 min-w-0">\n <code class="small">{{runner_id}}</code>\n <div class="text-muted small text-truncate">{{channelsLabel}} · {{processedLabel}}</div>\n </div>\n <span class="text-muted small">{{when}}</span>\n <span class="badge {{badgeClass}}">{{statusLabel}}</span>\n </li>\n {{/rows}}\n </ul>\n </details>\n '}async onInit(){await this._fetch()}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetch(){const e=this.getApp()?.rest;if(!e)return this.rows=[],this._fetchedOnce=!0,void this._reflectState();try{const t=await e.GET("/api/jobs/runners",{_:Date.now()}),i=t?.data?.data||[];this.rows=i.map(e=>this._normalize(e))}catch(t){console.warn("[JobsRunnersStrip] fetch failed:",t),this.rows=[]}finally{this._fetchedOnce=!0,this._reflectState()}}_reflectState(){this.empty=0===this.rows.length&&this._fetchedOnce,this.summary=this._buildSummary();const e=this.rows.some(e=>!e.alive);this.empty||0===this.rows.length&&this._fetchedOnce?this.summaryClass="text-danger":this.summaryClass=e?"text-warning":"text-success"}_normalize(e){const t=!!e.alive,i=Array.isArray(e.channels)?e.channels:[],s=Number(e.jobs_processed)||0,a=Number(e.jobs_failed)||0;return{runner_id:e.runner_id||e.id||"—",alive:t,dotClass:t?"text-success":"text-danger",badgeClass:t?"text-bg-success":"text-bg-danger",statusLabel:t?"alive":"down",channels:i,channelsLabel:i.length?i.join(", "):"no channels",processed:s,failed:a,processedLabel:a>0?`${s.toLocaleString()} done · ${a.toLocaleString()} failed`:`${s.toLocaleString()} done`,when:this._relativeTime(e.last_heartbeat)}}_buildSummary(){if(!this._fetchedOnce)return"Loading runners…";if(!this.rows.length)return"No runners registered";const e=this.rows.filter(e=>e.alive).length,t=this.rows.length;return`${e} of ${t} runner${1===t?"":"s"} alive · ${this.rows.reduce((e,t)=>e+t.processed,0).toLocaleString()} jobs processed`}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}}let Le=class extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div class="row 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 ',...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)}};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 a.Modal.confirm({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,()=>c.Job.test(),"Test job started successfully")}async onActionRunTestJobs(e,t){await a.Modal.confirm({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,()=>c.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}))],s=await a.Modal.form({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"}]}});s&&await this.executeJobAction(t,()=>c.Job.clearStuck(s.channel||null),e=>{const t=e.data.count||0;return`Cleared ${t} stuck job${1!==t?"s":""}${s.channel?` from channel "${s.channel}"`:""}`})}async onActionClearChannel(e,t){const i=(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel})),s=await a.Modal.form({title:"Clear Channel",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,required:!0,help:"Select the channel to clear."}]}});s&&await this.executeJobAction(t,()=>c.Job.clearChannel(s.channel),`Channel "${s.channel}" cleared successfully.`)}async onActionPurgeJobs(e,t){const i=await a.Modal.form({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,()=>c.Job.purgeJobs(i.days_old),e=>`Purged ${e.data.count||0} old job(s).`)}async onActionCleanupConsumers(e,t){await a.Modal.confirm({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,()=>c.Job.cleanConsumers(),e=>`Cleaned up ${e.data.count||0} consumer(s).`)}async onActionRunnerBroadcast(){const e=await a.Modal.form({title:"Broadcast Command to All Runners",formConfig:c.JobRunnerForms.broadcast});if(e)try{const t=await c.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,i){try{e.disabled=!0;const s=e.querySelector("i");s?.classList.add("spinning");const a=await t();if(a.success&&a.data?.status){const e="function"==typeof i?i(a):i;this.getApp().toast.success(e)}else this.getApp().toast.error(a.data?.error||"Operation failed")}catch(s){console.error("Job action failed:",s),this.getApp().toast.error("Error: "+s.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"}async getTemplate(){return'\n <div class="job-dashboard-container container-fluid">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h1 class="h3 mb-1">Job Engine</h1>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n </div>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all panels">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n\n <div class="mb-3" data-container="job-runners"></div>\n\n <div data-container="job-stats"></div>\n\n <div data-container="job-overview"></div>\n\n <div class="mt-4" data-container="job-operations"></div>\n </div>\n '}async onInit(){this.jobStats=new c.JobsEngineStats,this.runnersStrip=new JobsRunnersStrip({containerId:"job-runners"}),this.addChild(this.runnersStrip),this.jobStatsView=new JobStatsView({containerId:"job-stats",model:this.jobStats}),this.addChild(this.jobStatsView),this.overviewSection=new Le({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,{lazyMount:!0}),this.jobStats.fetch().catch(e=>{console.warn("[JobDashboardPage] initial stats fetch failed:",e)})}async onEnter(){await super.onEnter(),this.scheduleRefresh(()=>this.runnersStrip?.refresh?.(),6e4,{tier:"fast"}),this.scheduleRefresh(()=>this.jobStats?.fetch(),6e4,{tier:"fast"})}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}}const Ee=t.MOJOUtils.escapeHtml;function De(e){return null==e?"—":e>=1e9?(e/1e9).toFixed(2)+" GB":e>=1e6?(e/1e6).toFixed(2)+" MB":e>=1e3?(e/1e3).toFixed(2)+" KB":e+" B"}function Ve(e){return e?(Date.now()-new Date(e).getTime())/1e3:null}function $e(e){return null==e?"never":e<60?`${Math.round(e)}s ago`:e<3600?`${Math.round(e/60)}m ago`:`${Math.round(e/3600)}h ago`}function Re(e){const t=e?.get?.("alive");if(!t)return{key:"down",label:"Down",tone:"danger"};const i=Ve(e.get("last_heartbeat"));return null==i?{key:"stale",label:"No heartbeat",tone:"warning"}:i>=120?{key:"stale",label:"Stale heartbeat",tone:"warning"}:i>=30?{key:"stale",label:"Slow heartbeat",tone:"warning"}:{key:"healthy",label:"Healthy",tone:"success"}}class RunnerOverviewSection extends t.View{constructor(e={}){super({className:"runner-overview-section",template:'\n <div data-container="runner-status"></div>\n <div class="detail-kpi-grid">\n <div data-container="runner-kpi-uptime"></div>\n <div data-container="runner-kpi-processed"></div>\n <div data-container="runner-kpi-failure"></div>\n <div data-container="runner-kpi-active"></div>\n </div>\n ',...e}),this._activeJobs=e.activeJobs||(()=>[])}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"runner-status",model:e,tone:e=>Re(e).tone,state:e=>Re(e).label,headline:e=>this._headline(e),meta:e=>this._meta(e),actions:e=>this._actions(e)}),this.addChild(this.statusPanel),this.kpiUptime=this._kpi("runner-kpi-uptime",()=>"Uptime",e=>this._uptimeText(e)),this.kpiProcessed=this._kpi("runner-kpi-processed",()=>"Jobs processed",e=>(e.get("jobs_processed")||0).toLocaleString(),e=>(e.get("jobs_processed")||0)>0?"success":null),this.kpiFailure=this._kpi("runner-kpi-failure",()=>"Failure rate",e=>this._failureText(e),e=>this._failureTone(e)),this.kpiActive=this._kpi("runner-kpi-active",()=>"Active jobs",()=>String((this._activeJobs()||[]).length),()=>(this._activeJobs()||[]).length>0?"info":null),[this.kpiUptime,this.kpiProcessed,this.kpiFailure,this.kpiActive].forEach(e=>this.addChild(e))}_headline(e){const t=e||this.model,i=Re(t),s=this._uptimeText(t),a=$e(Ve(t.get("last_heartbeat")));return"down"===i.key?`Down · last heartbeat ${a}`:"stale"===i.key?`${i.label} · last heartbeat ${a}`:`Up ${s} · heartbeat ${a}`}_meta(e){const t=e||this.model,i=t.get("channels")||[],s=t.get("jobs_processed")||0,a=t.get("jobs_failed")||0;return`Channels: ${i.length?i.map(e=>`<code>${Ee(String(e))}</code>`).join(", "):'<span class="text-secondary">no channels</span>'} · ${s.toLocaleString()} processed${a>0?` · ${a.toLocaleString()} failed`:""}`}_actions(e){return"down"===Re(e||this.model).key?[]:[{label:"Ping",action:"ping",icon:"bi-broadcast-pin",variant:"outline-secondary"},{label:"Drain",action:"drain",icon:"bi-pause-circle",variant:"outline-warning"},{label:"Shutdown",action:"shutdown",icon:"bi-power",variant:"outline-danger"}]}_uptimeText(e){const t=(e||this.model).get("started");if(!t)return"unknown";const i=(Date.now()-new Date(t).getTime())/1e3;return i>=0?function(e){const t=Math.floor(e/86400),i=Math.floor(e%86400/3600),s=Math.floor(e%3600/60);return t>0?`${t}d ${i}h ${s}m`:i>0?`${i}h ${s}m`:`${s}m`}(i):"unknown"}_failureText(e){const t=e||this.model,i=t.get("jobs_processed")||0,s=t.get("jobs_failed")||0;return i<=0?"—":`${(s/i*100).toFixed(2)}%`}_failureTone(e){const t=e||this.model,i=t.get("jobs_processed")||0,s=t.get("jobs_failed")||0;if(i<=0)return null;const a=s/i*100;return a>=5?"danger":a>=1?"warning":"success"}setActiveJobs(e){this._activeJobsCache=e,this._activeJobs=()=>this._activeJobsCache||[],this.isMounted()&&this.render().catch(()=>{})}async refreshFromModel(){this.isMounted()&&await this.render()}_kpi(e,i,s,a=null){const n=this.model,o=a?a(n):null,l=new t.View({containerId:e,model:n,className:"metric-card"+(o?` metric-card-tone-${o}`:""),template:'\n <div class="metric-card-label">{{kpiLabel}}</div>\n <div class="metric-card-value">{{kpiValue}}</div>\n '});return l.kpiLabel=i(n),l.kpiValue=s(n),l}async onActionPing(){this.emit("action:ping")}async onActionShutdown(){this.emit("action:shutdown")}async onActionDrain(){this.emit("action:drain")}}class RunnerSystemSection extends t.View{constructor(e={}){const{sysinfo:t,sysinfoError:i,loading:s,...a}=e;super({className:"runner-system-section",template:'\n <div class="detail-section-eyebrow">\n <span>{{eyebrowText}}</span>\n <button class="detail-section-action" data-action="refresh-sysinfo" type="button" data-bs-toggle="tooltip" title="Refresh sysinfo">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n\n {{#hasError|bool}}\n <div class="alert alert-warning small mb-3">\n <i class="bi bi-exclamation-triangle me-1"></i>{{errorText}}\n </div>\n {{/hasError|bool}}\n\n {{#isLoading|bool}}\n <div class="text-secondary small text-center py-4">\n <span class="spinner-border spinner-border-sm me-1"></span>Loading system info…\n </div>\n {{/isLoading|bool}}\n\n {{#hasSysinfo|bool}}\n <div class="detail-section-eyebrow">Operating system</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Hostname</div><div class="detail-flat-row-value"><code>{{osHostname}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">System</div><div class="detail-flat-row-value">{{osSystem}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Release</div><div class="detail-flat-row-value"><code>{{osRelease}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Machine</div><div class="detail-flat-row-value"><code>{{osMachine}}</code></div></div>\n {{#hasBootTime|bool}}<div class="detail-flat-row"><div class="detail-flat-row-label">Boot time</div><div class="detail-flat-row-value">{{bootTime}}</div></div>{{/hasBootTime|bool}}\n\n <div class="detail-section-eyebrow">CPU</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Load</div><div class="detail-flat-row-value">{{{cpuMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Cores</div><div class="detail-flat-row-value">{{cpuCount}}</div></div>\n {{#hasCpuFreq|bool}}<div class="detail-flat-row"><div class="detail-flat-row-label">Frequency</div><div class="detail-flat-row-value">{{cpuFreqText}}</div></div>{{/hasCpuFreq|bool}}\n\n {{#hasMemory|bool}}\n <div class="detail-section-eyebrow">Memory</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Usage</div><div class="detail-flat-row-value">{{{memMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Total</div><div class="detail-flat-row-value"><code>{{memTotal}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Used</div><div class="detail-flat-row-value"><code>{{memUsed}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Available</div><div class="detail-flat-row-value"><code class="text-success">{{memAvailable}}</code></div></div>\n {{/hasMemory|bool}}\n\n {{#hasDisk|bool}}\n <div class="detail-section-eyebrow">Disk (root)</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Usage</div><div class="detail-flat-row-value">{{{diskMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Total</div><div class="detail-flat-row-value"><code>{{diskTotal}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Used</div><div class="detail-flat-row-value"><code>{{diskUsed}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Free</div><div class="detail-flat-row-value"><code class="text-success">{{diskFree}}</code></div></div>\n {{/hasDisk|bool}}\n\n {{#hasNetwork|bool}}\n <div class="detail-section-eyebrow">Network</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Bytes recv</div><div class="detail-flat-row-value"><code>{{netBytesRecv}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Bytes sent</div><div class="detail-flat-row-value"><code>{{netBytesSent}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Packets in/out</div><div class="detail-flat-row-value"><code>{{netPacketsIn}}</code> / <code>{{netPacketsOut}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Errors in/out</div><div class="detail-flat-row-value"><code class="{{netErrClass}}">{{netErrIn}}</code> / <code class="{{netErrClass}}">{{netErrOut}}</code></div></div>\n {{/hasNetwork|bool}}\n\n <div class="detail-section-eyebrow">Raw sysinfo</div>\n <div data-container="runner-sysinfo-raw"></div>\n {{/hasSysinfo|bool}}\n\n {{^hasSysinfo|bool}}{{^isLoading|bool}}{{^hasError|bool}}\n <div class="text-secondary small">No system info collected yet.</div>\n {{/hasError|bool}}{{/isLoading|bool}}{{/hasSysinfo|bool}}\n ',...a}),this.sysinfoFn=t||(()=>null),this.errorFn=i||(()=>null),this.loadingFn=s||(()=>!1)}async onInit(){this.rawCard=new n.KnownFieldsCard({containerId:"runner-sysinfo-raw",model:this.model,data:()=>this.sysinfoFn()||{},knownKeys:[],rawCollapsed:!0,rawLabel:"Raw sysinfo"}),this.addChild(this.rawCard)}get _sysinfo(){return this.sysinfoFn()||null}get _err(){return this.errorFn()||null}get isLoading(){return!0===this.loadingFn()}get hasError(){return!!this._err}get hasSysinfo(){return!!this._sysinfo&&!this._err&&!this.isLoading}get errorText(){return String(this._err||"")}get eyebrowText(){const e=this._sysinfo;return e?.datetime?`Collected ${e.datetime}`:"System info"}get osHostname(){return this._sysinfo?.os?.hostname||"—"}get osSystem(){return this._sysinfo?.os?.system||"—"}get osRelease(){return this._sysinfo?.os?.release||"—"}get osMachine(){return this._sysinfo?.os?.machine||"—"}get hasBootTime(){return!!this._sysinfo?.boot_time}get bootTime(){const e=this._sysinfo?.boot_time;return e?new Date(1e3*e).toLocaleString():""}get cpuPct(){return this._sysinfo?.cpu_load??null}get cpuCount(){return this._sysinfo?.cpu?.count?String(this._sysinfo.cpu.count):"—"}get hasCpuFreq(){return!!this._sysinfo?.cpu?.freq}get cpuFreqText(){const e=this._sysinfo?.cpu?.freq;return e?`${Math.round(e.current).toLocaleString()} MHz current · ${Math.round(e.max).toLocaleString()} MHz max`:""}get cpuMeterHtml(){return Ne(this.cpuPct)}get hasMemory(){return!!this._sysinfo?.memory}get memMeterHtml(){const e=this._sysinfo?.memory;return Ne(e?.percent,e?`${De(e.used)} / ${De(e.total)}`:"")}get memTotal(){return De(this._sysinfo?.memory?.total)}get memUsed(){return De(this._sysinfo?.memory?.used)}get memAvailable(){return De(this._sysinfo?.memory?.available)}get hasDisk(){return!!this._sysinfo?.disk}get diskMeterHtml(){const e=this._sysinfo?.disk;return Ne(e?.percent,e?`${De(e.used)} / ${De(e.total)}`:"")}get diskTotal(){return De(this._sysinfo?.disk?.total)}get diskUsed(){return De(this._sysinfo?.disk?.used)}get diskFree(){return De(this._sysinfo?.disk?.free)}get hasNetwork(){return!!this._sysinfo?.network}get netBytesRecv(){return De(this._sysinfo?.network?.bytes_recv)}get netBytesSent(){return De(this._sysinfo?.network?.bytes_sent)}get netPacketsIn(){return String(this._sysinfo?.network?.packets_recv??0)}get netPacketsOut(){return String(this._sysinfo?.network?.packets_sent??0)}get netErrIn(){return String(this._sysinfo?.network?.errin??0)}get netErrOut(){return String(this._sysinfo?.network?.errout??0)}get netErrClass(){const e=this._sysinfo?.network;return e&&(e.errin>0||e.errout>0)?"text-danger fw-bold":""}async onActionRefreshSysinfo(){this.emit("action:refresh-sysinfo")}}function Ne(e,t=""){if(null==e)return`\n <div style="width: 100%;">\n <div class="d-flex justify-content-between mb-1">\n <span class="text-secondary small">${t?Ee(t):""}</span>\n <span class="text-secondary small">—</span>\n </div>\n <div class="progress" role="progressbar" style="height: 6px;"><div class="progress-bar bg-secondary" style="width: 0%"></div></div>\n </div>\n `;const i=function(e){return null==e?"secondary":e>=80?"danger":e>=60?"warning":"success"}(e);return`\n <div style="width: 100%;">\n <div class="d-flex justify-content-between mb-1">\n <span class="text-secondary small">${t?Ee(t):""}</span>\n <span class="small fw-bold">${e.toFixed(0)}%</span>\n </div>\n <div class="progress" role="progressbar" style="height: 6px;">\n <div class="progress-bar bg-${i}" style="width: ${e.toFixed(0)}%;"></div>\n </div>\n </div>\n `}class RunnerChannelsSection extends t.View{constructor(e={}){const{activeJobs:t,...i}=e;super({className:"runner-channels-section",template:'\n <div class="detail-section-eyebrow">{{channelsEyebrow}}</div>\n {{#hasChannels|bool}}\n {{{channelRowsHtml}}}\n {{/hasChannels|bool}}\n {{^hasChannels|bool}}\n <div class="text-secondary small py-3">\n This runner serves no channels — it will not receive any jobs.\n </div>\n {{/hasChannels|bool}}\n ',...i}),this.activeJobsFn=t||(()=>[])}get _channels(){return this.model.get("channels")||[]}get hasChannels(){return this._channels.length>0}get channelsEyebrow(){const e=this._channels.length;return`${e} channel${1===e?"":"s"}`}get channelRowsHtml(){const e=this.activeJobsFn()||[];return this._channels.map(t=>{const i=e.filter(e=>e.channel===t).length,s=i>0?"info":"secondary";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-broadcast me-1"></i>Channel</div>\n <div class="detail-flat-row-value"><code>${Ee(String(t))}</code></div>\n <div class="detail-flat-row-action"><span class="badge text-bg-${s}">${i} active</span></div>\n </div>\n `}).join("")}}class RunnerActionsSection extends t.View{constructor(e={}){super({className:"runner-actions-section",template:'\n <div class="detail-section-eyebrow">Operates on <code>{{model.runner_id|default:\'unknown\'}}</code></div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-broadcast-pin me-1"></i>Ping</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Verify the runner is responsive — fire-and-forget.</span>\n {{#pingResult|bool}}<div class="small mt-1">{{{pingResult}}}</div>{{/pingResult|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-success" data-action="ping" type="button">\n <i class="bi bi-broadcast-pin me-1"></i>Ping now\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-pause-circle me-1"></i>Drain</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Stop accepting new jobs; finish in-flight work.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-warning" data-action="drain" type="button">\n <i class="bi bi-pause-circle me-1"></i>Drain\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-arrow-clockwise me-1"></i>Restart</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Graceful shutdown then restart on the same host.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-primary" data-action="restart" type="button">\n <i class="bi bi-arrow-clockwise me-1"></i>Restart\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-power me-1"></i>Shutdown</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Finish current job then exit. Fire-and-forget.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-danger" data-action="shutdown" type="button">\n <i class="bi bi-power me-1"></i>Shutdown\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-download me-1"></i>Export</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Download runner identity data as a JSON file.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-secondary" data-action="export" type="button">\n <i class="bi bi-download me-1"></i>Export\n </button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Broadcast command</div>\n <p class="text-secondary 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 small text-secondary 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 small text-secondary mb-1">Timeout (s)</label>\n <input type="number" class="form-control form-control-sm" 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" type="button">\n <i class="bi bi-megaphone me-1"></i>Broadcast to all runners\n </button>\n </div>\n </div>\n ',...e}),this.pingResult=""}setPingResult(e){this.pingResult=e||"",this.isMounted()&&this.render().catch(()=>{})}async onActionPing(){this.emit("action:ping")}async onActionShutdown(){this.emit("action:shutdown")}async onActionDrain(){this.emit("action:drain")}async onActionRestart(){this.emit("action:restart")}async onActionExport(){this.emit("action:export")}async onActionBroadcast(){const e=this.element?.querySelector('[data-field="broadcast-command"]'),t=this.element?.querySelector('[data-field="broadcast-timeout"]'),i=e?e.value:"status",s=t&&parseFloat(t.value)||2;this.emit("action:broadcast",{command:i,timeout:s})}}class RunnerDetailsView extends n.DetailView{constructor(e={}){const i=e.model instanceof c.JobRunner?e.model:new c.JobRunner(e.model||e.data||{}),s=i.get("runner_id"),a=new c.ActiveJobsList({runnerId:s,params:{size:25,sort:"-started_at"}}),n=new c.JobList({params:{runner_id:s,status:"completed",size:25,sort:"-created"}}),o=new c.JobLogList({params:{runner_id:s,size:50,sort:"-created"}}),r=new RunnerOverviewSection({model:i,activeJobs:()=>a.models?.map(e=>e.attributes||e)||[]}),d=new RunnerSystemSection({model:i,sysinfo:()=>i.attributes._sysinfo,sysinfoError:()=>i.attributes._sysinfoError,loading:()=>i.attributes._sysinfoLoading}),h=new RunnerChannelsSection({model:i,activeJobs:()=>a.models?.map(e=>e.attributes||e)||[]}),u=new l.TableView({collection:a,title:"Active jobs",eyebrow:"Section · Active jobs",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id","status"],clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.func|default:\'—\'}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge",width:"110px"},{key:"started_at",label:"Started",formatter:"relative",sortable:!0,width:"140px"},{key:"attempt",label:"Attempt",width:"80px"}]}),m=new l.TableView({collection:n,title:"Job history",eyebrow:"Section · Recent completed jobs",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id","status"],clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.func|default:\'—\'}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge",width:"110px"},{key:"status",label:"Status",width:"110px",formatter:e=>`<span class="badge text-bg-${{completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",expired:"warning"}[e]||"secondary"}">${t.MOJOUtils.escapeHtml(String(e||"unknown").toUpperCase())}</span>`},{key:"created",label:"Finished",formatter:"relative",sortable:!0,width:"140px"},{key:"duration_ms",label:"Duration",formatter:"duration",width:"110px"}]}),p=new l.TableView({collection:o,title:"Logs",eyebrow:"Section · Recent logs from this runner",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id"],columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"kind",label:"Kind",formatter:"badge",width:"100px"},{key:"job_id",label:"Job",formatter:e=>e?`<code class="small">${t.MOJOUtils.escapeHtml(String(e).slice(0,12))}</code>`:'<span class="text-secondary">—</span>',width:"130px"},{key:"message",label:"Message"}]}),b=new RunnerActionsSection({model:i}),g=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:r},{key:"System",label:"System",icon:"bi-cpu",view:d},{key:"Channels",label:"Channels",icon:"bi-broadcast",view:h},{key:"Active Jobs",label:"Active Jobs",icon:"bi-hourglass-split",view:u},{type:"divider",label:"History"},{key:"Job History",label:"Job History",icon:"bi-clock-history",view:m},{key:"Logs",label:"Logs",icon:"bi-code-square",view:p},{type:"divider",label:"Control"},{key:"Actions",label:"Actions",icon:"bi-power",view:b}],v=[{icon:"bi-broadcast-pin",text:e=>Re(e).label,variant:e=>{const t=Re(e).tone;return"danger"===t?"danger":"warning"===t?"warning":"success"}},{icon:"bi-tag",text:e=>e.get("version")?`v${e.get("version")}`:null,variant:"light",when:e=>!!e.get("version")},{icon:"bi-broadcast",text:e=>{const t=e.get("channels")||[];return t.length?`channels: ${t.join(" · ")}`:null},variant:"info",when:e=>(e.get("channels")||[]).length>0},{icon:"bi-pc-display",text:e=>{const t=e.attributes._sysinfo;return t?.os&&`${t.os.system||""}${t.os.machine?` · ${t.os.machine}`:""}`.trim()||null},variant:"light"}];"function"==typeof v[0].variant&&(v[0].variant=v[0].variant(i)),super({className:"runner-details-view",...e,model:i,header:{icon:"bi-cpu",iconToneFn:e=>Re(e).tone,titleFn:e=>e.get("runner_id")||"unknown",chips:v,auxFn:e=>function(e){if(!e)return"";const t=Re(e),i=e.get("started"),s=i?(Date.now()-new Date(i).getTime())/1e3:null,a=Ve(e.get("last_heartbeat")),n=e.get("jobs_processed")||0,o=e.get("jobs_failed")||0,l=n>0?`${(o/n*100).toFixed(2)}%`:"0%";let r,c;return"down"===t.key?(r="Down",c=null!=a?`last heartbeat ${$e(a)}`:""):"stale"===t.key?(r=t.label,c=null!=a?$e(a):""):(r=`${null!=s?`Up ${function(e){const t=Math.floor(e/86400),i=Math.floor(e%86400/3600);return t>0?`${t}d`:i>0?`${i}h`:`${Math.floor(e%3600/60)}m`}(s)}`:"Up"} · ${l} failure`,c=null!=a?`heartbeat ${$e(a)}`:""),r?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${t.tone&&"default"!==t.tone?` dh-aux-dot-${t.tone}`:""}"></span>\n <span>${Ee(r)}</span>\n </span>\n ${c?`<span class="dh-aux-meta">${Ee(c)}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:[{label:"Ping runner",action:"ping",icon:"bi-broadcast-pin"},{label:"Broadcast command…",action:"broadcast-prompt",icon:"bi-megaphone"},{label:"Drain mode",action:"drain",icon:"bi-pause-circle"},{label:"Restart",action:"restart",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Shutdown",action:"shutdown",icon:"bi-power",danger:!0},{type:"divider"},{label:"Export snapshot",action:"export",icon:"bi-download"}]}},sections:g,activeSection:"Overview"}),this.activeJobsCollection=a,this.jobHistoryCollection=n,this.logsCollection=o,this.overviewSection=r,this.systemSection=d,this.channelsSection=h,this.activeJobsSection=u,this.jobHistorySection=m,this.logsSection=p,this.actionsSection=b}async onAfterBuild(){this.overviewSection.on("action:ping",()=>this.onActionPing()),this.overviewSection.on("action:shutdown",()=>this.onActionShutdown()),this.overviewSection.on("action:drain",()=>this.onActionDrain()),this.systemSection.on("action:refresh-sysinfo",()=>this._loadSysinfo({force:!0})),this.actionsSection.on("action:ping",()=>this.onActionPing()),this.actionsSection.on("action:shutdown",()=>this.onActionShutdown()),this.actionsSection.on("action:drain",()=>this.onActionDrain()),this.actionsSection.on("action:restart",()=>this.onActionRestart()),this.actionsSection.on("action:export",()=>this.onActionExport()),this.actionsSection.on("action:broadcast",({command:e,timeout:t})=>this.onActionBroadcastWith(e,t)),this._updateChannelsBadge(),this._updateActiveJobsBadge(),this._updateJobHistoryBadge(),this.activeJobsCollection.on?.("fetch:success",()=>{this._updateActiveJobsBadge(),this._refreshOverviewActiveCount(),this.channelsSection?.isMounted()&&this.channelsSection.render().catch(()=>{})},this),this.jobHistoryCollection.on?.("fetch:success",()=>this._updateJobHistoryBadge(),this),this.activeJobsCollection.fetch().catch(()=>{}),this.jobHistoryCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this._loadSysinfo(),this._pollHandle=setInterval(()=>{this._loadSysinfo({silent:!0}),this.activeJobsCollection.fetch().catch(()=>{}),this.overviewSection?.isMounted()&&this.overviewSection.refreshFromModel().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})},15e3)}_updateActiveJobsBadge(){const e=this.activeJobsCollection.totalCount??this.activeJobsCollection.models?.length??0;this.setBadge?.("Active Jobs",e>0?{text:String(e),variant:"muted"}:null)}_updateChannelsBadge(){const e=(this.model.get("channels")||[]).length;this.setBadge?.("Channels",e>0?{text:String(e),variant:"muted"}:null)}_updateJobHistoryBadge(){const e=this.jobHistoryCollection.totalCount??this.jobHistoryCollection.models?.length??0;this.setBadge?.("Job History",e>0?{text:String(e),variant:"muted"}:null)}_refreshOverviewActiveCount(){const e=this.activeJobsCollection.models?.map(e=>e.attributes||e)||[];this.overviewSection.setActiveJobs?.(e)}async _loadSysinfo({force:e=!1,silent:t=!1}={}){const i=this.model;t||(i.attributes._sysinfoLoading=!0,i.attributes._sysinfoError=null,this.systemSection?.isMounted()&&this.systemSection.render().catch(()=>{}));try{const e=await this.getApp().rest.GET(`/api/jobs/runners/sysinfo/${encodeURIComponent(i.get("runner_id"))}`);if(e.success&&e.data){const t=e.data.data||e.data;if(t&&"error"===t.status)i.attributes._sysinfo=null,i.attributes._sysinfoError=t.error||"Runner reported an error collecting sysinfo.";else if(e.data.status||t?.cpu_load||t?.memory){const e=t.result||t;i.attributes._sysinfo=e,i.attributes._sysinfoError=null}else i.attributes._sysinfo=null,i.attributes._sysinfoError=e.data.error||"Could not load system info."}else i.attributes._sysinfo=null,i.attributes._sysinfoError="Could not load system info."}catch(s){i.attributes._sysinfo=null,i.attributes._sysinfoError=s.message||"Request failed."}finally{i.attributes._sysinfoLoading=!1,this.systemSection?.isMounted()&&this.systemSection.render().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}e&&this.getApp()?.toast?.info("Sysinfo refreshed")}async onActionPing(){try{const e=await this.getApp().rest.POST("/api/jobs/runners/ping",{runner_id:this.model.get("runner_id"),timeout:2});let t;t=e.success&&e.data?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>':'<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Ping request failed</span>',this.actionsSection.setPingResult(t),this.getApp()?.toast?.info("Ping complete")}catch(e){this.actionsSection.setPingResult(`<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${Ee(e.message||"Ping failed")}</span>`)}}async onActionShutdown(){if(await a.Modal.confirm(`Send a graceful shutdown to <strong class="font-monospace">${Ee(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 onActionDrain(){await a.Modal.confirm(`Place <strong class="font-monospace">${Ee(this.model.get("runner_id")||"")}</strong> in drain mode?<br><br>The runner stops accepting new jobs but finishes its current ones.`,"Drain Mode",{confirmText:"Drain",confirmClass:"btn-warning"})&&this.getApp()?.toast?.info("Drain mode requested (backend integration pending).")}async onActionRestart(){await a.Modal.confirm(`Restart <strong class="font-monospace">${Ee(this.model.get("runner_id")||"")}</strong>?<br><br>The runner will gracefully shut down then relaunch on the same host.`,"Restart Runner",{confirmText:"Restart",confirmClass:"btn-primary"})&&this.getApp()?.toast?.info("Restart requested (backend integration pending).")}async onActionBroadcastPrompt(){const e=await a.Modal.form({title:"Broadcast Command",size:"md",fields:[{name:"command",type:"select",label:"Command",required:!0,options:[{value:"status",label:"Status check"},{value:"pause",label:"Pause processing"},{value:"resume",label:"Resume processing"},{value:"reload",label:"Reload configuration"},{value:"shutdown",label:"Shutdown all runners"}]},{name:"timeout",type:"number",label:"Timeout (s)",value:2,min:.5,step:.5}],submitText:"Broadcast",cancelText:"Cancel"});e&&await this.onActionBroadcastWith(e.command,parseFloat(e.timeout)||2)}async onActionBroadcastWith(e,t){a.Modal.showBusy({message:`Broadcasting "${e}" to all runners…`});try{const i=await this.getApp().rest.POST("/api/jobs/runners/broadcast",{command:e,timeout:t});a.Modal.hideBusy(),i.success&&i.data?await a.Modal.code({code:JSON.stringify(i.data,null,2),language:"json",title:`Broadcast Response — ${e}`,size:"lg"}):this.showError?.(i.data&&i.data.error||"Broadcast failed.")}catch(i){a.Modal.hideBusy(),this.showError?.("Broadcast failed: "+i.message)}}async onActionExport(){try{const e={runner:this.model.toJSON?this.model.toJSON():this.model.attributes,exported_at:/* @__PURE__ */(new Date).toISOString()},t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),s=Object.assign(document.createElement("a"),{href:i,download:`runner-${this.model.get("runner_id")}-${Date.now()}.json`});document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(i),this.showSuccess?.("Runner data exported.")}catch(e){this.showError?.("Export failed: "+e.message)}}async onUnmount(){this._pollHandle&&(clearInterval(this._pollHandle),this._pollHandle=null),super.onUnmount&&await super.onUnmount()}static async show(e,t={}){const i=e instanceof c.JobRunner?e:new c.JobRunner(e),s=new RunnerDetailsView({model:i});return await a.Modal.detail(s,t)}}RunnerDetailsView.VIEW_CLASS=RunnerDetailsView,c.JobRunner.VIEW_CLASS=RunnerDetailsView,c.JobRunner.MODEL_REF="jobs.JobRunner";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 &amp; 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 l.TableView({containerId:"runner-table",Collection:c.JobRunnerList,searchable:!0,filterable:!1,paginated:!0,itemView:RunnerDetailsView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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),i=/* @__PURE__ */new Date-t,s=Math.floor(i/1e3);return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:`${Math.floor(s/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)}}const Fe=t.MOJOUtils.escapeHtml,Oe={pending:"info",running:"info",completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",expired:"warning"},Be={pending:"bi-hourglass",running:"bi-arrow-repeat",completed:"bi-check-circle",failed:"bi-x-octagon",canceled:"bi-x-circle",cancelled:"bi-x-circle",expired:"bi-clock"},je={enqueued:"info",started:"info",success:"success",completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",timeout:"warning",retry:"warning"},He={enqueued:"bi-inbox",started:"bi-play-fill",success:"bi-check-circle",completed:"bi-check-circle",failed:"bi-x-octagon",canceled:"bi-x-circle",cancelled:"bi-x-circle",timeout:"bi-stopwatch",retry:"bi-arrow-repeat"};function Ue(e){if(!e)return"secondary";const t=e.get("status")||"unknown";return!0===e.isScheduled?.()?"warning":Oe[t]||"secondary"}class JobOverviewSection2 extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div data-container="job-status"></div>\n <div class="detail-kpi-grid">\n <div data-container="job-kpi-attempt"></div>\n <div data-container="job-kpi-runtime"></div>\n <div data-container="job-kpi-retries"></div>\n <div data-container="job-kpi-next"></div>\n </div>\n <div class="detail-pair">\n <div data-container="job-overview-execution"></div>\n <div data-container="job-overview-lifecycle"></div>\n </div>\n ',...e})}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"job-status",model:e,tone:e=>Ue(e),state:e=>this._narrative(e).state,headline:e=>this._narrative(e).headline,meta:e=>this._narrative(e).meta,actions:e=>this._actions(e)}),this.addChild(this.statusPanel),this.kpiAttempt=this._kpi("job-kpi-attempt",()=>"Attempt",e=>`${e.get("attempt")??0} / ${e.get("max_retries")||"∞"}`,e=>(e.get("attempt")??0)>0&&"failed"===e.get("status")?"warning":null),this.kpiRuntime=this._kpi("job-kpi-runtime",()=>"Runtime",e=>e.getFormattedDuration?.()||"—"),this.kpiRetries=this._kpi("job-kpi-retries",()=>"Retries left",e=>String(Math.max(0,(e.get("max_retries")??0)-(e.get("attempt")??0)))),this.kpiNext=this._kpi("job-kpi-next",e=>!0===e.isScheduled?.()?"Scheduled":"Next",e=>this._nextLabel(e),e=>this._nextTone(e)),[this.kpiAttempt,this.kpiRuntime,this.kpiRetries,this.kpiNext].forEach(e=>this.addChild(e)),this.executionCard=new JobExecutionCard({containerId:"job-overview-execution",model:e}),this.addChild(this.executionCard),this.lifecycleCard=new JobLifecycleCard({containerId:"job-overview-lifecycle",model:e}),this.addChild(this.lifecycleCard)}_narrative(e){const t=e||this.model,i=t.get("status")||"unknown",s=!0===t.isScheduled?.(),a=t.get("created"),n=t.get("started_at"),o=t.get("finished_at"),l=t.get("runner_id"),r=t.get("attempt")??0,c=t.get("max_retries")??0,d=t.getFormattedDuration?.(),h=t.get("last_error")||"";if(s)return{state:"Scheduled",headline:`Runs ${this._fmtRelative(t.get("run_at"))}`,meta:`Function <code>${Fe(t.get("func")||"unknown")}</code> on channel <code>${Fe(t.get("channel")||"?")}</code> · queued ${Fe(this._fmtRelative(a))}`};if("running"===i)return{state:"Running",headline:l?`Running on ${l} · ${this._fmtRelative(n)}`:`Running · started ${this._fmtRelative(n)}`,meta:`Attempt <strong>${r}</strong> of <strong>${c||"∞"}</strong>`};if("completed"===i)return{state:"Completed",headline:d&&"N/A"!==d?`Completed in ${d}`:"Completed",meta:`Finished ${Fe(this._fmtRelative(o))}${l?` on ${Fe(l)}`:""}`};if("failed"===i){const e=h.split("\n")[0]||"Failed";return{state:"Failed",headline:d&&"N/A"!==d?`Failed after ${d}`:"Failed",meta:`Attempt <strong>${r}</strong> of <strong>${c||"∞"}</strong>${t.canRetry?.()?" · retry available":""}<br><code class="text-danger">${Fe(e)}</code>`}}return"canceled"===i||"cancelled"===i?{state:"Cancelled",headline:"Cancelled",meta:`Cancelled ${Fe(this._fmtRelative(o||t.get("modified")))}`}:"expired"===i?{state:"Expired",headline:"Expired before completion",meta:`Created ${Fe(this._fmtRelative(a))}`}:{state:"Pending",headline:"Waiting for a runner",meta:`Queued on channel <code>${Fe(t.get("channel")||"?")}</code> · ${Fe(this._fmtRelative(a))}`}}_actions(e){const t=e||this.model,i=[];return t.canRetry?.()&&i.push({label:"Retry now",action:"retry",icon:"bi-arrow-clockwise",variant:"primary"}),t.canCancel?.()&&i.push({label:"Cancel",action:"cancel",icon:"bi-x-circle",variant:"outline-danger"}),i}_nextLabel(e){const t=e||this.model,i=t.get("status")||"unknown";if(!0===t.isScheduled?.()){const e=Ge(t.get("run_at"));return e?qe(e):"Scheduled"}return"failed"===i&&t.canRetry?.()?"Retry available":"running"===i?"In flight":"—"}_nextTone(e){const t=e||this.model,i=t.get("status")||"unknown";return!0===t.isScheduled?.()?"warning":"failed"===i&&t.canRetry?.()||"running"===i?"info":null}_fmtRelative(e){const t=Ge(e);return null==t?"—":qe(t)}async onActionRetry(){this.emit("action:retry")}async onActionCancel(){this.emit("action:cancel")}_kpi(e,i,s,a=null){const n=this.model,o=a?a(n):null,l=new t.View({containerId:e,model:n,className:"metric-card"+(o?` metric-card-tone-${o}`:""),template:'\n <div class="metric-card-label">{{kpiLabel}}</div>\n <div class="metric-card-value">{{kpiValue}}</div>\n '});return l.kpiLabel=i(n),l.kpiValue=s(n),l}}class JobExecutionCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-cpu"></i>Execution</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Function</div>\n <div class="detail-flat-row-value"><code>{{model.func|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Channel</div>\n <div class="detail-flat-row-value"><code>{{model.channel|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Runner</div>\n <div class="detail-flat-row-value">\n {{#hasRunner|bool}}<code>{{model.runner_id}}</code>{{/hasRunner|bool}}\n {{^hasRunner|bool}}<span class="text-secondary">—</span>{{/hasRunner|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Created</div>\n <div class="detail-flat-row-value"><code>{{model.created|datetime}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Started</div>\n <div class="detail-flat-row-value">\n {{#hasStarted|bool}}<code>{{model.started_at|datetime}}</code>{{/hasStarted|bool}}\n {{^hasStarted|bool}}<span class="text-secondary">—</span>{{/hasStarted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Finished</div>\n <div class="detail-flat-row-value">\n {{#hasFinished|bool}}<code>{{model.finished_at|datetime}}</code>{{/hasFinished|bool}}\n {{^hasFinished|bool}}<span class="text-secondary">—</span>{{/hasFinished|bool}}\n </div>\n </div>\n {{#isScheduled|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Scheduled</div>\n <div class="detail-flat-row-value"><code>{{model.run_at|datetime}} · {{runAtRelative}}</code></div>\n </div>\n {{/isScheduled|bool}}\n {{#hasExpires|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Expires</div>\n <div class="detail-flat-row-value"><code>{{model.expires_at|datetime}}</code></div>\n </div>\n {{/hasExpires|bool}}\n {{#hasError|bool}}\n <pre class="detail-error-block">{{model.last_error}}</pre>\n {{/hasError|bool}}\n </div>\n </div>\n ',...e})}get hasRunner(){return!!this.model?.get?.("runner_id")}get hasStarted(){return!!this.model?.get?.("started_at")}get hasFinished(){return!!this.model?.get?.("finished_at")}get hasExpires(){return!!this.model?.get?.("expires_at")}get hasError(){return!!this.model?.get?.("last_error")}get isScheduled(){return!0===this.model?.isScheduled?.()}get runAtRelative(){const e=Ge(this.model?.get?.("run_at"));return null==e?"—":qe(e)}}class JobLifecycleCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-list-ul"></i>Lifecycle</div>\n <div data-container="job-lifecycle-timeline"></div>\n </div>\n </div>\n ',...e})}async onInit(){this.timeline=new n.Timeline({containerId:"job-lifecycle-timeline",model:this.model,limit:8,emptyText:"No events recorded yet. Lifecycle entries appear here as the runner picks up the job and emits events.",items:e=>(e.getEvents?.()||[]).map(e=>ze(e,!0))}),this.addChild(this.timeline)}}function ze(e,t=!1){if(!e)return null;const i=(e.event||"").toLowerCase(),s=je[i]||null,a=e.label||e.event||"event";let n="";if(e.details){const t="string"==typeof e.details?e.details:JSON.stringify(e.details);n=Fe(t)}else e.runner_id&&(n=`runner <code>${Fe(String(e.runner_id))}</code>`);return{tone:s,headline:a,detail:n,when:e.at?t?Je(e.at):function(e){const t=Ge(e);return null==t?"—":new Date(t).toLocaleString()}(e.at):"",_icon:He[i]||null}}class JobPayloadSection extends t.View{constructor(e={}){super({className:"job-payload-section",template:'\n <div class="detail-section-eyebrow">Payload</div>\n <pre class="detail-payload-block"><code>{{{model.payload|json}}}</code></pre>\n ',...e})}}class RetryHistorySection extends t.View{constructor(e={}){const{collection:t,...i}=e;super({className:"job-retry-history-section",template:'\n <div class="detail-section-eyebrow">Retry History</div>\n <div data-container="retry-timeline"></div>\n ',...i}),this.collection=t||null}async onInit(){this.timeline=new n.Timeline({containerId:"retry-timeline",model:this.model,emptyText:"No retry events yet.",items:()=>this._buildItems()}),this.addChild(this.timeline)}_buildItems(){return this.collection&&this.collection.models&&this.collection.models.length?this.collection.models.map(e=>ze(e.attributes||e,!1)).filter(Boolean):(this.model?.getEvents?.()||[]).filter(e=>"retry"===(e.event||"").toLowerCase()).map(e=>ze(e,!0)).filter(Boolean)}async refresh(){await(this.timeline?.render())}}function qe(e){const t=Math.round((e-Date.now())/1e3);if(t<=0){const e=-t;return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`}return t<60?`in ${t}s`:t<3600?`in ${Math.floor(t/60)}m`:t<86400?`in ${Math.floor(t/3600)}h`:`in ${Math.floor(t/86400)}d`}function Ge(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}function Je(e){const t=Ge(e);return null==t?"—":qe(t)}class JobDetailsView extends n.DetailView{constructor(e={}){const i=e.model||new c.Job(e.data||{}),s=i.get("status")||"unknown",a=!0===i.isScheduled?.(),n=new c.JobEventList({params:{job:i.get("id"),size:25,sort:"-at"}}),o=new c.JobEventList({params:{job:i.get("id"),event:"retry",ordering:"-at",size:25}}),r=new c.JobLogList({params:{job_id:i.get("id"),size:25,sort:"-created"}}),d=new JobOverviewSection2({model:i}),h=new JobPayloadSection({model:i}),u=new RetryHistorySection({model:i,collection:o}),m=new l.TableView({collection:n,title:"Events",eyebrow:"Section · Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["job"],columns:[{key:"at",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"event",label:"Event",formatter:"badge"},{key:"runner_id",label:"Runner",formatter:e=>e?`<span class="font-monospace small">${t.MOJOUtils.escapeHtml(String(e))}</span>`:'<span class="text-secondary">—</span>'},{key:"attempt",label:"Attempt",width:"70px"},{key:"details|json",label:"Details"}]}),p=new l.TableView({collection:r,title:"Logs",eyebrow:"Section · Logs",showFullscreen:!1,searchable:!1,hideActivePillNames:["job_id"],columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"kind",label:"Kind",formatter:"badge",width:"100px"},{key:"message",label:"Message"}]});let b=null;const g=i.get("func");if(g){const e=new c.SimilarJobsList({func:g,params:{size:15}});b=new l.TableView({collection:e,title:"Similar jobs",eyebrow:"Section · Similar",showFullscreen:!1,searchable:!1,hideActivePillNames:["func"],clickAction:"view",itemView:JobDetailsView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.channel}}</div>\n '},{key:"status",label:"Status",formatter:(e,i)=>{const s=i.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${t.MOJOUtils.escapeHtml((e||"unknown").toUpperCase())}</span>`}},{key:"created",label:"Created",formatter:"relative",sortable:!0},{key:"duration_ms",label:"Duration",formatter:"duration"}]})}const v=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:d},{key:"Payload",label:"Payload",icon:"bi-braces",view:h},{type:"divider",label:"Activity"},{key:"Events",label:"Events",icon:"bi-list-ul",view:m},{key:"Logs",label:"Logs",icon:"bi-code-square",view:p},{key:"RetryHistory",label:"Retry History",icon:"bi-arrow-repeat",view:u}];b&&(v.push({type:"divider",label:"Related"}),v.push({key:"Similar",label:"Similar",icon:"bi-files",view:b}));const y=a?"bi-clock-fill":Be[s]||"bi-question-circle",w=[{icon:"bi-broadcast",textPath:"channel",variant:"info"},{text:e=>e.get("id")?`#${String(e.get("id")).slice(-8)}`:null,variant:"light",when:e=>e.get("id")},{icon:"bi-cpu",text:e=>e.get("runner_id")||null,variant:"light",when:e=>!!e.get("runner_id")},{text:e=>`attempt ${e.get("attempt")??0}/${e.get("max_retries")??"∞"}`,variant:"light",when:e=>(e.get("attempt")??0)>0||(e.get("max_retries")??0)>0},{text:e=>{const t=e.getFormattedDuration?.();return t&&"N/A"!==t?`duration ${t}`:null},variant:"light"},{icon:"bi-exclamation-triangle",text:"cancel requested",variant:"warning",when:e=>!!e.get("cancel_requested")}],f=[{label:"Refresh",action:"refresh-job",icon:"bi-arrow-clockwise"}];i.canRetry?.()&&(f.push({type:"divider"}),f.push({label:"Retry job",action:"retry-job",icon:"bi-arrow-repeat"})),i.canCancel?.()&&(f.push({type:"divider"}),f.push({label:"Cancel job",action:"cancel-job",icon:"bi-x-circle",danger:!0})),super({className:"job-details-view",...e,model:i,header:{icon:y,iconToneFn:e=>Ue(e),titleFn:e=>e.get("func")||"unknown.task",subtitleFn:e=>function(e){const t=e.get("status")||"unknown";if(!0===e.isScheduled?.())return`Scheduled · runs ${Je(e.get("run_at"))}`;if("running"===t)return e.get("runner_id")?`Running on ${e.get("runner_id")} · started ${Je(e.get("started_at"))}`:`Running · started ${Je(e.get("started_at"))}`;if("failed"===t){const t=(e.get("last_error")||"").split("\n")[0],i=e.getFormattedDuration?.();return t?`Failed${i&&"N/A"!==i?` after ${i}`:""} · ${t}`:"Failed"}if("completed"===t){const t=e.getFormattedDuration?.();return t&&"N/A"!==t?`Completed in ${t}`:"Completed"}return"canceled"===t||"cancelled"===t?"Cancelled":"expired"===t?"Expired before completion":"Pending — waiting for a runner"}(e),chips:w,auxFn:e=>function(e){const t=e.get("status")||"unknown",i=!0===e.isScheduled?.(),s=Ue(e);let a,n;if(i){a="Scheduled";const t=Ge(e.get("run_at"));n=t?`runs ${Fe(qe(t))}`:""}else if("running"===t){const t=e.get("runner_id");a=t?`Running on ${Fe(String(t))}`:"Running",n=`started ${Fe(Je(e.get("started_at")))}`}else"failed"===t?(a="Failed",n=`${Fe(Je(e.get("finished_at")||e.get("modified")))}`):"completed"===t?(a="Completed",n=`${Fe(Je(e.get("finished_at")))}`):"canceled"===t||"cancelled"===t?(a="Cancelled",n=`${Fe(Je(e.get("finished_at")||e.get("modified")))}`):"expired"===t?(a="Expired",n=""):(a="Pending",n=`queued ${Fe(Je(e.get("created")))}`);return a?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${s&&"default"!==s?` dh-aux-dot-${s}`:""}"></span>\n <span>${Fe(a)}</span>\n </span>\n ${n?`<span class="dh-aux-meta">${n}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:f}},sections:v,activeSection:"Overview"}),this.eventsCollection=n,this.retryCollection=o,this.logsCollection=r,this.overviewSection=d,this.payloadSection=h,this.retrySection=u,this.eventsSection=m,this.logsSection=p,this.similarSection=b}async onAfterBuild(){this.overviewSection.on("action:retry",()=>this.onActionRetryJob()),this.overviewSection.on("action:cancel",()=>this.onActionCancelJob());try{await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel()}catch(e){console.warn("[JobDetailsView] initial fetch failed:",e)}this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this.retryCollection.fetch().then(()=>{this.retrySection?.isMounted?.()&&this.retrySection.refresh(),this.setBadge?.("RetryHistory",this.retryCollection.models?.length||0)}).catch(()=>{}),this.similarSection?.collection&&this.similarSection.collection.fetch?.().then(()=>{this.setBadge?.("Similar",this.similarSection.collection.models?.length||0)}).catch(()=>{})}async _refreshFromModel(){this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render()}async onActionRefreshJob(){try{await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel(),this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this.retryCollection.fetch().then(()=>this.retrySection?.refresh()).catch(()=>{})}catch(e){this.getApp()?.toast?.error(e.message||"Failed to refresh job")}}async onActionCancelJob(){if(!(await a.Modal.confirm(`Cancel job <code>${Fe(String(this.model.get("id")||""))}</code>?`,"Cancel Job")))return!0;try{const e=await this.model.cancel();e.success?(this.getApp()?.toast?.success("Cancellation requested"),await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel(),this.emit("job-cancelled",{job:this.model})):this.getApp()?.toast?.error(e.data?.error||"Failed to cancel job")}catch(e){console.error("[JobDetailsView] cancel failed:",e),this.getApp()?.toast?.error(e.message||"Failed to cancel job")}return!0}async onActionRetryJob(){const e=await a.Modal.form({title:"Retry Job",formConfig:c.JobForms?.retry});if(!e)return!0;try{const t=await this.model.retry(e.delay||0);t.success?(this.getApp()?.toast?.success("Retry scheduled"),this.emit("job-retried",{job:this.model,newJobId:t.newJobId})):this.getApp()?.toast?.error(t.data?.error||"Failed to retry job")}catch(t){console.error("[JobDetailsView] retry failed:",t),this.getApp()?.toast?.error(t.message||"Failed to retry job")}return!0}}JobDetailsView.VIEW_CLASS=JobDetailsView,c.Job.VIEW_CLASS=JobDetailsView,c.Job.MODEL_REF="jobs.Job";const We={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}} &middot; {{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 i=t.row;return`<span class="badge ${i.getStatusBadgeClass?i.getStatusBadgeClass():"bg-secondary"}"><i class="${i.getStatusIcon?i.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}} &middot; {{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}} &middot; {{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 i=t.row;return`<span class="badge ${i.getStatusBadgeClass?i.getStatusBadgeClass():"bg-secondary"}"><i class="${i.getStatusIcon?i.getStatusIcon():"bi-question"} me-1"></i>${e?.toUpperCase()||"UNKNOWN"}</span>`}},{key:"run_at",label:"Scheduled",formatter:e=>{if(!e)return'<span class="text-muted small">—</span>';const t=new Date("number"==typeof e&&e<1e11?1e3*e:e);if(isNaN(t.getTime()))return'<span class="text-muted small">—</span>';const i=t.getTime()>Date.now();return`<span class="${i?"text-warning":"text-muted"} small"><i class="${i?"bi-clock-fill":"bi-clock-history"} me-1"></i>${t.toLocaleString()}</span>`}},{key:"created",label:"Created",formatter:"datetime"},{key:"finished_at",label:"Finished",formatter:"datetime"},{key:"duration_ms",label:"Duration",formatter:"duration"}]},Ke=[{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"}],Ye=[{icon:"bi-x-circle-fill",label:"Cancel Jobs",action:"cancel-jobs"}];class JobTableSection extends t.View{constructor(e={}){const{status:t,sort:i="-created",extraParams:s={},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=i,this.extraParams=s,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?We[this.columnConfig]:this.columnConfig)||We[this.status]||We.all,i=this.selectable,s=this.batchActionConfig||(i?Ye:void 0),a=!this.status,n={containerId:"job-table",Collection:c.JobList,collectionParams:e,columns:t,searchable:!0,filterable:a,paginated:!0,itemView:JobDetailsView,hideActivePills:this.status?["status"]:[],viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},tableOptions:{striped:!1,hover:!0,size:"sm"}};i&&(n.selectable=!0,n.batchBarLocation="top",n.batchActions=s),a&&(n.filters=Ke,n.tableOptions.striped=!0,n.tableOptions.responsive=!0),this.tableView=new l.TableView(n),i&&this.tableView.on("action:batch-cancel-jobs",async(e,t,i)=>{const s=this.tableView.getSelectedItems();await Promise.all(s.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",selectable:!0}),this.addChild(this.jobTableSection)}}n.GeoLocatedIP.VIEW_CLASS=GeoIPView;class BlockedIPsTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_blocked_ips",pageName:"Blocked IPs",router:"admin/security/blocked-ips",Collection:n.GeoLocatedIPList,dayRangeFilter:{field:"blocked_at",value:"7d"},searchPlaceholder:"Search IP, country, or rule",viewDialogOptions:{header:!1,size:"xl",noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-modified",is_blocked:"true"},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}})}onActionBatchUnblock(){return this.batchAction({field:"unblock",value:"Bulk unblock from admin",label:"Unblock"})}onActionBatchWhitelist(){return this.batchAction({field:"whitelist",value:"Bulk whitelist from admin",label:"Whitelist"})}}class LogView extends t.View{constructor(e={}){super({className:"log-view",...e}),this.model=e.model||new l.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 d.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 i=this.model.get("log");let s=i;try{const e=JSON.parse(i);s=JSON.stringify(e,null,2)}catch(n){}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>${s}</code></pre>\n </div>\n `,onActionCopyLog:()=>{navigator.clipboard.writeText(s),this.getApp()?.toast?.success("Log content copied to clipboard.")}}),this.tabView=new o.TabView({containerId:"log-tabs",tabs:{Log:this.logContentView,Details:this.overviewView},activeTab:"Log"}),this.addChild(this.tabView);const a=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(a)}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 a.Modal.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})}}l.Log.VIEW_CLASS=LogView,l.Log.MODEL_REF="logs.Log",l.Log.VIEW_CLASS=LogView;class FirewallLogTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_firewall_log",pageName:"Firewall Log",router:"admin/security/firewall-log",Collection:l.LogList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search IP, action, or rule",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 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 i=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 '}),s=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 '}),a=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 o.TabView({tabs:{Overview:i,"Raw Signals":s,"Server Signals":a},activeTab:"Overview",containerId:"signal-tabs"}),this.addChild(this.tabView);const n=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(n)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}c.BouncerSignal.VIEW_CLASS=BouncerSignalView;class BouncerSignalTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_signals",pageName:"Bouncer Signals",router:"admin/security/bouncer-signals",Collection:c.BouncerSignalList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search IP, country, or rule",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,visibility:"xl",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",visibility:"lg",filter:{type:"text"}},{key:"stage",label:"Stage",visibility:"lg",filter:{type:"select",options:["assess","submit","event"]}},{key:"muid",label:"Device",formatter:"truncate_middle(12)",visibility:"xl"}],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 &middot; {{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 i=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 '}),s=new l.TableView({Collection:c.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"}}),a=new l.TableView({Collection:c.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 o.TabView({tabs:{Overview:i,Signals:s,Incidents:a},activeTab:"Overview",containerId:"device-tabs"}),this.addChild(this.tabView);const n=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(n)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}c.BouncerDevice.VIEW_CLASS=BouncerDeviceView;class BouncerDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_devices",pageName:"Bouncer Devices",router:"admin/security/bouncer-devices",Collection:c.BouncerDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search MUID or IP",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,visibility:"lg",align:"right",footer_total:!0},{key:"block_count",label:"Blocks",sortable:!0,visibility:"lg",align:"right",footer_total:!0},{key:"last_seen_ip",label:"Last IP",visibility:"lg",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 n.TablePage{constructor(e={}){super({...e,name:"admin_bot_signatures",pageName:"Bot Signatures",router:"admin/security/bot-signatures",Collection:c.BouncerSignatureList,viewDialogOptions:{size:"lg"},defaultQuery:{sort:"-modified"},dayRangeFilter:!0,searchPlaceholder:"Search signature value or notes",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:"boolean",trueLabel:"Active",falseLabel:"Inactive"}},{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}})}onActionBatchEnable(){return this.batchAction({field:"is_active",value:!0,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"is_active",value:!1,label:"Disable"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}class IPSetView extends t.View{constructor(e={}){super({className:"ipset-view",...e}),this.model=e.model||new c.IPSet(e.data||{});const t=this.model.get("kind")||"",i=c.IPSetKindBadgeOptions.find(e=>e.value===t);this.kindLabel=i?i.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 i=this.model.get("source")||"",s=c.IPSetSourceOptions.find(e=>e.value===i),a=s?s.label:i,n=this.model.get("last_synced"),l=this.model.get("sync_error"),r=[{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:a,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:n?new Date(n).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 d.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:r}),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 o.TabView({containerId:"ipset-tabs",tabs:{Configuration:this.configView,"CIDR Data":this.cidrView},activeTab:"Configuration"}),this.addChild(this.tabView);const h=this.model.get("is_enabled"),u=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"},h?{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(u)}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 a.Modal.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 a.Modal.modelForm({title:`Edit IP Set — ${this.model.get("name")}`,model:this.model,formConfig:c.IPSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("IP Set updated"))}async onActionDeleteIpset(){if(await a.Modal.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}`)}}}c.IPSet.VIEW_CLASS=IPSetView,c.IPSet.VIEW_CLASS=IPSetView;class IPSetTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_ipsets",pageName:"IP Sets",router:"admin/security/ipsets",Collection:c.IPSetList,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:"boolean",trueLabel:"Enabled",falseLabel:"Disabled"}},{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",sortable:!0,width:"120px",formatter:e=>{const t=c.IPSetKindBadgeOptions.find(t=>t.value===e);return`<span class="badge bg-primary bg-opacity-75">${t?t.label:e}</span>`},filter:{type:"select",options:c.IPSetKindBadgeOptions}},{key:"description",label:"Description",formatter:"truncate(40)|default('—')",visibility:"xl"},{key:"cidr_count",label:"CIDRs",width:"80px",sortable:!0,align:"right",visibility:"lg"},{key:"source",label:"Source",width:"110px",visibility:"lg",formatter:e=>{const t=c.IPSetSourceOptions.find(t=>t.value===e);return t?t.label:e||"—"}},{key:"last_synced|datetime",label:"Last Synced",width:"160px",sortable:!0,visibility:"xl"},{key:"sync_error",label:"Status",width:"80px",visibility:"xl",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,searchPlaceholder:"Search name, description, or kind",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 a.Modal.form({...c.IPSetForms.create});if(!e)return;if("country"===e.kind&&e.country_code){const t=e.country_code,i=c.CommonBlockCountries.find(e=>e.value===t);e.name=`country_${t}`,e.source="ipdeny",e.description=i?`Country block: ${i.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 c.IPSet,i=await t.save(e);i?.data?.status?(this.getApp()?.toast?.success("IP Set created"),this.tableView?.collection?.fetch()):a.Modal.showError(i?.data?.error||"Failed to create IP Set")}onActionBatchEnable(){return this.batchAction({field:"enable",value:1,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"disable",value:1,label:"Disable"})}onActionBatchSync(){return this.batchAction({field:"sync",value:1,label:"Sync"})}onActionBatchRefresh(){return this.batchAction({field:"refresh_source",value:1,label:"Refresh"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}l.Log.VIEW_CLASS=LogView;class LogTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_logs",pageName:"Manage Logs",router:"admin/logs",Collection:l.LogList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, message, or ID",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",visibility:"lg",filter:{type:"text"}},{key:"path",label:"Path",visibility:"lg",filter:{type:"text"}},{key:"username",label:"User",visibility:"lg",filter:{type:"text"}},{key:"ip",label:"IP",visibility:"xl",filter:{type:"text"}},{key:"duid",label:"Browser ID",formatter:"truncate_middle(16)",visibility:"xl",filter:{type:"text"}}],defaultQuery:{sort:"-created"},rowStripe:e=>{const t=String(e.get("level")||"").toLowerCase();return"error"===t?"danger":"warning"===t?"warning":null},searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No log entries found.",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 n.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 d.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 a.Modal.modelForm({title:`Edit Permissions for ${this.model.get("account")}`,model:this.model,formConfig:n.MetricsForms.edit});e&&(this.model.set(e.data.data),this.render())}async onActionDelete(){await a.Modal.confirm(`Are you sure you want to delete all permissions for ${this.model.get("account")}?`)&&(await this.model.destroy(),this.emit("deleted",this.model))}}n.MetricsPermission.VIEW_CLASS=MetricsPermissionsView,n.MetricsPermission.EDIT_FORM=n.MetricsForms.edit,n.MetricsPermission.VIEW_CLASS=MetricsPermissionsView;class MetricsPermissionsTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_metrics_permissions",pageName:"Metrics Permissions",router:"admin/metrics/permissions",Collection:n.MetricsPermissionList,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"}],actions:["view","edit","delete"],emptyMessage:"No metrics permissions found.",selectable:!0,searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0})}}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 Qe={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:t.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:t.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:Qe.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=Qe.create,Setting.EDIT_FORM=Qe.edit,Setting.VIEW_CLASS=SettingView;class SettingTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_settings",pageName:"Settings",router:"admin/settings",Collection:SettingList,searchPlaceholder:"Search key or group",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 n.TablePage{constructor(e={}){super({...e,name:"admin_file_managers",pageName:"Manage Storage Backends",router:"admin/file-managers",Collection:a.FileManagerList,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,visibility:"xl"},{key:"is_default",label:"Default",formatter:"boolean|badge"},{key:"is_active",label:"Active",formatter:"boolean|badge"},{key:"is_public",label:"Public",formatter:"boolean|badge",visibility:"xl"},{key:"backend_type",label:"Type",formatter:"default('Unknown')"},{key:"created",label:"Created",formatter:"epoch|datetime",visibility:"xl"}],searchPlaceholder:"Search backend name or URL",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),s=await a.Modal.modelForm({title:"Edit Owners",model:i,fields:FileManagerForms.owners.fields});if(!s)return!0;s.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),s=await i.save({check_cors:!0});return s.success&&s.data.status?await a.Modal.data({title:`Audit Report - ${i._.name}`,data:s.data,size:"lg"}):this.getApp().toast.error("Connection test failed"),!0}async onActionTestConnection(e,t){const i=this.collection.get(t.dataset.id),s=await i.save({test_connection:!0});return s.success&&s.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),s=await a.Modal.modelForm({title:"Edit Credentials",model:i,fields:FileManagerForms.credentials.fields});return!s||(s.success&&s.data.status?this.getApp().toast.success("Credentials updated successfully"):this.getApp().toast.error("Failed to update credentials"),!0)}async onActionClone(e,t){if(!(await a.Modal.confirm({title:"Clone File Manager",message:"This will create a clone with the same credentials."})))return!0;const i=this.collection.get(t.dataset.id),s=await i.save({clone:!0});return s.success&&s.data.status?(this.getApp().toast.success("Connection cloned successfully"),this.collection.fetch()):this.getApp().toast.error("Failed to clone connection"),!0}}a.File.VIEW_CLASS=n.FileView;class FileTablePage extends n.TablePage{constructor(e={}){super({name:"admin_files",pageName:"Manage Files",router:"admin/files",Collection:a.FileList,onAdd:async e=>{await this.handleFileUpload(e)},viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"filename",label:"Filename"},{key:"content_type",label:"Type",formatter:"default('Unknown')",visibility:"lg"},{key:"file_size",label:"Size",formatter:"filesize",align:"right"},{key:"group.name",label:"Group",formatter:"default('No Group')",visibility:"xl"},{key:"upload_status",label:"Status",formatter:"badge",visibility:"xl"},{key:"created",label:"Uploaded",formatter:"epoch|datetime"}],searchPlaceholder:"Search filename or content type",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 s=104857600;if(i.size>s)this.showError(`File size (${this._formatFileSize(i.size)}) exceeds maximum (${this._formatFileSize(s)})`);else try{const e=new a.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const s=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 s}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 s=e[0];s.name,s.type,s.size;try{const e=new a.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const i=e.upload({file:s,name:s.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 n.TablePage{constructor(e={}){super({...e,name:"admin_s3_buckets",pageName:"Manage S3 Buckets",router:"admin/s3-buckets",Collection:c.S3BucketList,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}})}}const Ze=t.MOJOUtils.escapeHtml;function Xe(e,t){const i=e?.get?.("short_link");if(i)return i;const s=e?.get?.("code");if(!s)return"";const a=t?.config?.shortlink_base_url||("undefined"!=typeof window?window.location.origin:"");return`${String(a).replace(/\/+$/,"")}/s/${s}`}function et(e){const t=e?.get?.("expires_at");if(!t)return null;const i=new Date(t).getTime();return Number.isFinite(i)?Math.round((i-Date.now())/864e5):null}function tt(e){if(!e)return"";const t=n.SHORTLINK_SOURCE_OPTIONS.find(t=>t.value===e);return t?t.label:String(e)}function it(e){const t=e?.models||[],i=Date.now(),s=864e5,a=i-30*s,n=i-7*s,o=/* @__PURE__ */new Date;o.setHours(0,0,0,0);const l=o.getTime();let r=0,c=0,d=0,h=0;const u=/* @__PURE__ */new Map;for(const b of t){const e=b.get?.("created");if(null==e)continue;const t="number"==typeof e?e<1e11?1e3*e:e:new Date(e).getTime();if(!Number.isFinite(t))continue;if(t<a)continue;r++,t>=n&&c++,t>=l&&d++,b.get?.("is_bot")&&h++;const i=b.get?.("country")||b.get?.("geo")?.country||b.get?.("ip_info")?.country||null;i&&u.set(i,(u.get(i)||0)+1)}let m=null,p=0;for(const[b,g]of u)g>p&&(m=b,p=g);return{count30:r,count7:c,countToday:d,topCountry:m,botPct:r>0?Math.round(h/r*1e3)/10:null}}class ShortLinkOverviewSection extends t.View{constructor(e={}){super({className:"shortlink-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="sl-kpi-30d"></div>\n <div data-container="sl-kpi-7d"></div>\n <div data-container="sl-kpi-today"></div>\n <div data-container="sl-kpi-country"></div>\n </div>\n\n <div class="detail-section-eyebrow">Slack / iMessage preview</div>\n {{#hasOg|bool}}\n <div class="sl-preview">\n <div class="sl-preview-thumb">\n {{#hasOgImage|bool}}<img src="{{ogImage}}" alt="">{{/hasOgImage|bool}}\n {{^hasOgImage|bool}}<i class="bi bi-link-45deg"></i>{{/hasOgImage|bool}}\n </div>\n <div class="sl-preview-body">\n <div class="sl-preview-domain">{{domain}}</div>\n {{#hasOgTitle|bool}}<div class="sl-preview-title">{{ogTitle}}</div>{{/hasOgTitle|bool}}\n {{#hasOgDescription|bool}}<div class="sl-preview-desc">{{ogDescription}}</div>{{/hasOgDescription|bool}}\n </div>\n </div>\n <div class="text-secondary small mt-2">\n <i class="bi bi-info-circle me-1"></i>\n Pulled from <code>og:title</code>, <code>og:description</code>, <code>og:image</code> on the destination page.\n </div>\n {{/hasOg|bool}}\n {{^hasOg|bool}}\n <div class="alert alert-info mb-0 small d-flex align-items-start gap-2">\n <i class="bi bi-info-circle flex-shrink-0 mt-1"></i>\n <div>\n No OG metadata set on this link. The server auto-scrapes the destination URL in the background &mdash;\n custom values entered in <strong>OG / Social</strong> override scraped values.\n </div>\n </div>\n {{/hasOg|bool}}\n ',...e}),this.clicksCollection=e.clicksCollection||null}get _flat(){return n.flattenShortLinkMetadata(this.model.get("metadata"))}get ogTitle(){return this._flat.og_title||""}get ogDescription(){return this._flat.og_description||""}get ogImage(){return this._flat.og_image||""}get hasOgTitle(){return!!this.ogTitle}get hasOgDescription(){return!!this.ogDescription}get hasOgImage(){return!!this.ogImage}get hasOg(){return this.hasOgTitle||this.hasOgDescription||this.hasOgImage}get domain(){return function(e){if(!e)return"";try{return new URL(e).hostname}catch(t){return e}}(this.model.get("url"))}async onInit(){const e=it(this.clicksCollection);this.kpi30=new n.MetricCard({containerId:"sl-kpi-30d",label:"Hits · 30d",value:e.count30,tone:e.count30>0?"success":"default"}),this.kpi7=new n.MetricCard({containerId:"sl-kpi-7d",label:"Hits · 7d",value:e.count7}),this.kpiToday=new n.MetricCard({containerId:"sl-kpi-today",label:"Today",value:e.countToday}),this.kpiCountry=new n.MetricCard({containerId:"sl-kpi-country",label:"Top country",value:e.topCountry||"—"}),[this.kpi30,this.kpi7,this.kpiToday,this.kpiCountry].forEach(e=>this.addChild(e)),this.clicksCollection&&this.clicksCollection.on("fetch:success",()=>this._refreshKpis(),this)}_refreshKpis(){const e=it(this.clicksCollection);this.kpi30?.setValue(e.count30),this.kpi7?.setValue(e.count7),this.kpiToday?.setValue(e.countToday),this.kpiCountry?.setValue(e.topCountry||"—")}async refreshFromModel(){this.isMounted?.()&&await this.render()}}class ShortLinkConfigurationSection extends t.View{constructor(e={}){super({className:"shortlink-configuration-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">\n Destination\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit shortlink">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Original URL</div>\n <div class="detail-flat-row-value detail-flat-row-value--url">\n {{#hasUrl|bool}}<a href="{{model.url}}" target="_blank" rel="noopener noreferrer" data-bs-toggle="tooltip" title="{{model.url}}">{{model.url}}</a>{{/hasUrl|bool}}\n {{^hasUrl|bool}}<span class="text-secondary fst-italic">—</span>{{/hasUrl|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Code</div>\n <div class="detail-flat-row-value">\n {{#hasCode|bool}}<code>{{model.code}}</code>{{/hasCode|bool}}\n {{^hasCode|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCode|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Source</div>\n <div class="detail-flat-row-value">\n {{#hasSource|bool}}{{sourceLabel}}{{/hasSource|bool}}\n {{^hasSource|bool}}<span class="text-secondary fst-italic">—</span>{{/hasSource|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Tracking\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit tracking">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Track clicks</div>\n <div class="detail-flat-row-value">\n {{#trackClicks|bool}}<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Enabled</span>{{/trackClicks|bool}}\n {{^trackClicks|bool}}<span class="badge text-bg-secondary">Disabled</span>{{/trackClicks|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Bot passthrough</div>\n <div class="detail-flat-row-value">\n {{#botPassthrough|bool}}<span class="badge text-bg-info"><i class="bi bi-robot me-1"></i>Bypassed</span>{{/botPassthrough|bool}}\n {{^botPassthrough|bool}}<span class="badge text-bg-secondary">Bots see preview</span>{{/botPassthrough|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Lifecycle\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit lifecycle">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Active</div>\n <div class="detail-flat-row-value">\n {{#isActive|bool}}<span class="badge text-bg-success">Active</span>{{/isActive|bool}}\n {{^isActive|bool}}<span class="badge text-bg-secondary">Disabled</span>{{/isActive|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Expires</div>\n <div class="detail-flat-row-value">\n {{#hasExpires|bool}}<code>{{model.expires_at|datetime}}</code>{{/hasExpires|bool}}\n {{^hasExpires|bool}}<span class="text-secondary">Never</span>{{/hasExpires|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Protected</div>\n <div class="detail-flat-row-value">\n {{#isProtected|bool}}<span class="badge text-bg-warning"><i class="bi bi-shield-lock me-1"></i>Protected</span>{{/isProtected|bool}}\n {{^isProtected|bool}}<span class="badge text-bg-secondary">Unprotected</span>{{/isProtected|bool}}\n </div>\n </div>\n ',...e})}get hasUrl(){return!!this.model.get("url")}get hasCode(){return!!this.model.get("code")}get hasSource(){return!!this.model.get("source")}get hasExpires(){return!!this.model.get("expires_at")}get sourceLabel(){return tt(this.model.get("source"))}get trackClicks(){return!!this.model.get("track_clicks")}get botPassthrough(){return!!this.model.get("bot_passthrough")}get isActive(){return!!this.model.get("is_active")}get isProtected(){return!!this.model.get("is_protected")}}class ShortLinkMetricsSection extends t.View{constructor(e={}){super({className:"shortlink-metrics-section",...e});const t=!!this.model.get("track_clicks"),i=this.model.get("user");this.userId=i?.id||i||null,this.code=this.model.get("code"),this.canShowMetrics=t&&this.userId&&this.code,this.canShowMetrics?this.template='\n <div class="detail-section-eyebrow">Click metrics</div>\n <div data-container="sl-metrics-chart"></div>\n ':(this.template='\n <div class="detail-section-eyebrow">Click metrics</div>\n <div class="alert alert-info mb-0 d-flex align-items-start gap-2">\n <i class="bi bi-info-circle flex-shrink-0 mt-1"></i>\n <div>{{reason}}</div>\n </div>\n ',this.reason=t?"No owning user on this shortlink — per-link metrics are recorded per user account.":'Click tracking is disabled — enable "Track clicks" on this shortlink to collect time-series data.')}async onInit(){this.canShowMetrics&&(this.metricsChart=new i.MetricsChart({containerId:"sl-metrics-chart",title:"Clicks",slugs:[`sl:click:${this.code}`],account:`user-${this.userId}`,granularity:"days",defaultDateRange:"30d",yAxis:{label:"Clicks",beginAtZero:!0},tooltip:{y:"number"},height:320}),this.addChild(this.metricsChart))}}class ShortLinkOgSection extends t.View{constructor(e={}){super({className:"shortlink-og-section",template:'\n <div class="detail-section-eyebrow">\n OG / Social\n <button class="detail-section-action" data-action="edit-og" data-bs-toggle="tooltip" title="{{#hasAny|bool}}Edit OG metadata{{/hasAny|bool}}{{^hasAny|bool}}Add OG metadata{{/hasAny|bool}}">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n {{#hasAny|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:title</div>\n <div class="detail-flat-row-value">\n {{#hasOgTitle|bool}}{{ogTitle}}{{/hasOgTitle|bool}}\n {{^hasOgTitle|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgTitle|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:description</div>\n <div class="detail-flat-row-value">\n {{#hasOgDescription|bool}}{{ogDescription}}{{/hasOgDescription|bool}}\n {{^hasOgDescription|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgDescription|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:image</div>\n <div class="detail-flat-row-value text-truncate">\n {{#hasOgImage|bool}}<a href="{{ogImage}}" target="_blank" rel="noopener noreferrer">{{ogImage}}</a>{{/hasOgImage|bool}}\n {{^hasOgImage|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgImage|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:card</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterCard|bool}}<code>{{twitterCard}}</code>{{/hasTwitterCard|bool}}\n {{^hasTwitterCard|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterCard|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:title</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterTitle|bool}}{{twitterTitle}}{{/hasTwitterTitle|bool}}\n {{^hasTwitterTitle|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterTitle|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:description</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterDescription|bool}}{{twitterDescription}}{{/hasTwitterDescription|bool}}\n {{^hasTwitterDescription|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterDescription|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:image</div>\n <div class="detail-flat-row-value text-truncate">\n {{#hasTwitterImage|bool}}<a href="{{twitterImage}}" target="_blank" rel="noopener noreferrer">{{twitterImage}}</a>{{/hasTwitterImage|bool}}\n {{^hasTwitterImage|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterImage|bool}}\n </div>\n </div>\n {{/hasAny|bool}}\n {{^hasAny|bool}}\n <p class="text-secondary small mb-0">\n No custom OG / Twitter metadata. The server scrapes the destination URL in the\n background &mdash; set values here to override what gets scraped.\n </p>\n {{/hasAny|bool}}\n ',...e})}get _flat(){return n.flattenShortLinkMetadata(this.model.get("metadata"))}get ogTitle(){return this._flat.og_title||""}get ogDescription(){return this._flat.og_description||""}get ogImage(){return this._flat.og_image||""}get twitterCard(){return this._flat.twitter_card||""}get twitterTitle(){return this._flat.twitter_title||""}get twitterDescription(){return this._flat.twitter_description||""}get twitterImage(){return this._flat.twitter_image||""}get hasOgTitle(){return!!this.ogTitle}get hasOgDescription(){return!!this.ogDescription}get hasOgImage(){return!!this.ogImage}get hasTwitterCard(){return!!this.twitterCard}get hasTwitterTitle(){return!!this.twitterTitle}get hasTwitterDescription(){return!!this.twitterDescription}get hasTwitterImage(){return!!this.twitterImage}get hasAny(){return this.hasOgTitle||this.hasOgDescription||this.hasOgImage||this.hasTwitterCard||this.hasTwitterTitle||this.hasTwitterDescription||this.hasTwitterImage}async refresh(){this.isMounted()&&await this.render()}}class ShortLinkMetadataSection extends t.View{constructor(e={}){super({className:"shortlink-metadata-section",template:'\n <div class="detail-section-eyebrow">\n Metadata\n <button class="detail-section-action" data-action="edit-metadata" data-bs-toggle="tooltip" title="Edit metadata JSON">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div data-container="sl-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"sl-metadata-card",model:this.model,data:e=>e.get("metadata")||{},knownKeys:[],rawLabel:"Raw metadata JSON",rawCollapsed:!1,emptyText:"No metadata is set on this shortlink. Use this for arbitrary configuration the framework doesn’t know about."}),this.addChild(this.knownFields)}async refresh(){this.isMounted?.()&&await this.render()}}class ShortLinkView extends n.DetailView{constructor(e={}){const i=e.model||new n.ShortLink(e.data||{}),s=Math.floor(Date.now()/1e3)-2592e3,a=new n.ShortLinkClickList({params:{shortlink:i.get("id"),created__gte:s,sort:"-created",size:200}}),o=new ShortLinkOverviewSection({model:i,clicksCollection:a}),r=new ShortLinkConfigurationSection({model:i}),c=!!i.get("track_clicks"),d=new l.TableView({collection:a,title:"Click History",eyebrow:"Section · Click History",showFullscreen:!1,searchable:!1,sortable:!0,filterable:!0,paginated:!0,hideActivePillNames:["shortlink","created__gte"],tableOptions:{hover:!0,size:"sm",emptyMessage:c?"No clicks recorded yet.":'Click tracking is disabled for this shortlink. Enable "Track clicks" in Configuration to collect per-click history.',emptyIcon:"bi-cursor",actions:[]},columns:[{key:"created",label:"Time",width:"180px",formatter:"datetime",sortable:!0},{key:"ip",label:"IP",width:"140px",template:"<code>{{model.ip}}</code>"},{key:"is_bot",label:"Bot",width:"80px",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Bots only"},{value:"false",label:"Humans only"}]}},{key:"user_agent",label:"User-Agent",formatter:"truncate(60)"},{key:"referer",label:"Referer",formatter:"truncate(40)|default('—')"}]}),h=new ShortLinkMetricsSection({model:i}),u=new ShortLinkOgSection({model:i}),m=new ShortLinkMetadataSection({model:i}),p=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:o},{key:"Configuration",label:"Configuration",icon:"bi-sliders",view:r},{type:"divider",label:"Activity"},{key:"ClickHistory",label:"Click History",icon:"bi-cursor",view:d},{key:"Metrics",label:"Metrics",icon:"bi-graph-up",view:h},{type:"divider",label:"Detail"},{key:"OgSocial",label:"OG / Social",icon:"bi-share",view:u},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:m}],b=[{icon:"bi-tag-fill",text:e=>tt(e.get("source"))||null,variant:"primary",when:e=>!!e.get("source")},{icon:"bi-cursor",text:e=>`${e.get("hit_count")||0} hits`,variant:"success",when:e=>(e.get("hit_count")||0)>0},{icon:"bi-graph-up",text:"Tracked",variant:"info",when:e=>!!e.get("track_clicks")},{icon:"bi-eye-slash",text:"Untracked",variant:"secondary",when:e=>!e.get("track_clicks")},{icon:"bi-clock",text:e=>{const t=et(e);return null==t?null:t<0?"Expired":`expires in ${t}d`},variant:"warning",when:e=>{const t=et(e);return null!=t&&t<=14}},{icon:"bi-shield-lock",text:"Protected",variant:"warning",when:e=>!!e.get("is_protected")}];super({className:"shortlink-view",...e,model:i,header:{icon:"bi-link-45deg",titleFn:t=>t.get("short_link")||Xe(t,e.app)||t.get("code")||"Short link",subtitleFn:e=>{const t=e.get("url")||"";return t?`→ ${t.length>80?`${t.slice(0,80)}…`:t}`:""},chips:b,titleAffix:()=>'<button type="button" class="dh-name-action" data-action="copy-link" data-bs-toggle="tooltip" title="Copy short URL"><i class="bi bi-clipboard"></i></button>',auxFn:e=>function(e){const i=!!e.get("is_active"),s=et(e),a=null!=s&&s<0,n=e.get("hit_count")||0,o=e.get("modified"),l=o?t.dataFormatter.pipe(o,"relative"):"",r=`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${i?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${i?a?"Expired":"Active":"Disabled"}</span>\n </label>\n `,c=[];i&&!a&&c.push(`${n.toLocaleString()} ${1===n?"hit":"hits"}`),l&&c.push(`updated ${Ze(l)}`);const d=c.join(" · ");return`\n <div class="dh-aux-top">${r}</div>\n ${d?`<span class="dh-aux-meta">${d}</span>`:""}\n `}(e),actions:[],contextMenu:{items:[{label:"Copy short URL",action:"copy-link",icon:"bi-clipboard"},{label:"Open destination",action:"open-destination",icon:"bi-box-arrow-up-right"},{type:"divider"},{label:"Edit shortlink",action:"edit-shortlink",icon:"bi-pencil"},{label:"Refresh OG metadata",action:"refresh-og",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Delete shortlink",action:"delete-shortlink",icon:"bi-trash",danger:!0}]}},sections:p,activeSection:"Overview"}),this.clicksCollection=a,this.overviewSection=o,this.configurationSection=r,this.clickHistorySection=d,this.metricsSection=h,this.ogSection=u,this.metadataSection=m}async onAfterBuild(){const e=()=>{const e=this.model.get("hit_count")||0;this.setBadge("ClickHistory",e>0?{text:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e),variant:"muted"}:null)};e(),this.clicksCollection.on("fetch:success",e,this),this.clicksCollection.fetch().catch(()=>{})}async _refreshFromModel(){this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.refreshFromModel(),this.configurationSection?.isMounted()&&await this.configurationSection.render(),this.ogSection?.isMounted()&&await this.ogSection.refresh(),this.metadataSection?.isMounted()&&await this.metadataSection.refresh()}async onActionToggleActive(e,t){const i=!!t.checked;t.disabled=!0;try{this.model.set("is_active",i);const e=await this.model.save({is_active:i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.emit("detail:updated")}catch(s){this.model.set("is_active",!i)}finally{t&&t.isConnected&&(t.disabled=!1)}return!0}async onActionCopyLink(){const e=Xe(this.model,this.getApp?.());if(e)try{await navigator.clipboard.writeText(e),this.getApp()?.toast?.success(`Copied: ${e}`)}catch(t){this.getApp()?.toast?.warning("Copy failed — select the URL manually.")}}async onActionOpenDestination(){const e=this.model.get("url");e&&/^https?:\/\//i.test(e)&&window.open(e,"_blank","noopener,noreferrer")}async onActionEditShortlink(){const e={...this.model.toJSON(),...n.flattenShortLinkMetadata(this.model.get("metadata"))},t=await a.Modal.form({...n.ShortLinkForms.edit,data:e});if(!t)return;const i=n.extractShortLinkPayload(t);try{await this.model.save(i),this.getApp()?.toast?.success("Shortlink updated"),await this._refreshFromModel(),this.emit("detail:updated")}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to update")}}async onActionEditOg(){const e=n.flattenShortLinkMetadata(this.model.get("metadata")),t=await a.Modal.form({title:"Edit OG / Social metadata",size:"md",fields:[{name:"og_title",type:"text",label:"og:title",cols:12},{name:"og_description",type:"textarea",label:"og:description",rows:3,cols:12},{name:"og_image",type:"url",label:"og:image",placeholder:"https://…",cols:12},{name:"twitter_card",type:"select",label:"twitter:card",options:n.TWITTER_CARD_OPTIONS,cols:6},{name:"twitter_title",type:"text",label:"twitter:title",cols:6},{name:"twitter_description",type:"textarea",label:"twitter:description",rows:2,cols:12},{name:"twitter_image",type:"url",label:"twitter:image",cols:12}],data:e,submitText:"Save",cancelText:"Cancel"});if(!t)return;const i=n.buildShortLinkMetadata(t);try{await this.model.save({metadata:i}),this.model.set("metadata",i),this.getApp()?.toast?.success("OG metadata saved"),await this._refreshFromModel(),this.emit("detail:updated")}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to save metadata")}}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. OG / Twitter keys are also editable from the OG / Social section.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }',tooltip:"Must be a valid JSON object"}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object (e.g. `{}`), not an array or scalar.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{await this.model.save({metadata:s}),this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._refreshFromModel(),this.emit("detail:updated")}catch(n){a.Modal.showError(n?.data?.error||n?.message||"Failed to save metadata")}}async onActionRefreshOg(){try{await this.model.fetch(),await this._refreshFromModel(),this.getApp()?.toast?.success("OG metadata refreshed")}catch(e){this.getApp()?.toast?.error(e?.message||"Failed to refresh metadata")}}async onActionDeleteShortlink(){if(await a.Modal.confirm(`Delete shortlink "${this.model.get("code")}"? This cannot be undone.`,"Delete Shortlink",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Shortlink deleted"),this.emit("shortlink:deleted",{model:this.model});const e=this.element?.closest?.(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance?.(e);t&&t.hide()}}catch(e){a.Modal.showError(e?.data?.error||e?.message||"Failed to delete")}}}ShortLinkView.VIEW_CLASS=ShortLinkView,n.ShortLink.VIEW_CLASS=ShortLinkView,n.ShortLink.MODEL_REF="shortlink.ShortLink",n.ShortLink.VIEW_CLASS=ShortLinkView;class ShortLinkTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_shortlinks",pageName:"Shortlinks",router:"admin/shortlinks/links",Collection:n.ShortLinkList,onAdd:()=>this._handleAdd(),onItemEdit:e=>this._handleEdit(e),dayRangeFilter:!0,searchPlaceholder:"Search code, URL, or source",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-created"},columns:[{key:"is_active",label:"Active",width:"70px",sortable:!0,visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Active"},{value:"false",label:"Disabled"}]}},{key:"code",label:"Code",sortable:!0,template:'\n <div class="d-flex align-items-center gap-2">\n <code>{{model.code}}</code>\n <button class="btn btn-sm btn-link p-0 text-muted"\n data-action="copy-code"\n data-code="{{model.code}}"\n title="Copy short URL">\n <i class="bi bi-clipboard"></i>\n </button>\n </div>\n '},{key:"url",label:"Destination",sortable:!0,formatter:"truncate(60)|default('—')"},{key:"source",label:"Source",width:"110px",sortable:!0,visibility:"lg",filter:{type:"select",options:n.SHORTLINK_SOURCE_OPTIONS}},{key:"hit_count",label:"Hits",width:"80px",sortable:!0,align:"right",footer_total:!0},{key:"track_clicks",label:"Tracked",width:"90px",visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Tracked"},{value:"false",label:"Not tracked"}]}},{key:"expires_at",label:"Expires",width:"160px",sortable:!0,visibility:"xl",formatter:"datetime|default('Never')"},{key:"created",label:"Created",width:"160px",sortable:!0,formatter:"datetime",visibility:"xl"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No shortlinks yet — create one to share a link with a rich preview card.",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,actions:["edit","delete"],pageSizes:[25,50,100],defaultPageSize:25,emptyIcon:"bi-link-45deg"}})}async _handleAdd(){const e=await a.Modal.form({...n.ShortLinkForms.create});if(!e)return;const t=n.extractShortLinkPayload(e);try{const e=new n.ShortLink;await e.save(t);const s=Xe(e,this.getApp());try{await(navigator.clipboard?.writeText?.(s)),this.getApp()?.toast?.success(`Shortlink created — ${s} copied to clipboard`)}catch(i){this.getApp()?.toast?.success(`Shortlink created: ${s}`)}this.tableView?.collection?.fetch()}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to create shortlink")}}async _handleEdit(e){if(!e)return;const t={...e.toJSON(),...n.flattenShortLinkMetadata(e.get("metadata"))},i=await a.Modal.form({...n.ShortLinkForms.edit,data:t});if(!i)return;const s=n.extractShortLinkPayload(i);try{await e.save(s),this.getApp()?.toast?.success("Shortlink updated"),this.tableView?.collection?.fetch()}catch(o){a.Modal.showError(o?.data?.error||o?.message||"Failed to update shortlink")}}async onActionCopyCode(e,t){e?.stopPropagation?.();const i=t?.dataset?.code;if(!i)return;const s=Xe({get:e=>"code"===e?i:null},this.getApp());try{await navigator.clipboard.writeText(s),this.getApp()?.toast?.success(`Copied: ${s}`)}catch(a){this.getApp()?.toast?.warning("Copy failed — select the URL manually.")}}async onActionBatchEnable(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await this.getApp().confirm(`Enable ${e.length} shortlink(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} shortlink(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await this.getApp().confirm(`Disable ${e.length} shortlink(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} shortlink(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await a.Modal.confirm(`Delete ${e.length} shortlink(s)? This cannot be undone.`,"Delete Shortlinks",{confirmText:"Delete",confirmClass:"btn-danger"})&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} shortlink(s) deleted`),this.tableView.collection.fetch())}}class ShortLinkClickTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_shortlink_clicks",pageName:"Shortlink Click History",router:"admin/shortlinks/clicks",Collection:n.ShortLinkClickList,dayRangeFilter:!0,...n.groupByDay("created"),defaultQuery:{sort:"-created"},columns:[{key:"created",label:"Time",width:"180px",sortable:!0,formatter:"datetime"},{key:"shortlink.code",label:"Code",width:"130px",template:"<code>{{model.shortlink.code}}</code>"},{key:"shortlink.url",label:"Destination",formatter:"truncate(50)|default('—')",visibility:"lg"},{key:"ip",label:"IP",width:"140px",template:"<code>{{model.ip}}</code>"},{key:"is_bot",label:"Bot",width:"80px",visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Bots only"},{value:"false",label:"Humans only"}]}},{key:"user_agent",label:"User-Agent",formatter:"truncate(50)",visibility:"lg"},{key:"referer",label:"Referer",formatter:"truncate(40)|default('—')",visibility:"xl"}],searchable:!1,sortable:!0,filterable:!0,paginated:!0,selectable:!1,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No click history recorded. Clicks are only captured on shortlinks created with track_clicks=true.",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 t.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">&middot;</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()||"",i=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":i.includes("iphone")?"bi-phone":i.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 i=this._geo,a=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">${i.city||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Region</div>\n <div class="udl-field-value">${i.region||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Country</div>\n <div class="udl-field-value">${i.country_name||"—"} ${i.country_code?`<span class="text-muted">(${i.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">${i.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">${i.timezone||"—"}</div>\n </div>\n ${i.latitude?`\n <div class="udl-field-row">\n <div class="udl-field-label">Coordinates</div>\n <div class="udl-field-value">${i.latitude}, ${i.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">${i.isp||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ASN</div>\n <div class="udl-field-value">${i.asn||"—"} ${i.asn_org?`<span class="text-muted small">(${i.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">${a?.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">${[a?.user_agent?.major,a?.user_agent?.minor,a?.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">${a?.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">${[a?.os?.major,a?.os?.minor,a?.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">${a?.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">${a?.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">${a?.device?.model||"—"}</div>\n </div>\n\n ${a?.string?`\n <div class="udl-section-label">User Agent String</div>\n <div class="udl-ua-string">${a.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;">${i.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!=i.risk_score?i.risk_score:"—"}</div>\n </div>\n\n <div class="udl-section-label">Detection Flags</div>\n ${this._riskRow("VPN","bi-shield",i.is_vpn)}\n ${this._riskRow("Tor Exit Node","bi-shield-lock",i.is_tor)}\n ${this._riskRow("Proxy","bi-diagram-3",i.is_proxy)}\n ${this._riskRow("Cloud Provider","bi-cloud",i.is_cloud)}\n ${this._riskRow("Datacenter","bi-hdd-stack",i.is_datacenter)}\n ${this._riskRow("Mobile","bi-phone",i.is_mobile)}\n\n <div class="udl-section-label">Reputation</div>\n ${this._riskRow("Known Attacker","bi-exclamation-triangle",i.is_known_attacker)}\n ${this._riskRow("Known Abuser","bi-flag",i.is_known_abuser)}\n ${this._riskRow("Threat","bi-shield-exclamation",i.is_threat)}\n ${this._riskRow("Suspicious","bi-question-circle",i.is_suspicious)}\n `})}];if(this.hasCoordinates)try{const e=new s.MapView({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(h){}const r=this.model.get("ip_address");if(r){const e=new l.TableView({collection:new c.IncidentEventList({params:{size:10,source_ip:r}}),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 l.TableView({collection:new l.LogList({params:{size:10,ip:r}}),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 n.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 d=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(d)}_riskRow(e,t,i){return`\n <div class="udl-field-row">\n <div class="udl-field-label"><i class="bi ${t} me-1 ${i?"udl-risk-yes":"udl-risk-no"}"></i>${e}</div>\n <div class="udl-field-value">${i?'<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 a.Modal.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 i=new t.UserDeviceLocation({id:e});return await i.fetch(),i.id?a.Modal.dialog({title:!1,size:"lg",body:new UserDeviceLocationView({model:i}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(a.Modal.alert({message:`Could not find location record: ${e}`,type:"warning"}),null)}}t.UserDeviceLocation.VIEW_CLASS=UserDeviceLocationView;const st={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"}]},at={ec2:"bi-pc-display",rds:"bi-database",redis:"bi-lightning-charge"},nt={ec2:"EC2 Instance",rds:"RDS Database",redis:"ElastiCache Redis"};function ot(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=st[this.resourceType]||[],t=at[this.resourceType]||"bi-cloud",i=nt[this.resourceType]||"Resource";this.resource.state||this.resource.status;const s=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;">${i}</span>\n </div>\n <div class="mt-1">${s}</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=st[this.resourceType]||[];for(let t=0;t<e.length;t++){const i=e[t],s=new CloudWatchChart({containerId:`cwrv-chart-${t}`,account:this.resourceType,category:i.key,slug:this.slug,title:i.label,height:200,yAxis:ot(i.unit),showGranularity:!0,showDateRange:!1,defaultDateRange:"24h",granularity:"hours"});this.addChild(s)}}_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={},s={}){const n=new CloudWatchResourceView({resourceType:e,slug:t,resource:i}),o=at[e]||"bi-cloud",l=nt[e]||"Resource";await a.Modal.dialog(n,{header:`<i class="bi ${o} me-2"></i>${t} <small class="text-muted">— ${l}</small>`,size:"xl",scrollable:!0})}}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 i=(e.queued_count||0)+(e.inflight_count||0);return i>50&&(t="warning"),(i>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(i){console.error("Failed to refresh health:",i)}finally{t.disabled=!1}}async onActionSystemSettings(){await a.Modal.alert({title:"System Settings",message:"System settings interface coming soon!",type:"info"})}}const lt={global:"bg-primary",user:"bg-info",group:"bg-warning text-dark"};class AssistantSkillView extends t.View{constructor(e={}){super({className:"assistant-skill-view",...e}),this.model=e.model||new c.AssistantSkill(e.data||{}),this.template='\n <div class="d-flex flex-column h-100">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n {{{tierBadge}}}\n {{{statusBadge}}}\n <span class="text-muted small">Skill #{{model.id}}</span>\n </div>\n <h4 class="mb-1">{{model.name}}</h4>\n <p class="text-muted mb-2">{{model.description}}</p>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n {{#model.auto_execute|bool}}\n <span><i class="bi bi-lightning-fill text-warning me-1"></i>Auto-execute enabled</span>\n {{/model.auto_execute|bool}}\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-danger btn-sm" data-action="delete-skill">\n <i class="bi bi-trash me-1"></i>Delete\n </button>\n </div>\n </div>\n\n \x3c!-- Auto-Execute Info --\x3e\n {{#model.auto_execute|bool}}\n <div class="alert alert-warning small py-2 mb-3">\n <i class="bi bi-lightning-fill me-1"></i>\n <strong>Auto-execute:</strong> The assistant will run this skill without asking for confirmation when triggered.\n </div>\n {{/model.auto_execute|bool}}\n\n \x3c!-- Triggers --\x3e\n {{#hasTriggers|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-chat-quote me-1"></i>Trigger Phrases</h6>\n <div class="d-flex flex-wrap gap-2">\n {{#triggers}}\n <span class="badge bg-light text-dark border px-2 py-1">{{.}}</span>\n {{/triggers}}\n </div>\n </div>\n {{/hasTriggers|bool}}\n\n \x3c!-- Steps --\x3e\n {{#hasSteps|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-list-ol me-1"></i>Steps ({{stepCount}})</h6>\n <div class="skill-steps-list">\n {{{stepsHtml}}}\n </div>\n </div>\n {{/hasSteps|bool}}\n\n \x3c!-- Metadata --\x3e\n {{#hasMetadata|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-braces me-1"></i>Metadata</h6>\n <pre class="bg-light p-3 rounded small mb-0"><code>{{metadataJson}}</code></pre>\n </div>\n {{/hasMetadata|bool}}\n </div>\n '}async onInit(){if(!this.model.get("steps"))try{await this.model.fetch({params:{graph:"detail"}})}catch(e){}}async onBeforeRender(){const e=this.model.get("tier")||"user",t=lt[e]||"bg-secondary";this.tierBadge=`<span class="badge ${t}">${e}</span>`;const i=this.model.get("is_active");this.statusBadge=i?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>',this.triggers=this.model.get("triggers")||[],this.hasTriggers=this.triggers.length>0;const s=this.model.get("steps")||[];this.hasSteps=s.length>0,this.stepCount=s.length,this.stepsHtml=this._buildStepsHtml(s);const a=this.model.get("metadata");this.hasMetadata=a&&Object.keys(a).length>0,this.metadataJson=this.hasMetadata?JSON.stringify(a,null,2):""}_buildStepsHtml(e){return e.map((t,i)=>{const s=this._escapeHtml,a=t.params?JSON.stringify(t.params,null,2):null,n=`step-params-${this.model.get("id")}-${i}`;let o=`\n <div class="skill-step-item d-flex gap-3 py-2 ${i<e.length-1?"border-bottom":""}">\n <div class="skill-step-number flex-shrink-0">\n <span class="badge bg-dark rounded-pill">${i+1}</span>\n </div>\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n <code class="small">${s(t.tool||"unknown")}</code>\n ${t.description?`<span class="text-muted small">— ${s(t.description)}</span>`:""}\n </div>`;return t.condition&&(o+=`\n <div class="small text-warning">\n <i class="bi bi-funnel me-1"></i>Condition: <code>${s(t.condition)}</code>\n </div>`),a&&(o+=`\n <div class="mt-1">\n <a class="small text-muted" data-bs-toggle="collapse" href="#${n}" role="button" aria-expanded="false">\n <i class="bi bi-chevron-right me-1"></i>Parameters\n </a>\n <div class="collapse" id="${n}">\n <pre class="bg-light p-2 rounded small mt-1 mb-0"><code>${s(a)}</code></pre>\n </div>\n </div>`),o+="\n </div>\n </div>",o}).join("")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionDeleteSkill(){if(await a.Modal.confirm(`Delete skill "${this.model.get("name")}"? This cannot be undone.`,"Delete Skill",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Skill deleted"),this.emit("item:deleted",{id:this.model.get("id")})}catch(e){this.getApp()?.toast?.error("Failed to delete skill")}}}c.AssistantSkill.VIEW_CLASS=AssistantSkillView;const rt={global:"bg-primary",user:"bg-info",group:"bg-warning text-dark"};class AssistantSkillTablePage extends n.TablePage{constructor(e={}){super({name:"assistant_skills",pageName:"Assistant Skills",router:"admin/assistant/skills",Collection:c.AssistantSkillList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-modified"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"description",label:"Description",sortable:!1,formatter:"truncate:60"},{key:"tier",label:"Tier",sortable:!0,width:"100px",formatter:e=>`<span class="badge ${rt[e]||"bg-secondary"}">${e||"unknown"}</span>`,filter:{type:"multiselect",placeHolder:"Select Tier",options:["global","user","group"]}},{key:"steps",label:"Steps",width:"80px",sortable:!1,formatter:e=>`<span class="badge bg-secondary">${Array.isArray(e)?e.length:0}</span>`},{key:"auto_execute",label:"Auto",width:"80px",sortable:!0,formatter:e=>e?'<i class="bi bi-check-circle-fill text-success"></i>':'<i class="bi bi-circle text-muted"></i>'},{key:"is_active",label:"Active",width:"80px",sortable:!0,formatter:e=>e?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],searchable:!0,sortable:!0,filterable:!0,paginated:!1,showRefresh:!0,showAdd:!1,showExport:!1,emptyMessage:"No skills found. Skills are created through the assistant chat.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}}class AssistantConversationView extends t.View{constructor(e={}){super({className:"assistant-conversation-view",...e}),this.model=e.model||new c.AssistantConversation(e.data||{}),this.template='\n <div class="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 <h4 class="mb-1 text-truncate">{{model.title}}</h4>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-hash me-1"></i>Conversation #{{model.id}}</span>\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>Last active {{model.modified|relative}}</span>\n {{/model.modified}}\n {{#messageCount}}\n <span><i class="bi bi-chat-left-text me-1"></i>{{messageCount}} messages</span>\n {{/messageCount}}\n </div>\n </div>\n <div class="d-flex align-items-center gap-2 flex-shrink-0">\n <button class="btn btn-outline-danger btn-sm" data-action="delete-conversation">\n <i class="bi bi-trash me-1"></i>Delete\n </button>\n </div>\n </div>\n\n \x3c!-- Messages --\x3e\n <div class="flex-grow-1 border rounded" style="min-height: 200px;" data-container="chat-view"></div>\n </div>\n '}async onInit(){try{await this.model.fetch({params:{graph:"detail"}})}catch(a){}const e=this.model.get("messages")||[];this.messageCount=e.length;const t=this.model.get("user"),i=t?.id,s=AssistantView._collapseMessages(e.filter(e=>"tool_result"!==e.role).map(e=>this._transformMessage(e,t)));this.chatView=new n.ChatView({containerId:"chat-view",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:i,showInput:!1,showFileInput:!1,adapter:{fetch:async()=>s,addNote:async()=>({success:!1})}}),this.addChild(this.chatView)}_transformMessage(e,t){let i,s=e.content||"",a=e.blocks||[],n=e.tool_calls||[];if(n.length>0){const e=n.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!s&&e.length>0&&(s=e.join("\n\n")),n=n.filter(e=>"tool_use"===e.type)}if(0===a.length&&s.includes("assistant_block")){const e=AssistantView._parseBlocks(s);s=e.content,a=e.blocks}if("assistant"===e.role)i={name:"Mojo"};else if(e.author)i=e.author;else{const s=e.user||t,a=s?.avatar?.thumbnail||s?.avatar?.url||"";i={name:s?.display_name||"Unknown",id:s?.id,...a?{avatarUrl:a}:{}}}return{id:e.id,role:e.role||"user",author:i,content:s,timestamp:e.created||e.timestamp,blocks:a,tool_calls:n,_conversationId:this.model.get("id")}}async onActionDeleteConversation(){if(await a.Modal.confirm("Delete this conversation? This cannot be undone.","Delete Conversation",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Conversation deleted"),this.emit("item:deleted",{id:this.model.get("id")})}catch(e){this.getApp()?.toast?.error("Failed to delete conversation")}}}c.AssistantConversation.VIEW_CLASS=AssistantConversationView;class AssistantConversationTablePage extends n.TablePage{constructor(e={}){super({name:"assistant_conversations",pageName:"Conversation History",router:"admin/assistant/conversations",Collection:c.AssistantConversationList,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-modified"},dayRangeFilter:{field:"modified",value:"7d"},searchPlaceholder:"Search title or user",columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"user.username",label:"User",sortable:!0},{key:"title",label:"Title",sortable:!0,formatter:"truncate:80"},{key:"created",label:"Created",sortable:!0,formatter:"datetime"},{key:"modified",label:"Last Active",sortable:!0,formatter:"relative"}],selectable:!0,searchable:!0,sortable:!0,filterable:!1,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!1,batchActions:[{label:"Delete",action:"delete",icon:"bi bi-trash",class:"text-danger"}],emptyMessage:"No conversations yet. Start a conversation with the assistant.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}const ct={global:{label:"Global",icon:"bi-globe",badge:"bg-primary",description:"Visible to all Mojo users"},user:{label:"Personal",icon:"bi-person",badge:"bg-info",description:"Private to you"},group:{label:"Group",icon:"bi-people",badge:"bg-warning text-dark",description:"Shared with your group"}};class AssistantMemoryPage extends e.Page{constructor(e={}){super({pageName:"Mojo Memory",className:"mojo-page assistant-memory-page",...e}),this._memories={},this._activeTier="user",this._loading=!0,this.template='\n <div class="container-fluid py-3">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h4 class="mb-0"><i class="bi bi-lightbulb me-2"></i>Mojo Memory</h4>\n <p class="text-muted small mb-0">Facts and preferences Mojo has learned during conversations.</p>\n </div>\n <button class="btn btn-outline-secondary btn-sm" data-action="refresh">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n \x3c!-- Tier Tabs --\x3e\n <ul class="nav nav-tabs mb-3">\n {{#tierTabs}}\n <li class="nav-item">\n <a class="nav-link {{#active}}active{{/active}}" href="#"\n data-action="switch-tier" data-tier="{{key}}">\n <i class="bi {{icon}} me-1"></i>{{label}}\n {{#count}}<span class="badge bg-secondary ms-1">{{count}}</span>{{/count}}\n </a>\n </li>\n {{/tierTabs}}\n </ul>\n\n \x3c!-- Content --\x3e\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border spinner-border-sm text-muted" role="status"></div>\n <div class="text-muted small mt-2">Loading memories...</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n {{{tierContent}}}\n {{/loading|bool}}\n </div>\n '}async onEnter(){await this._loadMemories()}async onBeforeRender(){this.loading=this._loading,this.tierTabs=["user","global","group"].filter(e=>"group"!==e||this._memories[e]).map(e=>{const t=ct[e],i=this._memories[e]||{};return{key:e,label:t.label,icon:t.icon,count:Object.keys(i).length||0,active:e===this._activeTier}}),this.tierContent=this._buildTierContent(this._activeTier)}async _loadMemories(){this._loading=!0,this.render();try{const e=await t.rest.get("/api/assistant/memory"),i=e?.data?.data||e?.data||{};this._memories={global:i.global||{},user:i.user||{},group:i.group||{}}}catch(e){this._memories={global:{},user:{},group:{}},this.getApp()?.toast?.error("Failed to load memories")}this._loading=!1,this.render()}_buildTierContent(e){const t=this._memories[e]||{},i=Object.keys(t),s=ct[e];if(0===i.length)return`\n <div class="text-center py-5">\n <i class="bi ${s.icon} fs-1 text-muted"></i>\n <p class="text-muted mt-2 mb-0">No memories stored.</p>\n <p class="text-muted small">The assistant learns and stores memories during conversations.</p>\n </div>\n `;const a=i.map(i=>{const s=this._escapeHtml(i);return`\n <tr>\n <td class="align-middle" style="width: 30%; min-width: 150px;">\n <code class="small">${s}</code>\n </td>\n <td class="align-middle">${this._escapeHtml(String(t[i]))}</td>\n <td class="align-middle text-end" style="width: 60px;">\n <button class="btn btn-outline-danger btn-sm"\n data-action="delete-memory"\n data-tier="${e}"\n data-key="${s}"\n title="Delete this memory">\n <i class="bi bi-trash"></i>\n </button>\n </td>\n </tr>\n `}).join("");return`\n <div class="small text-muted mb-2">\n <i class="bi bi-info-circle me-1"></i>${s.description}\n </div>\n <div class="table-responsive">\n <table class="table table-striped table-hover mb-0">\n <thead>\n <tr>\n <th>Key</th>\n <th>Value</th>\n <th></th>\n </tr>\n </thead>\n <tbody>${a}</tbody>\n </table>\n </div>\n `}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}onActionSwitchTier(e,t){const i=t.dataset.tier||t.closest("[data-tier]")?.dataset.tier;i&&i!==this._activeTier&&(e.preventDefault(),this._activeTier=i,this.render())}async onActionRefresh(){await this._loadMemories()}async onActionDeleteMemory(e,i){const s=i.dataset.tier||i.closest("[data-tier]")?.dataset.tier,n=i.dataset.key||i.closest("[data-key]")?.dataset.key;if(s&&n&&await a.Modal.confirm(`Delete memory "${n}" from ${ct[s]?.label||s}?`,"Delete Memory",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await t.rest.delete("/api/assistant/memory",{tier:s,key:n}),delete this._memories[s]?.[n],this.getApp()?.toast?.success("Memory deleted"),this.render()}catch(o){this.getApp()?.toast?.error("Failed to delete memory")}}}c.ScheduledTask.ADD_FORM=c.ScheduledTaskForms.create,c.ScheduledTask.EDIT_FORM=c.ScheduledTaskForms.edit;class AssistantPanelView extends t.View{constructor(e={}){super({className:"assistant-panel-view",...e}),this.app=e.app,this.ws=this.app?.ws,this.conversationId=e.conversationId||this.app?._assistantConversationId||null,this._wsHandlers={},this._messageIdCounter=0,this._hasMessages=!1,this._activePlans={},this._requestStartTime=null,this._showingHistory=!1}getTemplate(){return`\n <div class="assistant-panel-resize-handle" data-ref="resize-handle"></div>\n <div class="assistant-panel-layout">\n <div class="assistant-panel-header">\n <button class="assistant-panel-header-btn" data-action="toggle-history" type="button" title="Conversation history">\n <i class="bi bi-list"></i>\n </button>\n <span class="assistant-panel-title text-truncate" data-ref="panel-title">New conversation</span>\n <div class="d-flex gap-1 ms-auto">\n <button class="assistant-panel-header-btn" data-action="new-conversation" type="button" title="New conversation">\n <i class="bi bi-plus-lg"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="fullscreen" type="button" title="Open fullscreen">\n <i class="bi bi-arrows-fullscreen"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="pop-out" type="button" title="Open in popup window">\n <i class="bi bi-box-arrow-up-right"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="close-panel" type="button" title="Close">\n <i class="bi bi-x-lg"></i>\n </button>\n </div>\n </div>\n\n <div class="assistant-panel-history d-none" data-ref="history" data-container="conversation-list"></div>\n\n <div class="assistant-panel-chat" data-ref="chat-wrapper">\n <div class="assistant-welcome" data-ref="welcome">\n <div class="assistant-welcome-content">\n <div class="assistant-welcome-icon">\n <img src="https://mojo-verify.s3.amazonaws.com/signatures/14e7aab75c2749cb846f7d57298691ac/mojo_ai_7c0322e9.png" alt="Mojo">\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 </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-status d-none" data-ref="input-status"></div>\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message Mojo..." 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">Enter to send</span>\n </div>\n </div>\n </div>\n </div>\n `}async onInit(){this.conversations=new c.AssistantConversationList,this.conversations.params.user=this.app?.activeUser?.id,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},this.conversationListView.on("conversation:select",e=>{this._onConversationSelect(e),this._toggleHistory(!1)}),this.conversationListView.on("conversation:new",()=>{this._onNewConversation(),this._toggleHistory(!1)}),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.conversationId&&(this._showChatArea(),await this.chatView.refresh()),this._updateConnectionStatus(),this._updateTitle(),this._setupResizeHandle()}_setupResizeHandle(){const e=this.element?.querySelector('[data-ref="resize-handle"]');if(!e)return;const t="mojo:assistant_panel_width",i=localStorage.getItem(t);if(i){const e=parseInt(i,10);if(e>=300&&e<=700){const t=document.getElementById("assistant-panel");t&&(t.style.width=e+"px")}}let s,a;const n=e=>{const t=s-e.clientX,i=Math.min(700,Math.max(300,a+t)),n=document.getElementById("assistant-panel");n&&(n.style.width=i+"px")},o=()=>{document.removeEventListener("mousemove",n),document.removeEventListener("mouseup",o),document.body.style.cursor="",document.body.style.userSelect="";const e=document.getElementById("assistant-panel");e&&localStorage.setItem(t,parseInt(e.style.width,10))};e.addEventListener("mousedown",e=>{e.preventDefault(),s=e.clientX;const t=document.getElementById("assistant-panel");a=t?t.offsetWidth:500,document.body.style.cursor="col-resize",document.body.style.userSelect="none",document.addEventListener("mousemove",n),document.addEventListener("mouseup",o)})}onActionToggleHistory(){this._toggleHistory(!this._showingHistory)}onActionNewConversation(){this._onNewConversation(),this._showingHistory&&this._toggleHistory(!1)}onActionClosePanel(){this.emit("panel:close")}onActionFullscreen(){this.emit("panel:fullscreen",{conversationId:this.conversationId})}onActionPopOut(){this.emit("panel:popout",{conversationId:this.conversationId})}onActionUseSuggestion(e,t){const i=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!i)return;const s=this.element.querySelector('[data-ref="input"]');s&&(s.value=i,this._autoResize(s)),this._sendMessage()}onActionSend(){this._sendMessage()}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Response cancelled.");const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_toggleHistory(e){this._showingHistory=e;const t=this.element?.querySelector('[data-ref="history"]'),i=this.element?.querySelector('[data-ref="chat-wrapper"]'),s=this.element?.querySelector('[data-action="toggle-history"] i');t&&t.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),s&&(s.className=e?"bi bi-chat-dots":"bi bi-list"),e&&this.conversationListView.refresh()}_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())}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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e),this._setInputStatus(e?null:t),this._responseTimeout&&clearTimeout(this._responseTimeout),e?this._requestStartTime=null:this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4)}_setInputStatus(e){const t=this.element?.querySelector('[data-ref="input-status"]');t&&(e?(t.innerHTML=`${this._escapeHtml(e)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,t.classList.remove("d-none"),t._hasDismiss||(t._hasDismiss=!0,t.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))):(t.classList.add("d-none"),t.innerHTML=""))}_onResponseTimeout(){this._responseTimeout=null,this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Request timed out. Please try again.")}_updateTitle(e){const t=this.element?.querySelector('[data-ref="panel-title"]');t&&(t.textContent=e||(this.conversationId?"Mojo":"New conversation"))}_createAdapter(){return{fetch:async()=>{if(!this.conversationId)return[];try{const e=new c.AssistantConversation({id:this.conversationId});await e.fetch({graph:"detail"});const t=e.get("title")||e.get("summary");t&&this._updateTitle(t);const i=(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean);return AssistantView._collapseMessages(i)}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.chatView.showThinking("Thinking..."),this._requestStartTime=Date.now(),this._setInputEnabled(!1,"Waiting for response…"),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}),i=t?.data?.data||t?.data||t;i.conversation_id&&(this.conversationId=i.conversation_id,this.app._assistantConversationId=this.conversationId),i.response&&this.chatView.addMessage(this._transformMessage(i.response)),this._setInputEnabled(!0)}catch(i){this._handleAPIError(i)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),text:e=>this._onText(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_text",this._wsHandlers.text),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_text",this._wsHandlers.text),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_text":this._onText(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.app._assistantConversationId=this.conversationId)}_onThinking(e){this._isMyConversation(e)&&(this._adoptConversationId(e),this._showChatArea(),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){if(this._responseTimeout){if(this._requestStartTime&&Date.now()-this._requestStartTime>=3e5)return void this._onResponseTimeout();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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}async _onConversationSelect(e){this.conversationId=e.id,this.app._assistantConversationId=this.conversationId,this.conversationListView.setActive(e.id),this._showChatArea(),this._updateTitle(e.model?.get("title")||e.model?.get("summary")),await this.chatView.refresh()}_onNewConversation(){this.conversationId=null,this.app._assistantConversationId=null,this.conversationListView.setActive(null),this.chatView.clearMessages(),this._setInputEnabled(!0),this._showWelcome(),this._updateTitle();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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,i=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Mojo"}: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:i,tool_calls:s,_conversationId:this.conversationId}}_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");e&&(this.ws?.isConnected?(e.className="status-dot connected",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0)):this.ws?.isReconnecting?(e.className="status-dot reconnecting",e.title="Reconnecting...",this._setInputEnabled(!1,"Reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)):(e.className="status-dot disconnected",e.title="Disconnected",this._setInputEnabled(!1,"Disconnected — reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)))}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}focusInput(){const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}class TicketNoteAdapter{constructor(e){this.ticketId=e,this.collection=new c.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&&"system_event"!==e.type&&(e._rawContent=e.content,e.content=await this._renderMarkdown(e.content))})),e}transform(e){const t=e.get("metadata")||{},i=t.action&&"object"==typeof t.action,s="context"===t.type&&t.references,a=!e.get("user")&&(i||s),n={id:e.get("id"),type:e.get("user")?"user_comment":a?"llm_response":"system_event",role:a?"assistant":void 0,author:{id:e.get("user.id"),name:e.get("user.display_name")||(a?"AI Agent":"System"),avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]};return"status_change"===t.type&&(t.old_status||t.new_status)?(n.type="system_event",n.content=this._renderStatusChange(t.old_status,t.new_status)):t.action&&"object"==typeof t.action?n.action=t.action:"context"===t.type&&t.references&&(n.action={type:"context",references:t.references}),t.action_response&&(n.actionResponse=t.action_response),n._metadata=t,n}_renderStatusChange(e,t){const i=e=>e?`<span class="badge ${{new:"bg-info",open:"bg-success",in_progress:"bg-warning text-dark",pending:"bg-warning text-dark",resolved:"bg-success",qa:"bg-success",closed:"bg-secondary",ignored:"bg-secondary"}[e]||"bg-secondary"}">${String(e).replace(/[<>&"]/g,e=>({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"}[e])).replace(/_/g," ")}</span>`:'<span class="badge bg-secondary">unknown</span>';return`Status changed from ${i(e)} to ${i(t)}`}async addNote(e){const t=new c.TicketNote,i={parent:this.ticketId,note:e.text,media:e.files&&e.files.length>0?e.files[0].id:null};e.metadata&&(i.metadata=e.metadata);const s=await t.save(i);return s.success&&await this.collection.fetch(),s}async addActionResponse(e,t){return this.addNote({text:"approve"===t?"Approved":"Denied",metadata:{action_response:{handler:e.action.handler,action:t,context:e.action.context}}})}async _renderMarkdown(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}}function dt(e){return e?String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}const ht=/* @__PURE__ */new Set(["incident.RuleSet","incident.Incident","incident.Event","incident.Ticket","account.GeoLocatedIP"]),ut={"incident.rule_approval":{dot:"accent",label:"Rule"},"incident.block_confirm":{dot:"red",label:"Block"},"incident.rule_update":{dot:"green",label:"Update"},"incident.escalate":{dot:"amber",label:"Escalate"}};class ActionCardView extends t.View{constructor(e={}){super({className:"action-card-view",...e}),this.action=e.action,this.noteId=e.noteId,this.ticketStatus=e.ticketStatus}get isResolved(){return!!this.action?.resolved}get isContext(){return"context"===this.action?.type}get isClosed(){return"closed"===this.ticketStatus||"resolved"===this.ticketStatus}get handlerConfig(){return ut[this.action?.handler]||{dot:"accent",label:"Action"}}onBeforeRender(){const e=this.handlerConfig;this.dotClass=e.dot,this.isContext?this._buildContextTemplate():this.isResolved?this._buildResolvedTemplate():this._buildPendingTemplate()}onActionToggleCompact(){const e=this.element?.querySelector(".ac.resolved");e&&e.classList.toggle("compact")}_buildContextTemplate(){const e=(this.action.references||[]).filter(e=>ht.has(e.model)).map(e=>{const t=dt(e.label)||`${dt(e.model.split(".").pop())} #${dt(e.pk)}`;return`<span class="ac-ref" data-action="open-ref" data-model="${dt(e.model)}" data-pk="${dt(e.pk)}"><i class="bi bi-box-arrow-up-right"></i>${t}</span>`}).join("");this.template=`\n <div class="ac ac-context">\n <div class="ac-top">\n <span class="ac-dot context"></span>\n <span class="ac-label">Referenced models</span>\n </div>\n <div class="ac-detail">${e}</div>\n </div>\n `}_buildResolvedTemplate(){const e=this.action.resolution||"approved",t="approved"===e?"approved":"denied",i="approved"===e?"Approved":"Denied",s=this.action.context?.target;let a="";if(s&&ht.has(s.model)){const e=dt(this.action.context.label)||`${dt(s.model.split(".").pop())} #${dt(s.pk)}`;a=`<div class="ac-detail"><span class="ac-ref" data-action="open-ref" data-model="${dt(s.model)}" data-pk="${dt(s.pk)}"><i class="bi bi-box-arrow-up-right"></i>${e}</span></div>`}this.template=`\n <div class="ac resolved compact">\n <div class="ac-top" data-action="toggle-compact" title="Click to toggle">\n <span class="ac-dot ${this.dotClass}"></span>\n <span class="ac-label">${dt(this.action.label)||"Action"}</span>\n <span class="ac-badge ${t}">${i}</span>\n <i class="bi bi-chevron-down ac-chevron"></i>\n </div>\n ${a}\n </div>\n `}_buildPendingTemplate(){const e=this.action.context?.target;let t="";if(e&&ht.has(e.model)){const i=dt(this.action.context.label)||`${dt(e.model.split(".").pop())} #${dt(e.pk)}`;t=`<br><span class="ac-ref" data-action="open-ref" data-model="${dt(e.model)}" data-pk="${dt(e.pk)}"><i class="bi bi-box-arrow-up-right"></i>${i}</span>`}const i=dt(this.action.context?.detail),s=this.isClosed?" disabled":"";this.template=`\n <div class="ac">\n <div class="ac-top">\n <span class="ac-dot ${this.dotClass}"></span>\n <span class="ac-label">${dt(this.action.label)||"Action"}</span>\n </div>\n <div class="ac-detail">${i}${t}</div>\n <div class="ac-foot">\n <button class="btn-approve" data-action="approve"${s}>Approve</button>\n <button class="btn-deny" data-action="deny"${s}>Deny</button>\n </div>\n </div>\n `}async onActionOpenRef(e,t){const i=t.dataset.model,s=t.dataset.pk;if(!ht.has(i)||!/^\d+$/.test(s))return;const n=this.getApp(),o=n?.getModelByRef(i);o?.VIEW_CLASS&&a.Modal.showModelById(o,s)}onActionApprove(){this.emit("action:respond",{noteId:this.noteId,action:"approve",handler:this.action.handler,context:this.action.context})}onActionDeny(){this.emit("action:respond",{noteId:this.noteId,action:"deny",handler:this.action.handler,context:this.action.context})}}const mt=["new","open","in_progress","pending","resolved","qa","closed","ignored"],pt={new:"pill-new",open:"pill-open",in_progress:"pill-prog",pending:"pill-prog",resolved:"pill-resolved",qa:"pill-open",closed:"pill-closed",ignored:"pill-closed"},bt=[{value:10,label:"P10 — Critical"},{value:9,label:"P9 — Severe"},{value:8,label:"P8 — High"},{value:7,label:"P7 — Elevated"},{value:5,label:"P5 — Normal"},{value:3,label:"P3 — Low"},{value:1,label:"P1 — Info"}];class TicketPanelView extends t.View{constructor(e={}){super({className:"ticket-panel-view",...e}),this.model=e.model||new c.Ticket(e.data||{})}onBeforeRender(){const e=this.model.get("status")||"new";this.statusPill=pt[e]||"pill-closed",this.statusLabel=e.replace(/_/g," "),this.priorityLabel=`P${this.model.get("priority")||5}`,this.assigneeName=this.model.get("assignee.display_name")||this.model.get("assignee")||"Unassigned",this.categoryLabel=this.model.get("category")||"ticket",this.groupName=this.model.get("group.name")||this.model.get("group")||"None",this.hasDescription=!!this.model.get("description"),this.hasIncident=!(!this.model.get("incident")||"object"!=typeof this.model.get("incident")||!this.model.get("incident").id),this.priorityColor=(this.model.get("priority")||5)>=7?"var(--bs-danger)":"var(--bs-secondary-color)",this.template=`\n <style>\n .ticket-panel-view { height: 100%; display: flex; flex-direction: column; }\n .tp-header { padding: 10px 16px 6px; border-bottom: 1px solid var(--bs-border-color-translucent); flex-shrink: 0; }\n .tp-title-row { display: flex; align-items: flex-start; gap: 6px; }\n .tp-title { font-size: 0.88rem; font-weight: 600; color: var(--bs-emphasis-color); line-height: 1.3; flex: 1; min-width: 0; cursor: ${this.hasDescription?"pointer":"default"}; transition: color 0.12s; }\n ${this.hasDescription?".tp-title:hover { color: var(--bs-primary); }":""}\n .tp-title i { font-size: 0.6rem; vertical-align: middle; margin-left: 3px; opacity: 0; transition: opacity 0.12s; }\n .tp-title:hover i { opacity: 0.6; }\n .tp-btns { display: flex; gap: 2px; align-items: center; flex-shrink: 0; }\n .tp-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: none; color: var(--bs-secondary-color); border-radius: 6px; cursor: pointer; font-size: 0.85rem; transition: all 0.12s; }\n .tp-btn:hover { background: var(--bs-tertiary-bg); color: var(--bs-body-color); }\n .tp-sub { display: flex; align-items: center; gap: 6px; margin-top: 3px; }\n .tp-id { font-family: var(--bs-font-monospace); font-size: 0.7rem; color: var(--bs-secondary-color); }\n .tp-pill { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 0.66rem; font-weight: 500; cursor: pointer; transition: filter 0.12s; }\n .tp-pill:hover { filter: brightness(0.9); }\n .tp-pill-new { background: rgba(var(--bs-info-rgb), 0.1); color: var(--bs-info); }\n .tp-pill-open { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tp-pill-prog { background: rgba(var(--bs-warning-rgb), 0.1); color: var(--bs-warning); }\n .tp-pill-closed { background: var(--bs-secondary-bg); color: var(--bs-secondary-color); }\n .tp-pill-resolved { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tp-time { font-size: 0.66rem; color: var(--bs-secondary-color); }\n .tp-desc-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; color: var(--bs-primary); cursor: pointer; padding: 2px 8px; border-radius: 5px; background: rgba(var(--bs-primary-rgb), 0.08); transition: all 0.12s; }\n .tp-desc-chip:hover { background: rgba(var(--bs-primary-rgb), 0.16); }\n .tp-desc-chip i { font-size: 0.7rem; }\n .tp-meta { display: flex; align-items: center; gap: 3px; margin-top: 5px; }\n .tp-fields { display: inline-flex; align-items: center; gap: 2px; flex-wrap: wrap; }\n .tp-field { display: inline-flex; align-items: center; gap: 4px; font-size: 0.72rem; color: var(--bs-secondary-color); padding: 2px 7px; border-radius: 5px; cursor: pointer; transition: all 0.12s; border: 1px solid transparent; }\n .tp-field:hover { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }\n .tp-field i { font-size: 0.68rem; }\n .tp-field .caret { font-size: 0.55rem; opacity: 0; transition: opacity 0.12s; margin-left: -1px; }\n .tp-field:hover .caret { opacity: 0.6; }\n .tp-sep { color: var(--bs-secondary-color); font-size: 0.6rem; margin: 0 1px; user-select: none; }\n\n .tp-linked { display: flex; align-items: center; gap: 6px; padding: 7px 16px; border-bottom: 1px solid var(--bs-border-color-translucent); font-size: 0.75rem; color: var(--bs-secondary-color); flex-shrink: 0; }\n .tp-linked i { color: var(--bs-warning); font-size: 0.72rem; }\n .tp-linked a { color: var(--bs-primary); text-decoration: none; font-weight: 500; }\n .tp-linked a:hover { text-decoration: underline; }\n .tp-linked .lpill { font-size: 0.62rem; padding: 0 5px; border-radius: 3px; background: rgba(var(--bs-warning-rgb), 0.1); color: var(--bs-warning); font-weight: 500; }\n\n .tp-conv { flex: 1; overflow-y: auto; min-height: 0; }\n .tp-conv .chat-container { border: none; border-radius: 0; }\n .tp-conv .chat-messages { padding: 6px 0; }\n .tp-conv .chat-input-wrapper { display: none; }\n .tp-conv .message-item { position: relative; }\n .tp-conv .tp-edit-btn { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; display: none; align-items: center; justify-content: center; border: none; background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); border-radius: 5px; cursor: pointer; font-size: 0.72rem; transition: all 0.12s; }\n .tp-conv .tp-edit-btn:hover { background: var(--bs-secondary-bg); color: var(--bs-body-color); }\n .tp-conv .message-item:hover .tp-edit-btn { display: flex; }\n .tp-conv .message-text { white-space: normal; }\n .tp-conv .message-text p { margin-bottom: 6px; }\n .tp-conv .message-text p:last-child { margin-bottom: 0; }\n .tp-conv .message-text h1,\n .tp-conv .message-text h2,\n .tp-conv .message-text h3,\n .tp-conv .message-text h4,\n .tp-conv .message-text h5,\n .tp-conv .message-text h6 { font-weight: 600; margin-top: 10px; margin-bottom: 4px; line-height: 1.3; }\n .tp-conv .message-text h1 { font-size: 1.05rem; }\n .tp-conv .message-text h2 { font-size: 1rem; }\n .tp-conv .message-text h3 { font-size: 0.95rem; }\n .tp-conv .message-text h4,\n .tp-conv .message-text h5,\n .tp-conv .message-text h6 { font-size: 0.88rem; }\n .tp-conv .message-text h1:first-child,\n .tp-conv .message-text h2:first-child,\n .tp-conv .message-text h3:first-child { margin-top: 0; }\n .tp-conv .message-text hr { margin: 4px 0; opacity: 0.15; }\n .tp-conv .message-text ul,\n .tp-conv .message-text ol { padding-left: 20px; margin-top: 2px; margin-bottom: 6px; }\n .tp-conv .message-text li { margin-bottom: 2px; }\n .tp-conv .message-text pre { background: var(--bs-tertiary-bg); border-radius: 6px; padding: 10px 14px; margin: 8px 0; font-size: 0.8rem; overflow-x: auto; }\n .tp-conv .message-text code { font-size: 0.85em; padding: 1px 5px; background: var(--bs-tertiary-bg); border-radius: 4px; }\n .tp-conv .message-text pre code { padding: 0; background: none; }\n .tp-conv .message-text table { width: 100%; margin: 8px 0; border-collapse: collapse; font-size: 0.82rem; }\n .tp-conv .message-text th,\n .tp-conv .message-text td { padding: 5px 8px; border: 1px solid var(--bs-border-color); text-align: left; }\n .tp-conv .message-text th { background: var(--bs-tertiary-bg); font-weight: 600; }\n .tp-conv .message-text blockquote { margin: 6px 0; padding: 4px 12px; border-left: 3px solid var(--bs-border-color); color: var(--bs-secondary-color); }\n\n .tp-action-area { padding: 0; }\n\n .tp-input { border-top: 1px solid var(--bs-border-color-translucent); padding: 10px 16px; flex-shrink: 0; }\n .tp-input-wrap { display: flex; align-items: flex-end; gap: 8px; }\n .tp-input textarea { flex: 1; font-size: 0.8rem; border: 1px solid var(--bs-border-color); border-radius: 8px; padding: 7px 10px; resize: none; background: var(--bs-body-bg); color: var(--bs-body-color); outline: none; transition: border-color 0.15s; font-family: inherit; max-height: 160px; overflow-y: auto; }\n .tp-input textarea:focus { border-color: var(--bs-primary); }\n .tp-input textarea::placeholder { color: var(--bs-secondary-color); }\n .tp-send-btn { width: 32px; height: 32px; border-radius: 8px; border: none; background: var(--bs-primary); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; font-size: 0.85rem; transition: filter 0.12s; }\n .tp-send-btn:hover { filter: brightness(1.1); }\n .tp-input-hint { display: flex; align-items: center; gap: 4px; margin-top: 4px; font-size: 0.7rem; color: var(--bs-secondary-color); }\n .tp-input-hint i { font-size: 0.75rem; }\n .tp-input.tp-dragover { background: rgba(var(--bs-primary-rgb), 0.04); }\n .tp-input.tp-dragover textarea { border-color: var(--bs-primary); border-style: dashed; }\n .tp-attachments { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 0; }\n .tp-attach-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: 6px; font-size: 0.72rem; color: var(--bs-body-color); }\n .tp-attach-chip i { font-size: 0.68rem; }\n .tp-attach-chip .remove { cursor: pointer; color: var(--bs-secondary-color); margin-left: 2px; }\n .tp-attach-chip .remove:hover { color: var(--bs-danger); }\n .tp-attach-chip.uploading { opacity: 0.6; }\n .tp-conv .message-content.tp-collapsed { max-height: var(--tp-collapse-h, 52px); overflow: hidden; position: relative; -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); mask-image: linear-gradient(to bottom, black 60%, transparent 100%); }\n .tp-conv .message-item:has(.tp-show-more) { flex-wrap: wrap; }\n .tp-show-more { background: none; border: none; padding: 2px 0; margin: -2px 0 0 48px; font-size: 0.7rem; color: var(--bs-secondary-color); cursor: pointer; text-align: left; width: calc(100% - 48px); }\n .tp-show-more:hover { color: var(--bs-body-color); }\n </style>\n\n <div class="tp-header">\n <div class="tp-title-row">\n <div class="tp-title" ${this.hasDescription?'data-action="show-description"':""} ${this.hasDescription?'title="View full description"':""}>\n {{model.title}}${this.hasDescription?' <i class="bi bi-arrow-up-right-square"></i>':""}\n </div>\n <div class="tp-btns">\n <div data-container="panel-menu"></div>\n <button class="tp-btn" data-action="close" title="Close panel"><i class="bi bi-x-lg"></i></button>\n </div>\n </div>\n <div class="tp-sub">\n <span class="tp-id">#{{model.id}}</span>\n <span class="tp-pill tp-${this.statusPill}" data-action="change-status" title="Change status">{{statusLabel}} <i class="bi bi-chevron-down" style="font-size:0.5rem;"></i></span>\n <span class="tp-time"><i class="bi bi-clock"></i> {{model.created|relative}}</span>\n <span class="tp-desc-chip" data-action="show-description" title="${this.hasDescription?"View / edit description":"Add description"}"><i class="bi bi-file-text"></i> ${this.hasDescription?"Description":"Add description"}</span>\n </div>\n <div class="tp-meta">\n <div class="tp-fields">\n <span class="tp-field" data-action="change-priority" title="Change priority">\n <i class="bi bi-flag-fill" style="color:${this.priorityColor};"></i>{{priorityLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-assignee" title="Assign">\n <i class="bi bi-person"></i>{{assigneeName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-category" title="Change category">\n <i class="bi bi-tag"></i>{{categoryLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-group" title="Change group">\n <i class="bi bi-people"></i>{{groupName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n </div>\n </div>\n </div>\n\n {{#hasIncident|bool}}\n <div class="tp-linked">\n <i class="bi bi-exclamation-triangle-fill"></i>\n <a href="#" data-action="view-incident">Incident #{{model.incident.id}}</a>\n {{#model.incident.status}}<span class="lpill">{{model.incident.status}}</span>{{/model.incident.status}}\n {{#model.incident.event_count}}<span>&middot; {{model.incident.event_count}} events</span>{{/model.incident.event_count}}\n </div>\n {{/hasIncident|bool}}\n\n <div class="tp-conv">\n <div data-container="chat-area"></div>\n </div>\n\n <div class="tp-input" data-ref="drop-zone">\n <div class="tp-attachments" data-ref="attachments"></div>\n <div class="tp-input-wrap">\n <textarea rows="2" placeholder="Add a note..." data-ref="note-textarea"></textarea>\n <button class="tp-send-btn" data-action="send-note" title="Send"><i class="bi bi-arrow-up"></i></button>\n </div>\n <div class="tp-input-hint">\n <i class="bi bi-paperclip"></i> Drag &amp; drop files to attach\n </div>\n </div>\n `}async onInit(){this.adapter=new TicketNoteAdapter(this.model.get("id")),this.chatView=new n.ChatView({containerId:"chat-area",adapter:this.adapter,theme:"compact",currentUserId:this._getCurrentUserId(),showInput:!1}),this.addChild(this.chatView);const t=new e.ContextMenu({containerId:"panel-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots",btnClass:"tp-btn",items:[{label:"Ask AI",action:"ask-ai",icon:"bi-robot"},{type:"divider"},{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{label:"Refresh Notes",action:"refresh-notes",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Close Window",action:"close",icon:"bi-x-lg"}]}});this.addChild(t)}async onAfterRender(){this._setupTextarea(),this._setupDragDrop(),await new Promise(e=>setTimeout(e,0)),await this._loadActionCards(),this._addEditButtons(),this._setupCollapsible()}async _loadActionCards(){this._cleanupActionCards();const e=this.chatView.messages||[];if(e.length)for(const t of e){if(!t.action||"object"!=typeof t.action)continue;const e=this.chatView.messageViews.get(t.id);if(!e?.element)continue;const i=new ActionCardView({action:t.action,noteId:t.id,ticketStatus:this.model.get("status")});"context"===t.action.type||t.action.resolved||i.on("action:respond",e=>this._handleActionResponse(e)),this.addChild(i),await i.render(),e.element.after(i.element)}}_cleanupActionCards(){for(const e in this.children){const t=this.children[e];t instanceof ActionCardView&&this.removeChild(t)}}async _handleActionResponse(e){const t={action:{handler:e.handler,context:e.context}},i=this.getApp();i?.showLoading();try{await this.adapter.addActionResponse(t,e.action),await this.model.fetch(),await this.chatView.refresh(),this.render()}finally{i?.hideLoading()}}_getCurrentUserId(){const e=this.getApp();return e?.activeUser?.id||e?.getActiveUser?.()?.id||null}onActionClose(){this.emit("panel:close")}async onActionSendNote(){const e=this.element?.querySelector('[data-ref="note-textarea"]'),t=e?.value?.trim(),i=this._stagedFiles||[];(t||i.length)&&(e.value="",e.style.height="",this._stagedFiles=[],this._renderAttachments(),await this.adapter.addNote({text:t||"",files:i}),await this.chatView.refresh(),await this._afterChatRefresh())}_setupTextarea(){const e=this.element?.querySelector('[data-ref="note-textarea"]');if(!e)return;const t=(t,i=t.length)=>{const s=e.selectionStart,a=e.selectionEnd;e.setRangeText(t,s,a,"end"),e.selectionStart=e.selectionEnd=s+i,e.dispatchEvent(new Event("input")),e.scrollTop=e.scrollHeight},i=t=>{const i=e.selectionStart,s=e.selectionEnd,a=e.value.substring(i,s);a.startsWith(t)&&a.endsWith(t)?(e.setRangeText(a.slice(t.length,-t.length),i,s,"end"),e.selectionStart=i,e.selectionEnd=s-2*t.length):(e.setRangeText(t+a+t,i,s,"end"),e.selectionStart=i+t.length,e.selectionEnd=s+t.length)},s=t=>{const i=e.value.substring(0,t).lastIndexOf("\n")+1;return{start:i,text:e.value.substring(i,t)}},a=t=>(e.value.substring(0,t).match(/^```/gm)||[]).length%2==1;e.addEventListener("keydown",n=>{const o=n.ctrlKey||n.metaKey;if("Enter"===n.key&&!n.shiftKey&&!o)return n.preventDefault(),void this.onActionSendNote();if("Enter"===n.key&&n.shiftKey){const{start:i,text:a}=s(e.selectionStart),o=a.match(/^(\s*)([-*]|\d+\.)\s/);if(o){n.preventDefault();const s=o[1],l=o[2];if(a.trim()===l)e.setRangeText("",i,e.selectionStart,"end");else{const e=/^\d+\./.test(l)?`${parseInt(l)+1}.`:l;t(`\n${s}${e} `)}return}}if("`"===n.key&&!o){const i=e.selectionStart;if(e.value.substring(0,i).endsWith("``")&&!a(i-2))return n.preventDefault(),void t("`\n\n```",2)}if("Tab"!==n.key||!a(e.selectionStart))return o&&"b"===n.key?(n.preventDefault(),void i("**")):o&&"i"===n.key?(n.preventDefault(),void i("*")):void 0;if(n.preventDefault(),n.shiftKey){const{start:t,text:i}=s(e.selectionStart),a=i.replace(/^ {1,2}/,"");e.setRangeText(a,t,t+i.length,"end"),e.selectionStart=e.selectionEnd=t+a.length}else t(" ")});const n={"(":")","[":"]",'"':'"'};e.addEventListener("keydown",i=>{if(i.ctrlKey||i.metaKey||i.altKey)return;const s=n[i.key];if(!s)return;const a=e.selectionStart,o=e.selectionEnd;if(a!==o){i.preventDefault();const t=e.value.substring(a,o);e.setRangeText(i.key+t+s,a,o,"end"),e.selectionStart=a+1,e.selectionEnd=o+1}else i.preventDefault(),t(i.key+s,1)}),e.addEventListener("input",()=>{e.style.height="",e.style.height=Math.min(e.scrollHeight,160)+"px"})}_setupDragDrop(){const e=this.element?.querySelector('[data-ref="drop-zone"]');if(!e)return;this._stagedFiles=this._stagedFiles||[];let t=0;e.addEventListener("dragenter",i=>{i.preventDefault(),t++,e.classList.add("tp-dragover")}),e.addEventListener("dragleave",()=>{t--,t<=0&&(t=0,e.classList.remove("tp-dragover"))}),e.addEventListener("dragover",e=>e.preventDefault()),e.addEventListener("drop",async i=>{i.preventDefault(),t=0,e.classList.remove("tp-dragover");const s=Array.from(i.dataTransfer?.files||[]);if(s.length)for(const e of s){const t=Date.now()+Math.random();this._addAttachChip(t,e.name,!0);try{const i=new a.File;await i.upload({file:e,showToast:!1}),this._stagedFiles.push(i),this._updateAttachChip(t,i.get("name")||e.name,i)}catch(n){console.error("File upload failed:",n),this._removeAttachChip(t),this.getApp()?.toast?.error?.("Upload failed: "+e.name)}}})}_addAttachChip(e,t,i){const s=this.element?.querySelector('[data-ref="attachments"]');if(!s)return;const a=document.createElement("span");a.className="tp-attach-chip"+(i?" uploading":""),a.dataset.chipId=e,a.innerHTML=`<i class="bi bi-paperclip"></i>${this._escapeHtml(t)}`+(i?"":'<span class="remove" data-remove="1"><i class="bi bi-x"></i></span>'),i||a.querySelector(".remove").addEventListener("click",()=>{this._removeAttachChip(e)}),s.appendChild(a)}_updateAttachChip(e,t,i){const s=this.element?.querySelector(`[data-chip-id="${e}"]`);s&&(s.classList.remove("uploading"),s.innerHTML=`<i class="bi bi-paperclip"></i>${this._escapeHtml(t)}<span class="remove" data-remove="1"><i class="bi bi-x"></i></span>`,s.querySelector(".remove").addEventListener("click",()=>{this._stagedFiles=(this._stagedFiles||[]).filter(e=>e!==i),s.remove()}))}_removeAttachChip(e){const t=this.element?.querySelector(`[data-chip-id="${e}"]`);t&&t.remove()}_renderAttachments(){const e=this.element?.querySelector('[data-ref="attachments"]');e&&(e.innerHTML="")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}_addEditButtons(){const e=this._getCurrentUserId();if(!e||!this.chatView?.messageViews)return;const t=new Map((this.chatView.messages||[]).map(e=>[e.id,e]));this.chatView.messageViews.forEach((i,s)=>{const a=t.get(s),n=i?.element?.querySelector(".message-item");if(!n)return;if(n.querySelectorAll(".tp-edit-btn").forEach(e=>e.remove()),!a||a.author?.id!==e)return;const o=document.createElement("button");o.className="tp-edit-btn",o.title="Edit note",o.innerHTML='<i class="bi bi-pencil"></i>',o.addEventListener("click",e=>{e.stopPropagation(),this._editNote(a)}),n.appendChild(o)})}_setupCollapsible(){const e=this.element?.querySelector('[data-container="chat-area"]');e&&(e.querySelectorAll(".tp-show-more").forEach(e=>e.remove()),e.querySelectorAll(".tp-collapsed").forEach(e=>e.classList.remove("tp-collapsed")),setTimeout(()=>{e.querySelectorAll(".message-content").forEach(e=>{if(e.scrollHeight<=52)return;e.classList.add("tp-collapsed"),e.style.setProperty("--tp-collapse-h","52px");const t=document.createElement("button");t.className="tp-show-more",t.textContent="Show more",t.addEventListener("click",()=>{const i=e.classList.toggle("tp-collapsed");t.textContent=i?"Show more":"Show less"}),e.after(t)})},150))}async _editNote(e){const t=Object.keys(e._metadata||{}).length?JSON.stringify(e._metadata,null,2):"",i=await a.Modal.form({title:"Edit Note",icon:"bi-pencil",size:"lg",fields:[{type:"tabset",tabs:[{label:"Note",fields:[{name:"note",type:"textarea",label:"Note",required:!0,cols:12,rows:8,value:e._rawContent||e.content}]},{label:"Metadata",fields:[{name:"metadata_json",type:"json",label:"Metadata (JSON)",cols:12,rows:10,value:t,help:'Action metadata — e.g. { "action": { "handler": "incident.rule_approval", "label": "...", "context": { ... } } }'}]}]}]});if(!i)return;const s=new c.TicketNote({id:e.id}),n={note:i.note};i.metadata_json&&(n.metadata="string"==typeof i.metadata_json?JSON.parse(i.metadata_json):i.metadata_json),await s.save(n),await this.chatView.refresh(),await this._afterChatRefresh()}async _saveAndSync(e){await this.model.save(e),await this.model.fetch(),this.chatView&&(await this.chatView.refresh(),await this._afterChatRefresh())}async onActionChangeStatus(e){const t=mt.map(e=>({label:e.replace(/_/g," "),value:e,active:e===this.model.get("status")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({status:i}),this.render())}async onActionChangePriority(e){const t=bt.map(e=>({label:e.label,value:e.value,active:e.value===this.model.get("priority")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({priority:parseInt(i)}),this.render())}async onActionChangeAssignee(){const e=await a.Modal.form({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:t.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this._saveAndSync({assignee:e.assignee}),this.render())}async onActionChangeCategory(e){const t=Object.entries(c.TicketCategories).map(([e,t])=>({label:t,value:e,active:e===this.model.get("category")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({category:i}),this.render())}async onActionChangeGroup(){const e=await a.Modal.form({title:"Change Group",icon:"bi-people",size:"sm",fields:[{name:"group",type:"collection",label:"Group",Collection:t.GroupList,labelField:"name",valueField:"id",required:!1,cols:12,value:this.model.get("group")}]});e&&(await this._saveAndSync({group:e.group}),this.render())}async onActionShowDescription(){const e=this.model.get("description")||"";if(!e)return this._editDescription();let i=!1,s="";try{const a=await t.rest.post("/api/docit/render",{markdown:e});s=a?.data?.data?.html||a?.data?.html||"",i=!!s}catch(n){}if(!i){const t=document.createElement("div");t.textContent=e,s=`<pre style="white-space:pre-wrap;">${t.innerHTML}</pre>`}"edit"===await a.Modal.dialog({title:`Ticket #${this.model.get("id")} — Description`,body:`<div style="font-size:0.85rem; line-height:1.65;">${s}</div>`,size:"lg",buttons:[{text:"Edit",class:"btn-primary",value:"edit"},{text:"Close",class:"btn-secondary",value:"close"}]})&&await this._editDescription()}async _editDescription(){const e=this.model.get("id"),t=`\n <div class="tp-desc-edit">\n <textarea data-ref="desc-textarea" rows="16" placeholder="Description (markdown supported)..."\n style="width:100%; font-family: var(--bs-font-monospace); font-size: 0.85rem; padding: 10px 12px; border: 1px solid var(--bs-border-color); border-radius: 8px; background: var(--bs-body-bg); color: var(--bs-body-color); resize: vertical; outline: none;">${(this.model.get("description")||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</textarea>\n <div class="text-muted small mt-1">\n Markdown supported. Cmd/Ctrl+B = bold · Cmd/Ctrl+I = italic · Shift+Enter continues lists · \`\`\` opens a code block\n </div>\n </div>\n `,i=(async()=>{for(let e=0;e<20;e++){await new Promise(e=>setTimeout(e,50));const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');if(e)return this._wireMarkdownTextarea(e),e}return null})(),s=await a.Modal.dialog({title:`Ticket #${e} — Edit Description`,body:t,size:"lg",buttons:[{text:"Cancel",class:"btn-secondary",value:null},{text:"Save",class:"btn-primary",handler:()=>{const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');return e?e.value:null}}]});await i,null!=s&&(await this._saveAndSync({description:s}),this.render())}_wireMarkdownTextarea(e){const t=(t,i=t.length)=>{const s=e.selectionStart,a=e.selectionEnd;e.setRangeText(t,s,a,"end"),e.selectionStart=e.selectionEnd=s+i,e.dispatchEvent(new Event("input"))},i=t=>{const i=e.selectionStart,s=e.selectionEnd,a=e.value.substring(i,s);a.startsWith(t)&&a.endsWith(t)?(e.setRangeText(a.slice(t.length,-t.length),i,s,"end"),e.selectionStart=i,e.selectionEnd=s-2*t.length):(e.setRangeText(t+a+t,i,s,"end"),e.selectionStart=i+t.length,e.selectionEnd=s+t.length)},s=t=>{const i=e.value.substring(0,t).lastIndexOf("\n")+1;return{start:i,text:e.value.substring(i,t)}},a=t=>(e.value.substring(0,t).match(/^```/gm)||[]).length%2==1;e.addEventListener("keydown",n=>{const o=n.ctrlKey||n.metaKey;if("Enter"===n.key&&n.shiftKey){const{start:i,text:a}=s(e.selectionStart),o=a.match(/^(\s*)([-*]|\d+\.)\s/);if(o){n.preventDefault();const s=o[1],l=o[2];if(a.trim()===l)e.setRangeText("",i,e.selectionStart,"end");else{const e=/^\d+\./.test(l)?`${parseInt(l)+1}.`:l;t(`\n${s}${e} `)}return}}if("`"===n.key&&!o){const i=e.selectionStart;if(e.value.substring(0,i).endsWith("``")&&!a(i-2))return n.preventDefault(),void t("`\n\n```",2)}if("Tab"!==n.key||!a(e.selectionStart))return o&&"b"===n.key?(n.preventDefault(),void i("**")):o&&"i"===n.key?(n.preventDefault(),void i("*")):void 0;if(n.preventDefault(),n.shiftKey){const{start:t,text:i}=s(e.selectionStart),a=i.replace(/^ {1,2}/,"");e.setRangeText(a,t,t+i.length,"end"),e.selectionStart=e.selectionEnd=t+a.length}else t(" ")});const n={"(":")","[":"]",'"':'"'};e.addEventListener("keydown",i=>{if(i.ctrlKey||i.metaKey||i.altKey)return;const s=n[i.key];if(!s)return;const a=e.selectionStart,o=e.selectionEnd;if(a!==o){i.preventDefault();const t=e.value.substring(a,o);e.setRangeText(i.key+t+s,a,o,"end"),e.selectionStart=a+1,e.selectionEnd=o+1}else i.preventDefault(),t(i.key+s,1)})}async onActionViewIncident(){const e=this.model.get("incident");e?.id&&a.Modal.showModel(new c.Incident({id:e.id}))}async onActionEditTicket(){await a.Modal.modelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:c.TicketForms.edit.fields})&&this.render()}async onActionRefreshNotes(){await this.chatView.refresh(),await this._afterChatRefresh(),this.getApp()?.toast?.success("Notes refreshed")}async onActionAskAi(){await Z(this,"incident.Ticket")}async _showInlineSelect(t,i){return new Promise(s=>{let a=!1;const n=new e.ContextMenu({config:{items:t.map((e,t)=>({label:e.label,action:`pick-${t}`,class:e.active?"fw-bold":"",handler:()=>{a=!0,this.removeChild(n),s(e.value)}}))}}),o=n.closeDropdown.bind(n);n.closeDropdown=()=>{o(),a||(this.removeChild(n),s(null))},this.addChild(n),n.openAt(i.clientX,i.clientY)})}async _afterChatRefresh(){await new Promise(e=>setTimeout(e,0)),await this._loadActionCards(),this._addEditButtons(),this._setupCollapsible()}async setTicket(e){this.model=e,this.adapter=new TicketNoteAdapter(e.get("id")),this.chatView.adapter=this.adapter,this.chatView.clearMessages(),await this.render(),await this.chatView.refresh(),await this._afterChatRefresh()}}function gt(e,i=!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/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/shortlinks/links",ShortLinkTablePage,{permissions:["manage_shortlinks"]}),e.registerPage("system/shortlinks/clicks",ShortLinkClickTablePage,{permissions:["manage_shortlinks"]}),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/messaging/public-messages",PublicMessageTablePage,{permissions:["view_support","support","security"]}),e.registerPage("system/incident-dashboard",SecurityDashboardPage,{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"]}),e.registerPage("system/assistant/skills",AssistantSkillTablePage,{permissions:["view_admin","assistant"]}),e.registerPage("system/assistant/conversations",AssistantConversationTablePage,{permissions:["view_admin","assistant"]}),e.registerPage("system/assistant/memory",AssistantMemoryPage,{permissions:["view_admin","assistant"]}),i&&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:"Security Dashboard",route:"?page=system/incident-dashboard",icon:"bi-shield-check",permissions:["view_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:["jobs","view_jobs","manage_jobs"]},{text:"Runners",route:"?page=system/jobs/runners",icon:"bi-cpu",permissions:["jobs","view_jobs","manage_jobs"]},{text:"Jobs",route:"?page=system/jobs/list",icon:"bi-list-task",permissions:["jobs","view_jobs","manage_jobs"]}]},{text:"System Security",route:null,icon:"bi-shield-exclamation",permissions:["view_security"],children:[{text:"Tickets",route:"?page=system/tickets",icon:"bi-ticket-detailed",permissions:["manage_security"]},{text:"Incidents",route:"?page=system/incidents",icon:"bi-exclamation-triangle",permissions:["view_security"]},{text:"Events",route:"?page=system/events",icon:"bi-bell",permissions:["view_security"]},{text:"Rules",route:"?page=system/rulesets",icon:"bi-funnel",permissions:["manage_security"]}]},{text:"Network Security",route:null,icon:"bi-hdd-network",permissions:["view_security"],children:[{text:"IPs",route:"?page=system/system/geoip",icon:"bi-globe",permissions:["view_security"]},{text:"IP Sets",route:"?page=system/security/ipsets",icon:"bi-shield-shaded",permissions:["view_security"]},{text:"Blocked",route:"?page=system/security/blocked-ips",icon:"bi-slash-circle",permissions:["view_security"]},{text:"Firewall Log",route:"?page=system/security/firewall-log",icon:"bi-journal-code",permissions:["view_security"]}]},{text:"Bouncer",route:null,icon:"bi-fingerprint",permissions:["view_security"],children:[{text:"Signals",route:"?page=system/security/bouncer-signals",icon:"bi-activity",permissions:["view_security"]},{text:"Devices",route:"?page=system/security/bouncer-devices",icon:"bi-fingerprint",permissions:["view_security"]},{text:"Bots",route:"?page=system/security/bot-signatures",icon:"bi-robot",permissions:["manage_security"]}]},{text:"Messaging",route:null,icon:"bi-envelope",permissions:["manage_aws","view_support","support"],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:"Contact Messages",route:"?page=system/messaging/public-messages",icon:"bi-chat-square-text",permissions:["view_support","support"]}]},{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:"Shortlinks",route:null,icon:"bi-link-45deg",permissions:["manage_shortlinks"],children:[{text:"Links",route:"?page=system/shortlinks/links",icon:"bi-link",permissions:["manage_shortlinks"]},{text:"Click History",route:"?page=system/shortlinks/clicks",icon:"bi-cursor",permissions:["manage_shortlinks"]}]},{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:"Mojo",route:null,icon:"bi-robot",permissions:["view_admin","assistant"],children:[{text:"Skills",route:"?page=system/assistant/skills",icon:"bi-lightning",permissions:["view_admin","assistant"]},{text:"Memory",route:"?page=system/assistant/memory",icon:"bi-lightbulb",permissions:["view_admin","assistant"]},{text:"Conversations",route:"?page=system/assistant/conversations",icon:"bi-chat-left-text",permissions:["view_admin","assistant"]}]},{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)}}const s=[c.Incident,c.IncidentEvent,c.RuleSet,c.Ticket,n.GeoLocatedIP,t.User,t.Group,l.Member,c.Job,c.JobRunner,a.File,l.Log,n.ShortLink];for(const t of s)t.MODEL_REF&&e.registerModelRef(t.MODEL_REF,t);vt(e)}function vt(e){function t(){if(!e._ticketPanel)return;const t=document.querySelector(".portal-layout");t&&t.classList.remove("ticket-panel-open"),e._ticketPanel.destroy(),e._ticketPanel=null;const i=document.getElementById("ticket-panel");i&&i.remove()}e.openTicketPanel=async function(i){let s;if(i&&"object"==typeof i&&i.get?(s=i,await s.fetch()):(s=new c.Ticket({id:i}),await s.fetch()),e._ticketPanel&&e._ticketPanel.isMounted())return void e._ticketPanel.setTicket(s);const a=document.querySelector(".portal-layout");if(!a)return;let n=document.getElementById("ticket-panel");n||(n=document.createElement("div"),n.id="ticket-panel",a.appendChild(n));const o=new TicketPanelView({model:s,app:e});o.on("panel:close",()=>t()),await o.render(!0,n),e._ticketPanel=o,requestAnimationFrame(()=>{a.classList.add("ticket-panel-open")})},e.closeTicketPanel=t}exports.FileView=n.FileView,exports.PushDeviceView=c.PushDeviceView,exports.WebApp=h.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.AssistantConversationTablePage=AssistantConversationTablePage,exports.AssistantConversationView=AssistantConversationView,exports.AssistantMemoryPage=AssistantMemoryPage,exports.AssistantSkillTablePage=AssistantSkillTablePage,exports.AssistantSkillView=AssistantSkillView,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.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=SecurityDashboardPage,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.PublicMessageTablePage=PublicMessageTablePage,exports.PublicMessageView=PublicMessageView,exports.PushConfigTablePage=PushConfigTablePage,exports.PushDashboardPage=PushDashboardPage,exports.PushDeliveryTablePage=PushDeliveryTablePage,exports.PushDeliveryView=PushDeliveryView,exports.PushDeviceTablePage=PushDeviceTablePage,exports.PushTemplateTablePage=PushTemplateTablePage,exports.RuleSetTablePage=RuleSetTablePage,exports.RuleSetView=RuleSetView,exports.RunnerDetailsView=RunnerDetailsView,exports.S3BucketTablePage=S3BucketTablePage,exports.SMSTablePage=SMSTablePage,exports.SentMessageTablePage=SentMessageTablePage,exports.SettingTablePage=SettingTablePage,exports.SettingView=SettingView,exports.ShortLinkClickTablePage=ShortLinkClickTablePage,exports.ShortLinkTablePage=ShortLinkTablePage,exports.ShortLinkView=ShortLinkView,exports.TicketTablePage=TicketTablePage,exports.TicketView=TicketView,exports.UserDeviceLocationTablePage=UserDeviceLocationTablePage,exports.UserDeviceLocationView=UserDeviceLocationView,exports.UserDeviceTablePage=UserDeviceTablePage,exports.UserTablePage=UserTablePage,exports.UserView=UserView,exports.registerAdminPages=gt,exports.registerAssistant=function(e){function t(){if(!e._assistantPanel)return;const t=document.querySelector(".portal-layout");t&&t.classList.remove("assistant-panel-open"),e._assistantPanel.destroy(),e._assistantPanel=null;const i=document.getElementById("assistant-panel");i&&i.remove()}async function i(){if(e._assistantPanel&&e._assistantPanel.isMounted())return void e._assistantPanel.focusInput();const a=document.querySelector(".portal-layout");if(!a)return s();let n=document.getElementById("assistant-panel");n||(n=document.createElement("div"),n.id="assistant-panel",a.appendChild(n));const o=new AssistantPanelView({app:e});o.on("panel:close",()=>t()),o.on("panel:fullscreen",()=>s()),o.on("panel:popout",a=>async function(a){t();const n=window.open("","mojo-assistant","width=480,height=700,toolbar=no,menubar=no,status=no,location=no,resizable=yes");if(!n)return e.toast&&e.toast.warning("Popup blocked — opening sidebar instead"),void(window.innerWidth>=1e3?await i():await s());const o=document.querySelectorAll('link[rel="stylesheet"], style');let l="";o.forEach(e=>{"LINK"===e.tagName?l+=`<link rel="stylesheet" href="${e.href}">`:l+=e.outerHTML}),n.document.write(`<!DOCTYPE html>\n<html><head><title>Mojo</title>${l}\n<style>\n body { margin: 0; height: 100vh; overflow: hidden; }\n #assistant-popup-root { height: 100vh; }\n .assistant-panel-view { height: 100%; }\n .assistant-panel-resize-handle { display: none; }\n</style>\n</head><body class="assistant-popup">\n<div id="assistant-popup-root"></div>\n</body></html>`),n.document.close();const r=new AssistantPanelView({app:e,conversationId:a||e._assistantConversationId||null});r.on("panel:close",()=>n.close()),r.on("panel:popout",()=>{});const c=n.document.getElementById("assistant-popup-root");await r.render(!0,c),e._assistantPopup=n,e._assistantPopupView=r,n.addEventListener("beforeunload",()=>{e._assistantPopupView&&(e._assistantPopupView.destroy(),e._assistantPopupView=null),e._assistantPopup=null})}(a?.conversationId)),await o.render(!0,n),e._assistantPanel=o;const l=localStorage.getItem("mojo:assistant_panel_width");if(l){const e=parseInt(l,10);e>=300&&e<=700&&(n.style.width="0px")}requestAnimationFrame(()=>{if(a.classList.add("assistant-panel-open"),l){const e=parseInt(l,10);e>=300&&e<=700&&(n.style.width=e+"px")}})}async function s(){t();const i=new AssistantView({app:e});a.Modal.show(i,{size:"fullscreen",noBodyPadding:!0,title:" ",buttons:[]})}let n=null;window.addEventListener("resize",function(){n&&clearTimeout(n),n=setTimeout(()=>{e._assistantPanel&&window.innerWidth<1e3&&s()},250)});const o={id:"assistant",icon:"bi-robot",action:"open-assistant",isButton:!0,buttonClass:"btn btn-link nav-link",tooltip:"Mojo",permissions:["view_admin"],handler:async()=>{e._assistantPanel&&e._assistantPanel.isMounted()?t():window.innerWidth>=1e3?await i():await s()}};e.topbar&&e.topbar.config?(e.topbar.config.rightItems.unshift(o),e.topbar.isMounted()&&e.topbar.render()):e.topbarConfig&&(e.topbarConfig.rightItems||(e.topbarConfig.rightItems=[]),e.topbarConfig.rightItems.unshift(o))},exports.registerSystemPages=gt,exports.registerTicketPanel=vt;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./chunks/ContextMenu-DavvvDHE.js"),t=require("./chunks/User-B1rsVKZn.js"),i=require("./chunks/exportChart-CbdjAe3W.js"),s=require("./chunks/MetricsCountryMapView-BtOe426O.js"),a=require("./chunks/Modal-BlwwVcGL.js"),n=require("./chunks/ChatView-BPJGwNB-.js"),o=require("./chunks/FormView-DNuchWxp.js"),l=require("./chunks/Passkeys-1N9xo02Z.js"),r=require("./chunks/ListView-DbLe2wcm.js"),c=require("./chunks/admin-models-s9AXuwJ1.js"),d=require("./chunks/DataView-BClQAaKx.js"),h=require("./chunks/WebApp-B4a19_WH.js"),u=require("./chunks/version-Ci5qX4UI.js");class KPITile extends t.View{constructor(e={}){super({tagName:"button",...e,className:`mojo-kpi-tile ${e.severity?"mojo-kpi-tile-"+e.severity:""} ${e.className||""}`.trim()}),this.slug=e.slug||null,this.label=e.label||"",this.value=e.value??null,this.delta=e.delta??null,this.deltaPct=e.deltaPct??null,this.severity=e.severity||null,this.tone=e.tone||null,this.sparklineValues=Array.isArray(e.sparkline)?e.sparkline.slice():[],this.sparklineColor=e.sparklineColor||null,this.sparklineHeight=e.sparklineHeight||36,this.formatter="function"==typeof e.formatter?e.formatter:null,this.element.setAttribute("type","button")}async onInit(){this.sparkline=new i.MiniChart({containerId:"spark",chartType:"line",data:this.sparklineValues,color:this.sparklineColor||this._defaultSparkColor(),fillColor:this._fillColorFor(this.sparklineColor||this._defaultSparkColor()),fill:!0,smoothing:.3,height:this.sparklineHeight,width:"100%",showTooltip:!1,showCrosshair:!1,showXAxis:!1,animate:!1,padding:2}),this.addChild(this.sparkline)}async getTemplate(){const e=this._formatValue(this.value),t=this._renderDelta();return`\n <span class="mojo-kpi-tile-label">${this.escapeHtml(this.label)}</span>\n <span class="mojo-kpi-tile-value">${this.escapeHtml(e)}</span>\n ${t}\n <div data-container="spark" class="mojo-kpi-tile-spark"></div>\n `}async onAfterRender(){this._clickBound||(this.element.addEventListener("click",()=>{this.emit?.("tile:click",{tile:this,slug:this.slug})}),this._clickBound=!0)}setData({value:e,delta:t,deltaPct:i,sparkline:s}={}){void 0!==e&&(this.value=e),void 0!==t&&(this.delta=t),void 0!==i&&(this.deltaPct=i),void 0!==s&&(this.sparklineValues=Array.isArray(s)?s.slice():[],this.sparkline?.setData(this.sparklineValues)),this.isMounted()&&this.render()}_defaultSparkColor(){return{critical:"rgba(220, 53, 69, 1)",high:"rgba(253, 126, 20, 1)",warn:"rgba(255, 193, 7, 1)",info:"rgba(13, 202, 240, 1)",good:"rgba(25, 135, 84, 1)"}[this.severity]||"rgba(13, 202, 240, 1)"}_fillColorFor(e){const t=String(e).match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);return t?`rgba(${t[1]}, ${t[2]}, ${t[3]}, 0.12)`:"rgba(108, 117, 125, 0.12)"}_formatValue(e){return null==e?"—":this.formatter?this.formatter(e):"number"==typeof e?e.toLocaleString():String(e)}_renderDelta(){if(null==this.deltaPct&&null==this.delta)return"";let e,t;if(null!=this.deltaPct&&Number.isFinite(this.deltaPct)){const i=Math.abs(this.deltaPct)>=10?Math.round(this.deltaPct):Math.round(10*this.deltaPct)/10;t=i>0?"+":i<0?"−":"±",e=`${t}${Math.abs(i)}%`}else{if(null==this.delta)return"";t=this.delta>0?"+":this.delta<0?"−":"±",e=`${t}${Math.abs(this.delta)}`}let i="mojo-kpi-tile-delta";return"±"===t?i+=" mojo-kpi-tile-delta-flat":"bad"===this.tone?i+="+"===t?" mojo-kpi-tile-delta-bad":" mojo-kpi-tile-delta-good":"good"===this.tone?i+="+"===t?" mojo-kpi-tile-delta-good":" mojo-kpi-tile-delta-bad":i+=" mojo-kpi-tile-delta-neutral",`<span class="${i}">${this.escapeHtml(e)}</span>`}}class KPIStrip extends t.View{constructor(e={}){super({...e,className:`mojo-kpi-strip ${e.className||""}`.trim()}),this.tiles=Array.isArray(e.tiles)?e.tiles:[],this.account=e.account||"incident",this.granularity=e.granularity||"days",this.sparklineDays=e.sparklineDays??7,this.sparklineGranularity=e.sparklineGranularity||"days",this.seriesEndpoint=e.seriesEndpoint||"/api/metrics/series",this.fetchEndpoint=e.fetchEndpoint||"/api/metrics/fetch",this.includeSparkline=!1!==e.includeSparkline,this.tileHeight=e.tileHeight||36,this._tileViews=[]}async getTemplate(){return'<div class="mojo-kpi-strip-grid" data-container="grid"></div>'}async onInit(){for(let e=0;e<this.tiles.length;e++){const t=this.tiles[e],i=new KPITile({containerId:`kpi-tile-${e}`,slug:t.slug||t.key||`tile-${e}`,label:t.label||t.slug||"",severity:t.severity||null,tone:t.tone||null,sparklineHeight:this.tileHeight,formatter:t.formatter||null});i.on?.("tile:click",e=>{this.emit?.("tile:click",{...e,key:t.key||t.slug})}),this._tileViews.push(i),this.addChild(i)}}async getViewData(){return{...this.data,tilesHtml:this._tileViews.map((e,t)=>`<div data-container="kpi-tile-${t}" class="mojo-kpi-strip-cell"></div>`).join("")}}async renderTemplate(){return`<div class="mojo-kpi-strip-grid">${this._tileViews.map((e,t)=>`<div data-container="kpi-tile-${t}" class="mojo-kpi-strip-cell"></div>`).join("")}</div>`}async onAfterRender(){await this.refresh()}async refresh(){const e=this.getApp()?.rest;if(!e)return;const t=this.tiles.filter(e=>e.slug),i=this.tiles.filter(e=>e.rest),s=Array.from(/* @__PURE__ */new Set([...t.map(e=>e.slug),...i.map(e=>e.sparklineSlug).filter(Boolean)]));t.map(e=>e.slug);const a=[];let n=null;if(s.length){const t={slugs:s.join(","),account:this.account,granularity:this.granularity,with_delta:!0,_:Date.now()};n=e.GET(this.seriesEndpoint,t).catch(e=>(console.warn("[KPIStrip] series fetch failed:",e),null)),a.push(n)}let o=null;if(this.includeSparkline&&s.length){const t=new Date(Date.now()-864e5*this.sparklineDays),i={slugs:s.join(","),account:this.account,granularity:this.sparklineGranularity,with_labels:!0,dr_start:Math.floor(t.getTime()/1e3),_:Date.now()};o=e.GET(this.fetchEndpoint,i).catch(e=>(console.warn("[KPIStrip] sparkline fetch failed:",e),null)),a.push(o)}const l=i.map(t=>{const i={...t.rest.params||{},_:Date.now()};return e.GET(t.rest.endpoint,i).catch(e=>(console.warn(`[KPIStrip] count fetch failed for ${t.label}:`,e),null))});a.push(...l),await Promise.allSettled(a);const r=n?await n:null,c=o?await o:null,d=this._unwrap(r),h=this._unwrap(c)?.data;for(const m of t){const e=this.tiles.indexOf(m),t=this._tileViews[e];if(!t)continue;const i=d?.data?.[m.slug]??null,s=d?.deltas?.[m.slug]||{},a=s.delta??null,n=s.delta_pct??null,o=h?.data?.[m.slug],l=h?.data?.default,r=Array.isArray(o)?o:Array.isArray(l)?l:[];t.setData({value:i,delta:a,deltaPct:n,sparkline:r})}let u=0;for(const m of i){const e=this.tiles.indexOf(m),t=this._tileViews[e];if(!t)continue;const i=await l[u++],s={value:this._readRestCount(i),delta:null,deltaPct:null};if(m.sparklineSlug){const e=h?.data?.[m.sparklineSlug],t=h?.data?.default;s.sparkline=Array.isArray(e)?e:Array.isArray(t)?t:[];const i=d?.deltas?.[m.sparklineSlug];i&&(s.delta=i.delta??null,s.deltaPct=i.delta_pct??null)}t.setData(s)}this.emit?.("strip:refreshed")}_unwrap(e){return e?e.success&&e.data?e.data:e:null}_readRestCount(e){if(!e)return null;const t=e.data||e;return"number"==typeof t?.count?t.count:"number"==typeof t?.data?.count?t.data.count:null}}const m={success_login:"rgba(32, 201, 151, 0.85)",success:"rgba(32, 201, 151, 0.85)",login:"rgba(32, 201, 151, 0.85)",failed_login:"rgba(220, 53, 69, 0.85)",failure:"rgba(220, 53, 69, 0.85)",failed:"rgba(220, 53, 69, 0.85)",suspicious:"rgba(255, 193, 7, 0.85)",mfa_required:"rgba(255, 193, 7, 0.85)",mfa:"rgba(255, 193, 7, 0.85)"};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.viewMode=e.viewMode||"summary",this.listZoom=e.listZoom??3.3,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-flex align-items-center gap-2 mb-2">\n <div class="d-none align-items-center gap-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 class="ms-auto btn-group btn-group-sm" role="group">\n <button class="btn btn-sm btn-outline-secondary ${"summary"===this.viewMode?"active":""}"\n data-action="set-mode" data-mode="summary"\n title="Aggregated by country">\n <i class="bi bi-globe-americas"></i>\n </button>\n <button class="btn btn-sm btn-outline-secondary ${"list"===this.viewMode?"active":""}"\n data-action="set-mode" data-mode="list"\n title="Every login">\n <i class="bi bi-pin-map"></i>\n </button>\n </div>\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{this.mapView=new s.MapLibreView({containerId:"map",height:this.height,style:this.mapStyle,...this._defaultView(),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 onActionSetMode(e,t){const i=t?.dataset?.mode;if(!i||i===this.viewMode)return;this.viewMode=i,this._drillCountry=null,this._hideDrillBar(),this.element?.querySelectorAll('[data-action="set-mode"]').forEach(e=>{e.classList.toggle("active",e.dataset.mode===this.viewMode)});const{center:s,zoom:a}=this._defaultView();this.mapView?.map?.flyTo({center:s,zoom:a,duration:600}),await this.refresh()}_defaultView(){return"list"===this.viewMode?{center:[-98.58,39.83],zoom:this.listZoom}:{center:[10,20],zoom:1.3}}async refresh(){if(!this._refreshing&&this._mapAvailable){this._refreshing=!0,this._setStatus("Loading locations…");try{if("list"===this.viewMode){const e=await this._fetchList();this._applyListMarkers(e)}else{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 i={};let s;this.drStart&&(i.dr_start=this.drStart),this.drEnd&&(i.dr_end=this.drEnd),this.userId?(s="/api/account/logins/user",i.user_id=this.userId):s="/api/account/logins/summary",e&&(i.country_code=e,i.region=!0);const a=await t.GET(s,i);if(!a.success||!a.data?.status)throw new Error(a.data?.error||"Login summary API error");return a.data.data||[]}async _fetchList(){const e=this.getApp()?.rest;if(!e)throw new Error("REST client unavailable");const t={graph:"list",size:1e3,sort:"-created"};this.drStart&&(t.dr_start=this.drStart),this.drEnd&&(t.dr_end=this.drEnd),this.userId&&(t.user=this.userId);const i=await e.GET("/api/account/logins",t);if(!i.success||!i.data?.status)throw new Error(i.data?.error||"Login events API error");return i.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)),i=e.filter(e=>e.latitude&&e.longitude).map(e=>{const i=e.count/(t||1),s=Math.round(18+26*i),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:s,color:this._getMarkerColor(i),popup:r,_countryCode:e.country_code,_isRegion:a}});this.mapView.updateMarkers(i),this._drillCountry||"summary"!==this.viewMode||this._attachMarkerClicks(i)}_attachMarkerClicks(e){this.mapView?.mapMarkers&&this.mapView.mapMarkers.forEach((t,i)=>{const s=e[i];if(!s||s._isRegion)return;const a=t.getElement();a&&a.addEventListener("dblclick",e=>{e.stopPropagation(),this.drillDown(s._countryCode)})})}_getMarkerColor(e){const t=[255,193,7],i=[32,201,151].map((i,s)=>Math.round(i+(t[s]-i)*e));return`rgba(${i[0]}, ${i[1]}, ${i[2]}, 0.9)`}_applyListMarkers(e){const t=e.filter(e=>e.latitude&&e.longitude);if(!t.length)return this.mapView.updateMarkers([]),void this._setStatus("No login events with location data found.");const i=t.map(e=>{const t=[e.city,e.region,e.country_code].filter(Boolean).join(", "),i=e.created?new Date(1e3*e.created).toLocaleString():"—",s=!this.userId&&e.user?.id?`<button class="btn btn-outline-primary mt-2 w-100"\n style="font-size:0.7rem;padding:2px 8px;"\n data-action="open-user"\n data-user-id="${e.user.id}">\n <i class="bi bi-person me-1"></i>${e.user.display_name||e.user.username||"View User"}\n </button>`:"",a=`\n <div style="min-width:160px;">\n <div class="fw-semibold">${t||"—"}</div>\n <div class="text-muted small"><code>${e.ip_address||""}</code></div>\n <div class="text-muted small">${i}</div>\n ${e.source?`<div class="text-muted small">via ${e.source}</div>`:""}\n ${s}\n </div>\n `;return{lng:e.longitude,lat:e.latitude,size:10,color:this._getEventColor(e.event_type),popup:a}});this.mapView.updateMarkers(i),this._setStatus(`${i.length.toLocaleString()} login${1!==i.length?"s":""} plotted`)}async onActionOpenUser(e,i){const s=Number(i?.dataset?.userId);if(s)if(t.User.VIEW_CLASS)try{await a.Modal.showModelById(t.User,s)}catch(n){console.error("LoginLocationMapView: failed to open user",n)}else console.warn("LoginLocationMapView: User.VIEW_CLASS not registered")}_getEventColor(e){return m[String(e||"").toLowerCase()]||"rgba(108, 117, 125, 0.85)"}async drillDown(e){if(!this._refreshing&&"summary"===this.viewMode){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"]'),i=this.element?.querySelector('[data-region="drill-label"]');t&&t.classList.replace("d-none","d-flex"),i&&(i.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 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!-- Login Locations Map --\x3e\n <div class="row mb-4">\n <div class="col-12">\n <div class="card border-0 shadow-sm">\n <div class="card-header bg-transparent border-bottom d-flex align-items-center gap-2">\n <i class="bi bi-geo-alt"></i>\n <span class="fw-semibold">Login Locations</span>\n <span class="text-muted small ms-1">— last 30 days</span>\n </div>\n <div class="card-body p-0">\n <div data-container="login-map"></div>\n </div>\n </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);const e=/* @__PURE__ */new Date,t=new Date(e.getTime()-2592e6);this.loginMapView=new LoginLocationMapView({containerId:"login-map",height:360,mapStyle:"dark",drStart:t.toISOString().slice(0,10),drEnd:e.toISOString().slice(0,10)}),this.addChild(this.loginMapView),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:"bar",showDateRange:!1,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number:0"},containerId:"api-metrics-chart"}),this.addChild(this.apiMetricsChart)}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");try{s?.classList.add("bi-spin"),i&&(i.disabled=!0);const e=[this.headerView?.loadValues(),this.apiMetricsChart?.refresh(),this.loginMapView?.refresh()].filter(Boolean);await Promise.allSettled(e),this.lastUpdated=/* @__PURE__ */(new Date).toLocaleString();const t=this.getApp()?.events;t&&t.emit("admin:dashboard-refreshed",{page:this,timestamp:this.lastUpdated})}catch(a){console.error("Failed to refresh dashboard:",a);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{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}async onActionExportMetrics(e,t){try{this.apiMetricsChart?.chart&&i.exportChartPng(this.apiMetricsChart.chart);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 i=this.getApp()?.router;i&&i.navigateTo("/admin/alerts")}async onActionViewSystemStatus(e,t){const i=this.getApp()?.router;i&&i.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()}}const p=t.MOJOUtils.escapeHtml;function b(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}function g(e){const t=b(e);if(null==t)return"—";const i=Math.round((Date.now()-t)/1e3);return i<0?"in the future":i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}function v(e){const t=e?.user_agent||{},i=[t.family,t.major].filter(Boolean);return i.length?i.join(" "):"Unknown browser"}function y(e){const t=e.get("device_info")||{};return t.last_geo||t.geolocation||e.get("last_geo")||{}}function w(e,t){return function(e){const t=y(e),i=[];return t.is_vpn&&i.push({key:"vpn",label:"VPN"}),t.is_tor&&i.push({key:"tor",label:"Tor"}),t.is_proxy&&i.push({key:"proxy",label:"Proxy"}),t.is_cloud&&i.push({key:"cloud",label:"Cloud"}),i}(e).some(e=>e.key===t)}class DeviceLocationRow extends l.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 text-bg-warning">VPN</span>'),e.is_tor&&t.push('<span class="badge text-bg-danger">Tor</span>'),e.is_proxy&&t.push('<span class="badge text-bg-warning">Proxy</span>'),e.is_cloud&&t.push('<span class="badge text-bg-info">Cloud</span>'),t.join(" ")}get hasThreatFlags(){const e=this.model?.get("geolocation")||{};return!!(e.is_vpn||e.is_tor||e.is_proxy||e.is_cloud)}}class DeviceOverviewSection extends t.View{constructor(e={}){const{locationsCollection:t,...i}=e;super({className:"device-overview-section",template:'\n <div class="detail-section-eyebrow">Overview</div>\n <div class="detail-kpi-grid">\n <div data-container="dv-kpi-sessions"></div>\n <div data-container="dv-kpi-locations"></div>\n <div data-container="dv-kpi-days"></div>\n <div data-container="dv-kpi-last-login"></div>\n </div>\n\n <div class="detail-section-eyebrow">Threat signals</div>\n <div data-container="dv-overview-threats"></div>\n ',...i}),this.locationsCollection=t||null}async onInit(){const e=this.model;this.kpiSessions=new n.MetricCard({containerId:"dv-kpi-sessions",label:"Sessions",value:()=>{const t=e.get("session_count")??e.get("sessions");return null==t?"—":String(t)}}),this.kpiLocations=new n.MetricCard({containerId:"dv-kpi-locations",label:"Locations",value:()=>{const e=this._readLocationsCount();return null==e?"—":String(e)}}),this.kpiDays=new n.MetricCard({containerId:"dv-kpi-days",label:"Days active",value:()=>{const t=function(e){const t=b(e.get("first_seen")),i=b(e.get("last_seen"));return null==t||null==i?null:Math.max(0,Math.floor((i-t)/864e5))}(e);return null==t?"—":String(t)}}),this.kpiLastLogin=new n.MetricCard({containerId:"dv-kpi-last-login",label:"Last login",value:()=>e.get("last_seen")?g(e.get("last_seen")):"Never",tone:e.get("last_seen")?"success":"default"}),[this.kpiSessions,this.kpiLocations,this.kpiDays,this.kpiLastLogin].forEach(e=>this.addChild(e)),this.threatTimeline=new n.Timeline({containerId:"dv-overview-threats",model:e,emptyText:"No threat signals recorded.",items:e=>this._threatItems(e)}),this.addChild(this.threatTimeline),this.locationsCollection&&this.locationsCollection.on("fetch:success",()=>{this.kpiLocations?.isMounted?.()&&this.kpiLocations.render()},this)}_readLocationsCount(){return this.locationsCollection?this.locationsCollection.totalCount??this.locationsCollection.models?.length??null:null}_threatItems(e){const t=e||this.model,i=y(t),s=!!t.get("is_trusted"),a=t.get("last_seen")?g(t.get("last_seen")):"unknown",n=[];n.push({tone:s?"success":"default",headline:s?"Marked trusted":"Not marked trusted",detail:s?"Operator has flagged this device as safe.":"No trust override set.",when:a});const o=w(t,"vpn");n.push({tone:o?"warning":"success",headline:o?"VPN detected":"No VPN signal",detail:o?"Last session originated from a VPN exit.":"",when:"live"});const l=w(t,"tor");n.push({tone:l?"danger":"success",headline:l?"Seen from a Tor exit":"No Tor signal",detail:l?"Recent activity routed through the Tor network.":"",when:"live"}),w(t,"proxy")&&n.push({tone:"warning",headline:"Open proxy detected",detail:"Last session went through an open proxy.",when:"live"});const r=t.get("location_count")??null,c=null!=r?`${r} distinct location${1===r?"":"s"}`:i.country_name?`Last from ${p(i.country_name)}`:"Location unknown";n.push({tone:"info",headline:"Geo footprint",detail:c,when:"live"});const d=t.get("duid");return d&&n.push({tone:"default",headline:"Device fingerprint",detail:`<code>${p(String(d))}</code>`,when:t.get("first_seen")?g(t.get("first_seen")):""}),n}}class DeviceHardwareSection extends t.View{constructor(e={}){super({className:"device-hardware-section",template:'\n <div class="detail-section-eyebrow">Browser</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.user_agent.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Version</div>\n <div class="detail-flat-row-value">\n {{#hasBrowserVersion|bool}}{{browserVersion}}{{/hasBrowserVersion|bool}}\n {{^hasBrowserVersion|bool}}<span class="text-secondary fst-italic">—</span>{{/hasBrowserVersion|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Engine</div>\n <div class="detail-flat-row-value">{{model.device_info.user_agent.engine|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Operating system</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.os.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Version</div>\n <div class="detail-flat-row-value">\n {{#hasOsVersion|bool}}{{osVersion}}{{/hasOsVersion|bool}}\n {{^hasOsVersion|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOsVersion|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Hardware</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Brand</div>\n <div class="detail-flat-row-value">{{model.device_info.device.brand|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Family</div>\n <div class="detail-flat-row-value">{{model.device_info.device.family|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Model</div>\n <div class="detail-flat-row-value">{{model.device_info.device.model|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Display &amp; environment</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Resolution</div>\n <div class="detail-flat-row-value">\n {{#hasResolution|bool}}{{resolutionDisplay}}{{/hasResolution|bool}}\n {{^hasResolution|bool}}<span class="text-secondary fst-italic">—</span>{{/hasResolution|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Pixel ratio</div>\n <div class="detail-flat-row-value">\n {{#hasPixelRatio|bool}}{{pixelRatioDisplay}}{{/hasPixelRatio|bool}}\n {{^hasPixelRatio|bool}}<span class="text-secondary fst-italic">—</span>{{/hasPixelRatio|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Color depth</div>\n <div class="detail-flat-row-value">\n {{#hasColorDepth|bool}}{{colorDepthDisplay}}{{/hasColorDepth|bool}}\n {{^hasColorDepth|bool}}<span class="text-secondary fst-italic">—</span>{{/hasColorDepth|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Locale</div>\n <div class="detail-flat-row-value">\n {{#model.device_info.locale}}<code>{{model.device_info.locale}}</code>{{/model.device_info.locale}}\n {{^model.device_info.locale}}<span class="text-secondary fst-italic">—</span>{{/model.device_info.locale}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#model.device_info.timezone}}<code>{{model.device_info.timezone}}</code>{{/model.device_info.timezone}}\n {{^model.device_info.timezone}}<span class="text-secondary fst-italic">—</span>{{/model.device_info.timezone}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Identification</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Device ID</div>\n <div class="detail-flat-row-value">\n {{#model.duid}}<code>{{model.duid}}</code>{{/model.duid}}\n {{^model.duid}}<span class="text-secondary fst-italic">—</span>{{/model.duid}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last IP</div>\n <div class="detail-flat-row-value">\n {{#model.last_ip}}<code>{{model.last_ip}}</code>{{/model.last_ip}}\n {{^model.last_ip}}<span class="text-secondary fst-italic">—</span>{{/model.last_ip}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">First seen</div>\n <div class="detail-flat-row-value">\n {{#model.first_seen}}<code>{{model.first_seen|datetime}}</code>{{/model.first_seen}}\n {{^model.first_seen}}<span class="text-secondary fst-italic">—</span>{{/model.first_seen}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last seen</div>\n <div class="detail-flat-row-value">\n {{#model.last_seen}}<code>{{model.last_seen|datetime}}</code>{{/model.last_seen}}\n {{^model.last_seen}}<span class="text-secondary fst-italic">—</span>{{/model.last_seen}}\n </div>\n </div>\n\n {{#hasUaString|bool}}\n <div class="detail-section-eyebrow">User agent</div>\n <pre class="detail-error-block">{{model.device_info.string}}</pre>\n {{/hasUaString|bool}}\n ',...e})}get hasBrowserVersion(){const e=this.model?.get("device_info")?.user_agent||{};return[e.major,e.minor,e.patch].some(e=>null!=e&&""!==e)}get browserVersion(){const e=this.model?.get("device_info")?.user_agent||{};return[e.major,e.minor,e.patch].filter(e=>null!=e&&""!==e).join(".")}get hasOsVersion(){const e=this.model?.get("device_info")?.os||{};return[e.major,e.minor,e.patch].some(e=>null!=e&&""!==e)}get osVersion(){const e=this.model?.get("device_info")?.os||{};return[e.major,e.minor,e.patch].filter(e=>null!=e&&""!==e).join(".")}get hasResolution(){const e=this.model?.get("device_info")?.screen||{};return!(!e.width||!e.height)}get resolutionDisplay(){const e=this.model?.get("device_info")?.screen||{};return`${e.width} × ${e.height}`}get hasPixelRatio(){return null!=(this.model?.get("device_info")?.screen||{}).pixel_ratio}get pixelRatioDisplay(){return`${(this.model?.get("device_info")?.screen||{}).pixel_ratio}×`}get hasColorDepth(){return null!=(this.model?.get("device_info")?.screen||{}).color_depth}get colorDepthDisplay(){return`${(this.model?.get("device_info")?.screen||{}).color_depth}-bit`}get hasUaString(){return!!this.model?.get("device_info")?.string}}class DeviceSessionsSection extends t.View{constructor(e={}){super({className:"device-sessions-section",template:'\n <div class="detail-section-eyebrow">Sessions</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value text-secondary fst-italic">\n Session history is not yet recorded server-side. Once a\n UserDeviceSession collection lands, this section will\n list every login / token-grant for this device.\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Sessions seen</div>\n <div class="detail-flat-row-value">\n {{#hasSessionCount|bool}}<strong>{{sessionCount}}</strong>{{/hasSessionCount|bool}}\n {{^hasSessionCount|bool}}<span class="text-secondary fst-italic">—</span>{{/hasSessionCount|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last login</div>\n <div class="detail-flat-row-value">\n {{#model.last_seen}}<code>{{model.last_seen|datetime}}</code> <span class="text-secondary">· {{model.last_seen|relative}}</span>{{/model.last_seen}}\n {{^model.last_seen}}<span class="text-secondary fst-italic">Never</span>{{/model.last_seen}}\n </div>\n </div>\n ',...e})}get hasSessionCount(){return null!=(this.model?.get("session_count")??this.model?.get("sessions"))}get sessionCount(){const e=this.model?.get("session_count")??this.model?.get("sessions");return null==e?"":String(e)}}class DeviceMetadataSection extends t.View{constructor(e={}){super({className:"device-metadata-section",template:'\n <div class="detail-section-eyebrow">Metadata</div>\n <div data-container="dv-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"dv-metadata-card",model:this.model,data:e=>({...e.get("metadata")||{},_record_id:e.get("id"),_user:e.get("user"),_duid:e.get("duid"),_first_seen:e.get("first_seen"),_last_seen:e.get("last_seen"),_is_trusted:e.get("is_trusted")}),knownKeys:[{key:"_record_id",label:"Record ID",formatter:e=>null!=e?`<code>${p(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"_duid",label:"DUID",formatter:e=>e?`<code>${p(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"_user.display_name",label:"Owner",hideEmpty:!0},{key:"_first_seen",label:"First seen",formatter:"datetime"},{key:"_last_seen",label:"Last seen",formatter:"datetime"},{key:"_is_trusted",label:"Trusted",formatter:"yesnoicon"}],rawLabel:"Raw metadata",rawCollapsed:!0,emptyText:"No metadata recorded for this device."}),this.addChild(this.knownFields)}}class DeviceView extends n.DetailView{constructor(e={}){const i=e.model||new t.UserDevice(e.data||{}),s=i.get("device_info")||{},a=new t.UserDeviceLocationList({params:{user_device:i.get("id"),size:10}}),n=new DeviceOverviewSection({model:i,locationsCollection:a}),o=new DeviceHardwareSection({model:i}),r=new l.TableView({collection:a,title:"Locations",eyebrow:"Section · Locations",showFullscreen:!1,showRefresh:!0,searchable:!1,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["user_device"],clickAction:"view",itemClass:DeviceLocationRow,selectable:!1,columns:[{key:"ip_address",label:"Location",template:'\n <div class="fw-semibold small">\n <i class="bi bi-geo-alt text-secondary me-1"></i>{{locationText}}\n {{#countryName}} <span class="text-secondary fw-normal">· {{countryName}}</span>{{/countryName}}\n </div>\n <div class="text-secondary small mt-1">\n <code>{{model.ip_address}}</code>\n {{#ispName}} <span class="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",sortable:!0,width:"120px"},{key:"last_seen",label:"Last seen",formatter:"epoch|relative",sortable:!0,width:"120px"}]}),c=new DeviceSessionsSection({model:i}),d=new DeviceMetadataSection({model:i}),h=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:n},{key:"Hardware",label:"Hardware",icon:"bi-cpu",view:o},{type:"divider",label:"Activity"},{key:"Locations",label:"Locations",icon:"bi-geo-alt",view:r},{key:"Sessions",label:"Sessions",icon:"bi-clock-history",view:c},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:d}],u=function(e){const t=(e?.user_agent?.family||"").toLowerCase(),i=(e?.os?.family||"").toLowerCase(),s=(e?.device?.family||"").toLowerCase();return t.includes("chrome")?"bi-browser-chrome":t.includes("firefox")?"bi-browser-firefox":t.includes("safari")?"bi-browser-safari":t.includes("edge")?"bi-browser-edge":i.includes("mac")||i.includes("ios")?"bi-apple":i.includes("windows")?"bi-windows":i.includes("android")?"bi-android2":i.includes("linux")?"bi-ubuntu":s.includes("iphone")?"bi-phone":s.includes("ipad")?"bi-tablet":"bi-laptop"}(s),m=[{icon:"bi-window",text:e=>v(e.get("device_info")||{}),variant:"info",when:e=>!!e.get("device_info")?.user_agent?.family},{text:e=>{const t=e.get("session_count")??e.get("sessions");return null!=t?`${t} ${1===t?"session":"sessions"}`:null},variant:"light"},{text:e=>{const t=e.get("location_count");return null!=t?`${t} ${1===t?"location":"locations"}`:null},variant:"light"},{icon:"bi-shield-check",text:"Trusted",variant:"success",when:e=>!!e.get("is_trusted")},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",when:e=>!!e.get("is_blocked")},{icon:"bi-shield-exclamation",text:"VPN",variant:"warning",when:e=>w(e,"vpn")},{icon:"bi-shield-x",text:"Tor",variant:"danger",when:e=>w(e,"tor")},{icon:"bi-shield-exclamation",text:"Proxy",variant:"warning",when:e=>w(e,"proxy")},{icon:"bi-cloud",text:"Cloud",variant:"info",when:e=>w(e,"cloud")}],f=void 0!==i.get("is_trusted"),_=[{label:"View user",action:"view-user",icon:"bi-person"},{label:"View locations",action:"view-locations-section",icon:"bi-geo-alt"}];f&&_.push({label:i.get("is_trusted")?"Mark untrusted":"Mark trusted",action:"toggle-trusted",icon:"bi-shield-check"}),_.push({type:"divider"}),_.push({label:"Forget device",action:"forget-device",icon:"bi-trash",danger:!0}),super({className:"device-view",...e,model:i,header:{icon:u,iconToneFn:e=>w(e,"tor")||w(e,"proxy")?"danger":w(e,"vpn")||w(e,"cloud")?"warning":e.get("is_blocked")?"danger":e.get("is_trusted")?"success":"info",titleFn:e=>{const t=e.get("device_info")||{};return`${v(t)} on ${function(e){const t=e?.os||{},i=[t.major,t.minor].filter(Boolean).join(".");return t.family?`${t.family} ${i}`.trim():"Unknown OS"}(t)}`},subtitleFn:e=>function(e){const t=e.get("last_seen"),i=e.get("last_ip"),s=y(e),a=e.get("user"),n=[];n.push(t?`Last seen ${g(t)}`:"Never seen"),i&&n.push(`from ${i}`);const o=[s.city,s.country_name].filter(Boolean).join(", ");return o&&n.push(`· ${o}`),a?.display_name&&n.push(`· owner ${a.display_name}`),n.join(" ")}(e),chips:m,auxFn:e=>function(e){const t=e.get("last_seen"),i=(()=>{const e=b(t);return null!=e&&Date.now()-e<3e5})();let s="",a="";e.get("is_blocked")?(s=" dh-aux-dot-danger",a="Blocked"):i?(s=" dh-aux-dot-success",a="Online"):t?(s=" dh-aux-dot-secondary",a="Offline"):(s=" dh-aux-dot-secondary",a="Never seen");const n=t?`Last seen ${p(g(t))}`:"";return`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${s}"></span>\n <span>${p(a)}</span>\n </span>\n ${n?`<span class="dh-aux-meta">${n}</span>`:""}\n `}(e),activeField:f?"is_trusted":null,actions:[{label:"View user",icon:"bi-person",action:"view-user",title:"Open the user that owns this device"},{label:"Forget",icon:"bi-trash",action:"forget-device",title:"Delete this device record"}],contextMenu:{items:_}},sections:h,activeSection:"Overview"}),this.locationsCollection=a,this.overviewSection=n,this.hardwareSection=o,this.locationsSection=r,this.sessionsSection=c,this.metadataSection=d}async onAfterBuild(){const e=()=>{const e=this.locationsCollection.totalCount??this.locationsCollection.models?.length??0;this.setBadge("Locations",e>0?{text:String(e),variant:"muted"}:null)};this.locationsCollection.on("fetch:success",e,this),this.locationsCollection.models?.length&&e();const t=this.model.get("session_count")??this.model.get("sessions");null!=t&&t>0&&this.setBadge("Sessions",{text:String(t),variant:"muted"}),this.locationsCollection.fetch().catch(()=>{})}async onActionViewUser(){const e=this.model.get("user"),i=e?.id||e;if(!i)return void this.getApp()?.toast?.warning?.("No user linked to this device");this.emit("view-user",{userId:i});const s=t.User.VIEW_CLASS;if(!s)return;const n=new t.User({id:i});try{await n.fetch()}catch{}const o=new s({model:n});await a.Modal.detail(o)}async onActionViewLocationsSection(){await this.showSection("Locations")}async onActionToggleTrusted(){const e=!this.model.get("is_trusted");try{const t=await this.model.save({is_trusted:e});if(t&&t.status&&t.status>=400)throw new Error("Save failed");this.model.set("is_trusted",e),this.getApp()?.toast?.success(e?"Marked trusted":"Marked untrusted"),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.threatTimeline?.isMounted()&&await this.overviewSection.threatTimeline.render(),this.emit("detail:updated")}catch(t){this.getApp()?.toast?.error(`Failed to update trust: ${t.message}`)}}async onActionForgetDevice(){if(!(await a.Modal.confirm("Forget this device? The record will be deleted.","Forget Device")))return!0;try{const e=await this.model.destroy();if(e&&!1===e.success)throw new Error(e.data?.error||"Delete failed");this.getApp()?.toast?.success("Device forgotten");const t=this.element?.closest(".modal");if(t){const e=window.bootstrap?.Modal?.getInstance(t);e&&e.hide()}this.emit("device:deleted",{model:this.model})}catch(e){this.getApp()?.toast?.error(`Failed to forget: ${e.message}`)}return!0}static async show(e){const i=await t.UserDevice.getByDuid(e);return i?a.Modal.detail(new DeviceView({model:i})):(a.Modal.alert({message:`Could not find device with DUID: ${e}`,type:"warning"}),null)}}DeviceView.VIEW_CLASS=DeviceView,t.UserDevice.VIEW_CLASS=DeviceView;const f={in_app:"In-App",email:"Email",push:"Push"},_=["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 _.map(e=>({key:e,label:f[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:_.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,i){const s=i.dataset.kind,a=i.dataset.channel,n=i.checked;this.preferences[s]||(this.preferences[s]={}),this.preferences[s][a]=n;try{const e=await t.rest.POST("/api/account/notification/preferences",{user:this.model.id,preferences:{[s]:{[a]:n}}});e.success||(this.getApp()?.toast?.error(e.message||"Failed to update preference"),i.checked=!n)}catch(o){this.getApp()?.toast?.error("Failed to update preference"),i.checked=!n}return!0}}class AdminPersonalSection extends t.View{constructor(e={}){super({className:"admin-personal-section",enableTooltips:!0,template:'\n \x3c!-- Name --\x3e\n <div class="detail-section-eyebrow">Name</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display Name</div>\n <div class="detail-flat-row-value">\n {{#model.display_name}}{{model.display_name}}{{/model.display_name}}\n {{^model.display_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.display_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">First Name</div>\n <div class="detail-flat-row-value">\n {{#model.first_name}}{{model.first_name}}{{/model.first_name}}\n {{^model.first_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.first_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-first-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last Name</div>\n <div class="detail-flat-row-value">\n {{#model.last_name}}{{model.last_name}}{{/model.last_name}}\n {{^model.last_name}}<span class="text-secondary fst-italic">Not set</span>{{/model.last_name}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-last-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n \x3c!-- Details --\x3e\n <div class="detail-section-eyebrow">Details</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Date of Birth</div>\n <div class="detail-flat-row-value">\n {{#hasDob|bool}}\n {{dobFormatted}}\n {{#model.is_dob_verified|bool}}<span class="badge text-bg-success">Verified</span>{{/model.is_dob_verified|bool}}\n {{^model.is_dob_verified|bool}}<span class="badge text-bg-warning">Unverified</span>{{/model.is_dob_verified|bool}}\n {{/hasDob|bool}}\n {{^hasDob|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasDob|bool}}\n </div>\n <div class="detail-flat-row-action">\n {{#hasDob|bool}}\n {{#model.is_dob_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="edit-dob" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">{{timezoneDisplay}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n \x3c!-- Address --\x3e\n <div class="detail-section-eyebrow">Address</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Address</div>\n <div class="detail-flat-row-value">\n {{#hasAddress|bool}}{{addressSummary}}{{/hasAddress|bool}}\n {{^hasAddress|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasAddress|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-address" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\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,i,s]=e.split("-");return`${i}/${s}/${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 a.Modal.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 a.Modal.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 a.Modal.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({display_name:e.trim()},"Display name"),!0}async onActionEditFirstName(){const e=await a.Modal.prompt("First name:","Edit First Name",{defaultValue:this.model.get("first_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({first_name:e.trim()},"First name"),!0}async onActionEditLastName(){const e=await a.Modal.prompt("Last name:","Edit Last Name",{defaultValue:this.model.get("last_name")||""});return"string"==typeof e&&e.trim()&&await this._saveField({last_name:e.trim()},"Last name"),!0}async onActionEditDob(){const e=await a.Modal.form({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 a.Modal.form({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 a.Modal.form({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 i=await this.model.save(e);200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(i.message||`Failed to update ${t.toLowerCase()}`)}}class AdminSecuritySection extends t.View{constructor(e={}){super({className:"admin-security-section",template:'\n <div class="detail-section-eyebrow">Authentication</div>\n\n <div class="admin-security-item" data-action="reset-password">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Send Password Reset</div>\n <div class="admin-security-desc">Send a password reset email to {{model.email}}</div>\n </div>\n <span class="badge text-bg-light border">Send</span>\n </div>\n\n <div class="admin-security-item" data-action="send-magic-link">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-link-45deg"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Send Magic Login Link</div>\n <div class="admin-security-desc">Send a one-click login link to {{model.email}}</div>\n </div>\n <span class="badge text-bg-light border">Send</span>\n </div>\n\n {{#isAdminCaller|bool}}\n <div class="admin-security-item" data-action="change-password">\n <div class="admin-security-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Set Password</div>\n <div class="admin-security-desc">Set a new password directly for this user</div>\n </div>\n <span class="badge text-bg-light border">Set</span>\n </div>\n {{/isAdminCaller|bool}}\n\n <div class="detail-section-eyebrow">Multi-Factor Authentication</div>\n\n {{#isAdminCaller|bool}}\n <div class="admin-security-item" data-action="toggle-mfa">\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-shield-lock"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">MFA Requirement</div>\n <div class="admin-security-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}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-light border">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n {{/isAdminCaller|bool}}\n\n <div class="admin-security-item{{#totpEnabled|bool}} admin-security-item-clickable{{/totpEnabled|bool}}"{{#totpEnabled|bool}} data-action="disable-totp"{{/totpEnabled|bool}}>\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Authenticator (TOTP)</div>\n <div class="admin-security-desc">\n {{#totpEnabled|bool}}User has an authenticator app enrolled — click to disable{{/totpEnabled|bool}}\n {{^totpEnabled|bool}}User has not enrolled an authenticator app{{/totpEnabled|bool}}\n </div>\n </div>\n {{#totpEnabled|bool}}<span class="badge text-bg-success">Enrolled</span>{{/totpEnabled|bool}}\n {{^totpEnabled|bool}}<span class="badge text-bg-light border">Not enrolled</span>{{/totpEnabled|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-info bg-opacity-10 text-info"><i class="bi bi-phone"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">SMS Verification</div>\n <div class="admin-security-desc">\n {{#smsEligible|bool}}Verified phone available — SMS-based MFA can be used{{/smsEligible|bool}}\n {{^smsEligible|bool}}No verified phone on file — SMS-based MFA unavailable{{/smsEligible|bool}}\n </div>\n </div>\n {{#smsEligible|bool}}<span class="badge text-bg-success">Eligible</span>{{/smsEligible|bool}}\n {{^smsEligible|bool}}<span class="badge text-bg-light border">Unavailable</span>{{/smsEligible|bool}}\n </div>\n\n <div class="admin-security-item admin-security-item-clickable" data-action="manage-passkeys">\n <div class="admin-security-icon bg-success bg-opacity-10 text-success"><i class="bi bi-fingerprint"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Passkeys</div>\n <div class="admin-security-desc">View and manage registered passkeys</div>\n </div>\n {{#hasPasskey|bool}}<span class="badge text-bg-success me-2">Registered</span>{{/hasPasskey|bool}}\n <i class="bi bi-chevron-right admin-security-chevron"></i>\n </div>\n\n {{#totpEnabled|bool}}\n <div class="admin-security-item admin-security-item-clickable" data-action="view-recovery-codes">\n <div class="admin-security-icon" style="background: rgba(var(--bs-purple-rgb,111,66,193),0.1); color: var(--bs-purple, #6f42c1);"><i class="bi bi-file-earmark-lock"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Recovery Codes</div>\n <div class="admin-security-desc">View remaining recovery codes</div>\n </div>\n <i class="bi bi-chevron-right admin-security-chevron"></i>\n </div>\n {{/totpEnabled|bool}}\n\n {{#isAdminCaller|bool}}\n <div class="detail-section-eyebrow">Sessions</div>\n\n <div class="admin-security-item" data-action="revoke-all-sessions">\n <div class="admin-security-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-box-arrow-right"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Revoke All Sessions</div>\n <div class="admin-security-desc">Force sign-out from all devices</div>\n </div>\n </div>\n {{/isAdminCaller|bool}}\n ',...e})}get totpEnabled(){const e=this.model;return!!(e?.get?.("has_totp")||e?.get?.("totp_enabled")||e?.get?.("totp")?.is_enabled)}get smsEligible(){const e=this.model;return!(!e?.get?.("phone_number")||!e?.get?.("is_phone_verified"))}get hasPasskey(){return!!this.model?.get?.("has_passkey")}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}async onActionToggleMfa(){const e=this.getApp(),t=this.model.get("requires_mfa"),i=t?"disable":"enable";if(!(await a.Modal.confirm((t?"Disable":"Enable")+" MFA requirement for this user?",(t?"Disable":"Enable")+" MFA")))return!0;const s=await this.model.save({requires_mfa:!t});return 200===s.status?(e?.toast?.success(`MFA ${i}d`),await this.render()):e?.toast?.error(s.message||`Failed to ${i} MFA`),!0}async onActionDisableTotp(){const e=this.getApp();if(!(await a.Modal.confirm("Disable the authenticator app for this user? Their existing TOTP enrollment will be removed and they will need to re-enroll if they want to use one again.","Disable Authenticator")))return!0;const i=await t.rest.DELETE(`/api/user/${this.model.id}/totp`);return i.success?(this.model.set("has_totp",!1),this.model.set("totp_enabled",!1),e?.toast?.success("Authenticator disabled"),await this.render()):e?.toast?.error(i.message||"Failed to disable authenticator"),!0}async onActionManagePasskeys(){const e=new l.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch(n){}const i=e.models||[],s=new t.View({template:'\n {{#passkeys}}\n <div class="admin-passkey-row">\n <div class="admin-passkey-icon"><i class="bi bi-fingerprint"></i></div>\n <div class="admin-passkey-info">\n <div class="admin-passkey-name">{{.friendly_name|default:\'Unnamed Passkey\'}}</div>\n <div class="admin-passkey-meta text-secondary">Created {{.created|date}} &middot; Last used {{.last_used|relative|default:\'never\'}} &middot; {{.sign_count}} uses</div>\n </div>\n <div class="admin-passkey-actions">\n <button type="button" class="btn btn-sm 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-sm 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="admin-passkey-empty text-secondary">\n <i class="bi bi-fingerprint"></i>\n <div>No passkeys registered</div>\n </div>\n {{/passkeys|bool}}\n '});return s.passkeys=i.map(e=>e.toJSON?e.toJSON():e),s.onActionEditPasskey=async(e,t)=>{const s=t.dataset.id,n=i.find(e=>String(e.id)===String(s));return n&&await a.Modal.modelForm({title:"Edit Passkey",model:n,fields:l.PasskeyForms.edit.fields,size:"sm"}),!0},s.onActionDeletePasskey=async(e,t)=>{const s=t.dataset.id;if(await a.Modal.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(s));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await a.Modal.dialog({title:"Passkeys",body:s,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:s,codes:n}=i.data,o=new t.View({template:'\n <div class="admin-recovery-info text-secondary"><span class="admin-recovery-remaining">{{remaining}}</span> recovery codes remaining</div>\n <div class="admin-recovery-list">\n {{#codes}}<div class="admin-recovery-code">{{.}}</div>{{/codes}}\n </div>\n '});return o.remaining=s,o.codes=n||[],await a.Modal.dialog({title:"Recovery Codes",body:o,size:"sm",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}}const k={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",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Linked accounts</div>\n {{#connections}}\n <div class="admin-connected-row">\n <div class="admin-connected-icon"><i class="bi {{.icon}}"></i></div>\n <div class="admin-connected-info">\n <div class="admin-connected-provider">{{.provider}}</div>\n <div class="admin-connected-meta text-secondary">{{.email}} &middot; Connected {{.created|relative}}</div>\n </div>\n <div class="admin-connected-actions">\n <button type="button" class="btn btn-sm 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="admin-connected-empty text-secondary">\n <i class="bi bi-plug"></i>\n <div>No connected accounts</div>\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}),i=e?.data?.results||e?.data||[];this.connections=i.map(e=>({...e,icon:k[e.provider]||"bi-link-45deg"}))}catch(e){this.connections=[]}}async onActionUnlink(e,i){const s=i.dataset.id,n=this.connections.find(e=>String(e.id)===String(s)),o=n?.provider||"this account";if(!(await a.Modal.confirm(`Unlink ${o} for this user?`,"Unlink Account")))return!0;const l=await t.rest.DELETE(`/api/account/oauth_connection/${s}`);return l.success?(this.getApp()?.toast?.success(`${o} account unlinked`),await this.render()):this.getApp()?.toast?.error(l.message||"Failed to unlink account"),!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(),i=Object.keys(t).sort();if(!i.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 s=i.map(e=>{const i=t[e],s="object"==typeof i?JSON.stringify(i):String(i);return`\n <div class="amd-item">\n <div class="amd-key">${this._escapeHtml(e)}</div>\n <div class="amd-value">${this._escapeHtml(s)}</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">${s}</div>`}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionAddEntry(){const e=await a.Modal.form({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 s=this._getMetadata(),n=s[i],o="object"==typeof n?JSON.stringify(n):String(n),l=await a.Modal.form({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={...s};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 a.Modal.confirm(`Remove metadata key "<strong>${this._escapeHtml(i)}</strong>"?`,"Remove Entry")))return!0;const s={...this._getMetadata()};return delete s[i],200===(await this.model.save({metadata:s})).status?(this.getApp()?.toast?.success("Metadata entry removed"),this._renderEntries()):this.getApp()?.toast?.error("Failed to remove metadata entry"),!0}}const x=t.MOJOUtils.escapeHtml,S={admin:{label:"Blocked",variant:"danger"},abuse:{label:"Banned",variant:"danger"},inactive:{label:"Auto-disabled",variant:"warning"},anonymized:{label:"Anonymized",variant:"secondary"},self:{label:"Self-deactivated",variant:"secondary"}};function C(e){return e?.get?.("metadata")?.protected?.disable||null}function A(e){return C(e)?.reason||null}const T={google:"bi-google",github:"bi-github",microsoft:"bi-microsoft",apple:"bi-apple",facebook:"bi-facebook",twitter:"bi-twitter-x",linkedin:"bi-linkedin"},M={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"},I={error:"bi-shield-x",critical:"bi-shield-x",warning:"bi-exclamation-triangle",warn:"bi-exclamation-triangle",info:"bi-pencil-square"},P={success_login:"success",success:"success",login:"success",failed_login:"danger",failure:"danger",failed:"danger",suspicious:"warning",mfa_required:"warning",mfa:"warning"};t.dataFormatter.formatters?.has?.("leveltone")||t.dataFormatter.register("levelTone",e=>M[String(e||"").toLowerCase()]||"secondary"),t.dataFormatter.formatters?.has?.("levelicon")||t.dataFormatter.register("levelIcon",e=>I[String(e||"").toLowerCase()]||"bi-circle"),t.dataFormatter.formatters?.has?.("logintone")||t.dataFormatter.register("loginTone",e=>P[String(e||"").toLowerCase()]||"secondary");class UserOverviewSection extends t.View{constructor(e={}){const{devicesCollection:t,pushDevicesCollection:i,membersCollection:s,loginsCollection:a,activityCollection:n,eventsCollection:o,objectLogsCollection:l,...r}=e;super({className:"user-overview-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Account snapshot</div>\n <div class="detail-kpi-grid">\n <div data-container="user-kpi-devices"></div>\n <div data-container="user-kpi-last-login"></div>\n <div data-container="user-kpi-sessions"></div>\n <div data-container="user-kpi-groups"></div>\n </div>\n\n <div class="detail-section-eyebrow">Identity</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display name</div>\n <div class="detail-flat-row-value">{{model.display_name|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email</div>\n <div class="detail-flat-row-value">\n {{#hasEmail|bool}}{{{model.email|clipboard}}}{{/hasEmail|bool}}\n {{^hasEmail|bool}}<span class="text-secondary">—</span>{{/hasEmail|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Phone</div>\n <div class="detail-flat-row-value">\n {{#hasPhone|bool}}<code>{{model.phone_number}}</code>{{/hasPhone|bool}}\n {{^hasPhone|bool}}<span class="text-secondary">—</span>{{/hasPhone|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Account type</div>\n <div class="detail-flat-row-value">{{accountType}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value"><code>{{model.date_joined|date|default:\'—\'}}</code></div>\n </div>\n\n <div class="detail-section-eyebrow">Recent activity</div>\n <div data-container="user-overview-activity"></div>\n ',...r}),this.devicesCollection=t,this.pushDevicesCollection=i,this.membersCollection=s,this.loginsCollection=a,this.activityCollection=n,this.eventsCollection=o,this.objectLogsCollection=l}get hasEmail(){return!!this.model?.get?.("email")}get hasPhone(){return!!this.model?.get?.("phone_number")}get accountType(){const e=this.model;return e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":"User"}async onInit(){this.kpiDevices=new n.MetricCard({containerId:"user-kpi-devices",label:"Devices",value:()=>String(this._deviceCount())}),this.kpiLastLogin=new n.MetricCard({containerId:"user-kpi-last-login",label:"Last login",value:()=>this._lastLoginLabel()}),this.kpiSessions=new n.MetricCard({containerId:"user-kpi-sessions",label:"Active sessions",value:()=>String(this._sessionCount()),tone:()=>this._sessionCount()>0?"success":"default"}),this.kpiGroups=new n.MetricCard({containerId:"user-kpi-groups",label:"Groups",value:()=>String(this._groupCount())}),[this.kpiDevices,this.kpiLastLogin,this.kpiSessions,this.kpiGroups].forEach(e=>this.addChild(e)),this.activityTimeline=new n.Timeline({containerId:"user-overview-activity",limit:5,emptyText:"No recent activity yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline)}async onAfterRender(){await super.onAfterRender(),[this.devicesCollection,this.pushDevicesCollection,this.membersCollection,this.loginsCollection,this.activityCollection,this.eventsCollection,this.objectLogsCollection].forEach(e=>{e&&!e._userOverviewWired&&(e.on("fetch:success",()=>{this.kpiDevices?.isMounted()&&this.kpiDevices.render().catch(()=>{}),this.kpiLastLogin?.isMounted()&&this.kpiLastLogin.render().catch(()=>{}),this.kpiSessions?.isMounted()&&this.kpiSessions.render().catch(()=>{}),this.kpiGroups?.isMounted()&&this.kpiGroups.render().catch(()=>{}),this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())},this),e._userOverviewWired=!0)})}_deviceCount(){return(this.devicesCollection?.meta?.count??this.devicesCollection?.models?.length??0)+(this.pushDevicesCollection?.meta?.count??this.pushDevicesCollection?.models?.length??0)}_sessionCount(){return this.devicesCollection?.meta?.count??this.devicesCollection?.models?.length??0}_lastLoginLabel(){const e=this.loginsCollection?.models?.[0]?.get?.("created")??this.model?.get?.("last_login");return e&&t.dataFormatter.apply("relative",e)||"—"}_groupCount(){return this.membersCollection?.meta?.count??this.membersCollection?.models?.length??0}_buildActivityItems(){const e=[],i=this.loginsCollection?.models?.slice(0,2)||[];for(const o of i){const i=o.get("ip_address"),s=[o.get("city"),o.get("country_code")].filter(Boolean).join(", "),a=[i?`<code>${x(String(i))}</code>`:"",s?`<span class="text-secondary">${x(s)}</span>`:""].filter(Boolean).join(" · ");e.push({_ts:L(o.get("created")),tone:"info",headline:"Logged in",detail:a,when:t.dataFormatter.apply("relative",o.get("created"))})}const s=this.eventsCollection?.models?.slice(0,2)||[];for(const o of s){const i=o.get("category");e.push({_ts:L(o.get("created")),tone:"danger",headline:o.get("title")||i||"Incident event",detail:i?`<span class="text-secondary">${x(String(i))}</span>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}const a=this.objectLogsCollection?.models?.slice(0,2)||[];for(const o of a){const i=o.get("log");e.push({_ts:L(o.get("created")),tone:M[(o.get("level")||"").toLowerCase()]||null,headline:o.get("kind")||"Change",detail:i?`<span class="text-secondary">${x(String(i).slice(0,80))}</span>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}const n=this.activityCollection?.models?.slice(0,1)||[];for(const o of n){const i=o.get("path");e.push({_ts:L(o.get("created")),tone:M[(o.get("level")||"").toLowerCase()]||null,headline:o.get("kind")||"Activity",detail:i?`<code class="small">${x(String(i))}</code>`:"",when:t.dataFormatter.apply("relative",o.get("created"))})}return e.filter(e=>null!=e._ts).sort((e,t)=>t._ts-e._ts).slice(0,5)}}class UserProfileSection extends t.View{constructor(e={}){super({className:"user-profile-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Personal</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Display name</div>\n <div class="detail-flat-row-value">{{model.display_name|default:\'—\'}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-display-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Identity</div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-secondary bg-opacity-10 text-secondary"><i class="bi bi-at"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Username</div>\n <div class="admin-security-desc"><code>{{model.username|default:\'—\'}}</code></div>\n </div>\n {{#isAdminCaller|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-username" title="Edit username"><i class="bi bi-pencil"></i></button>\n {{/isAdminCaller|bool}}\n {{^isAdminCaller|bool}}\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="send-magic-link"><i class="bi bi-link-45deg me-1"></i>Send magic link</button>\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-envelope"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">\n Email\n {{#model.is_email_verified|bool}}<span class="badge text-bg-success ms-2"><i class="bi bi-shield-check me-1"></i>verified</span>{{/model.is_email_verified|bool}}\n {{^model.is_email_verified|bool}}{{#hasEmail|bool}}<span class="badge text-bg-warning ms-2">unverified</span>{{/hasEmail|bool}}{{/model.is_email_verified|bool}}\n </div>\n <div class="admin-security-desc">\n {{#hasEmail|bool}}{{{model.email|clipboard}}}{{/hasEmail|bool}}\n {{^hasEmail|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEmail|bool}}\n </div>\n </div>\n {{#isAdminCaller|bool}}\n {{#hasEmail|bool}}\n {{#model.is_email_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="force-verify-email" title="Force verify"><i class="bi bi-patch-check"></i></button>\n {{/model.is_email_verified|bool}}\n {{/hasEmail|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="change-email" title="Edit email"><i class="bi bi-pencil"></i></button>\n {{/isAdminCaller|bool}}\n {{^isAdminCaller|bool}}\n {{#hasEmail|bool}}<button type="button" class="btn btn-sm btn-outline-secondary" data-action="send-magic-link"><i class="bi bi-link-45deg me-1"></i>Send magic link</button>{{/hasEmail|bool}}\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-info bg-opacity-10 text-info"><i class="bi bi-telephone"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">\n Phone\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}<span class="badge text-bg-success ms-2"><i class="bi bi-shield-check me-1"></i>verified</span>{{/model.is_phone_verified|bool}}\n {{^model.is_phone_verified|bool}}<span class="badge text-bg-warning ms-2">unverified</span>{{/model.is_phone_verified|bool}}\n {{/hasPhone|bool}}\n </div>\n <div class="admin-security-desc">\n {{#hasPhone|bool}}<code>{{model.phone_number}}</code>{{/hasPhone|bool}}\n {{^hasPhone|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasPhone|bool}}\n </div>\n </div>\n {{#isAdminCaller|bool}}\n {{#hasPhone|bool}}\n {{#model.is_phone_verified|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" 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="detail-section-action" data-bs-toggle="tooltip" data-action="remove-phone" title="Clear phone"><i class="bi bi-x-lg"></i></button>\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="change-phone" title="Edit phone"><i class="bi bi-pencil"></i></button>\n {{/hasPhone|bool}}\n {{^hasPhone|bool}}\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="set-phone" title="Set phone"><i class="bi bi-plus-lg"></i></button>\n {{/hasPhone|bool}}\n {{/isAdminCaller|bool}}\n </div>\n\n <div class="admin-security-item">\n <div class="admin-security-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-key"></i></div>\n <div class="admin-security-info">\n <div class="admin-security-title">Password</div>\n <div class="admin-security-desc">{{#hasEmail|bool}}Send a password reset link to {{model.email}}{{/hasEmail|bool}}{{^hasEmail|bool}}<span class="text-secondary fst-italic">No email on file</span>{{/hasEmail|bool}}</div>\n </div>\n {{#hasEmail|bool}}<button type="button" class="btn btn-sm btn-outline-secondary" data-action="reset-password"><i class="bi bi-envelope me-1"></i>Send Reset Link</button>{{/hasEmail|bool}}\n </div>\n\n <div class="detail-section-eyebrow">\n Account\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-account" title="Edit account"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Account type</div>\n <div class="detail-flat-row-value">{{accountType}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">MFA</div>\n <div class="detail-flat-row-value">\n {{#model.requires_mfa|bool}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-secondary">Not required</span>{{/model.requires_mfa|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value"><code>{{model.date_joined|date|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last login</div>\n <div class="detail-flat-row-value">{{model.last_login|relative|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last seen</div>\n <div class="detail-flat-row-value">{{model.last_activity|relative|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#hasTimezone|bool}}{{timezone}}{{/hasTimezone|bool}}\n {{^hasTimezone|bool}}<span class="text-secondary">—</span>{{/hasTimezone|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Linked accounts\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="manage-linked" title="Manage linked accounts"><i class="bi bi-pencil"></i></button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">SSO providers</div>\n <div class="detail-flat-row-value">{{{linkedProvidersHtml}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">2-factor</div>\n <div class="detail-flat-row-value">\n {{#model.requires_mfa|bool}}<span class="badge text-bg-success">Required</span>{{/model.requires_mfa|bool}}\n {{^model.requires_mfa|bool}}<span class="badge text-bg-secondary">Not required</span>{{/model.requires_mfa|bool}}\n <a href="#" class="small ms-2" data-action="manage-passkeys">Manage passkeys</a>\n </div>\n </div>\n ',...e}),this.connections=[]}async onBeforeRender(){try{const e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id}),i=e?.data?.results||e?.data||[];this.connections=Array.isArray(i)?i:[]}catch(e){this.connections=[]}}get accountType(){const e=this.model;return e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":"User"}get hasEmail(){return!!this.model?.get?.("email")}get hasPhone(){return!!this.model?.get?.("phone_number")}get hasTimezone(){return!!this.model?.get?.("metadata")?.timezone}get timezone(){return this.model?.get?.("metadata")?.timezone||""}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}get linkedProvidersHtml(){return this.connections.length?this.connections.map(e=>{const t=T[e.provider]||"bi-link-45deg",i=x(String(e.provider||"")),s=e.email?` · ${x(String(e.email))}`:"";return`<span class="badge text-bg-light border me-1"><i class="bi ${x(t)} me-1"></i>${i}${s}</span>`}).join(""):'<span class="text-secondary fst-italic">No linked accounts</span>'}}class UserPermissionsSection extends t.View{constructor(e={}){super({className:"user-permissions-section",template:'\n <div class="detail-section-eyebrow">Permissions</div>\n <p class="text-secondary small mb-3">Toggles autosave as soon as you flip them.</p>\n <div data-container="user-permissions-form"></div>\n ',...e})}async onInit(){const e=t.User._permSwitch,i=[{label:"Categories",fields:(t.User.CATEGORY_PERMISSIONS||[]).map(e)},...(t.User.GRANULAR_PERMISSION_TABS||[]).map(t=>({label:t.label,fields:(t.permissions||[]).map(e)}))];this.formView=new o.FormView({containerId:"user-permissions-form",fields:[{type:"tabset",tabs:i}],model:this.model,autosaveModelField:!0}),this.addChild(this.formView)}}class UserDevicesSection extends t.View{constructor(e={}){const{devicesCollection:t,pushDevicesCollection:i,...s}=e;super({className:"user-devices-section",template:'\n <div class="detail-section-eyebrow">Devices &amp; sessions</div>\n <div data-container="user-devices-tabs"></div>\n ',...s}),this.devicesCollection=t,this.pushDevicesCollection=i}async onInit(){this.browserList=new r.ListView({collection:this.devicesCollection,paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},searchable:!0,searchPlaceholder:"Search browser devices…",hideActivePillNames:["user"],onItemClick:e=>a.Modal.detail(new DeviceView({model:e})),emptyMessage:"No browser devices on file.",itemTemplate:'\n <div class="user-device-row" role="button">\n <div class="user-device-icon"><i class="bi bi-laptop"></i></div>\n <div class="user-device-info">\n <div class="user-device-label">{{model.device_info.user_agent.family|default:\'Unknown browser\'}} {{model.device_info.user_agent.major}} · {{model.device_info.os.family|default:\'Unknown OS\'}} {{model.device_info.os.major}}</div>\n <div class="user-device-meta">\n {{model.last_seen|relative|default:\'never\'}}\n {{#model.duid}} · <code>{{model.duid|truncate_middle(20)}}</code>{{/model.duid}}\n </div>\n </div>\n <span class="badge text-bg-info">Browser</span>\n </div>\n '}),this.browserList.onTabActivated=async()=>{await(this.devicesCollection?.fetch?.().catch(()=>{}))},this.pushList=new r.ListView({collection:this.pushDevicesCollection,paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},searchable:!0,searchPlaceholder:"Search push devices…",hideActivePillNames:["user"],onItemClick:e=>a.Modal.detail(new c.PushDeviceView({model:e})),emptyMessage:"No push devices on file.",itemTemplate:'\n <div class="user-device-row" role="button">\n <div class="user-device-icon"><i class="bi bi-bell"></i></div>\n <div class="user-device-info">\n <div class="user-device-label">{{model.device_info.device.family|default:\'Push device\'}}{{#model.device_info.os.family}} · {{model.device_info.os.family}} {{model.device_info.os.major}}{{/model.device_info.os.family}}</div>\n <div class="user-device-meta">\n {{model.last_seen|relative|default:\'never\'}}\n {{#model.duid}} · <code>{{model.duid|truncate_middle(20)}}</code>{{/model.duid}}\n </div>\n </div>\n <span class="badge text-bg-primary"><i class="bi bi-bell me-1"></i>Push</span>\n </div>\n '}),this.pushList.onTabActivated=async()=>{await(this.pushDevicesCollection?.fetch?.().catch(()=>{}))},this.tabView=new o.TabView({containerId:"user-devices-tabs",tabs:{Browser:this.browserList,Push:this.pushList},activeTab:"Browser"}),this.addChild(this.tabView)}}class UserAuditSection extends t.View{constructor(e={}){const{eventsCollection:t,activityCollection:i,objectLogsCollection:s,...a}=e;super({className:"user-audit-section",template:'\n {{#hasDisableHistory|bool}}\n <div class="detail-section-eyebrow">Disable history</div>\n <div class="user-disable-history accordion mb-3" id="user-disable-history">\n {{#disableHistory}}\n <div class="accordion-item">\n <h2 class="accordion-header">\n <button class="accordion-button collapsed" type="button"\n data-bs-toggle="collapse" data-bs-target="#disable-history-{{.idx}}">\n <span class="badge text-bg-{{.tone}} me-2">{{.label}}</span>\n <span class="text-secondary me-2">{{.atRel}}</span>\n {{#.byUsername|bool}}<span class="me-2">by <code>{{.byUsername}}</code></span>{{/.byUsername|bool}}\n {{#.reactivated|bool}}<span class="ms-auto badge text-bg-light border">Reactivated</span>{{/.reactivated|bool}}\n </button>\n </h2>\n <div id="disable-history-{{.idx}}" class="accordion-collapse collapse" data-bs-parent="#user-disable-history">\n <div class="accordion-body small">\n <div><strong>Disabled:</strong> {{.atFmt}}</div>\n {{#.note|bool}}<div class="mt-1"><strong>Note:</strong> {{.note}}</div>{{/.note|bool}}\n {{#.reactivated|bool}}\n <div class="mt-2 pt-2 border-top">\n <div><strong>Reactivated:</strong> {{.reactivatedAtFmt}}{{#.reactivatedBy|bool}} by <code>{{.reactivatedBy}}</code>{{/.reactivatedBy|bool}}</div>\n {{#.reactivatedNote|bool}}<div class="mt-1"><strong>Note:</strong> {{.reactivatedNote}}</div>{{/.reactivatedNote|bool}}\n </div>\n {{/.reactivated|bool}}\n </div>\n </div>\n </div>\n {{/disableHistory}}\n </div>\n {{/hasDisableHistory|bool}}\n <div class="detail-section-eyebrow">Audit</div>\n <div data-container="user-audit-tabs"></div>\n ',...a}),this.eventsCollection=t,this.activityCollection=i,this.objectLogsCollection=s}get hasDisableHistory(){return Array.isArray(C(this.model)?.history)&&C(this.model).history.length>0}get disableHistory(){return(C(this.model)?.history||[]).map((e,i)=>{const s=S[e?.reason]||{label:"Inactive",variant:"secondary"},a=e?.at,n=e?.reactivated_at;return{idx:i,label:s.label,tone:s.variant,atFmt:a?t.dataFormatter.apply("datetime",a)||a:"",atRel:a&&t.dataFormatter.apply("relative",a)||"",byUsername:e?.by_username||"",note:e?.note||"",reactivated:!!n,reactivatedAtFmt:n?t.dataFormatter.apply("datetime",n)||n:"",reactivatedBy:e?.reactivated_by_username||"",reactivatedNote:e?.reactivated_note||""}})}async onInit(){this.activityTable=new r.ListView({collection:this.activityCollection,searchable:!0,searchPlaceholder:"Search activity…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["uid"],emptyMessage:"No activity recorded yet.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-{{model.level|levelTone}}">\n <div class="user-audit-icon"><i class="bi {{model.level|levelIcon}}"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.kind}}{{model.kind}}{{/model.kind}}{{^model.kind}}{{model.level|default:\'event\'}}{{/model.kind}}</div>\n <div class="user-audit-detail">{{model.log|default:\'(no message)\'}}</div>\n {{#model.path}}<div class="user-audit-path font-monospace">{{model.path}}</div>{{/model.path}}\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.activityTable.onTabActivated=async()=>{await(this.activityCollection?.fetch?.().catch(()=>{}))},this.incidentsTable=new r.ListView({collection:this.eventsCollection,searchable:!0,searchPlaceholder:"Search events…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["model_id","model_name"],emptyMessage:"No events for this user.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-info">\n <div class="user-audit-icon"><i class="bi bi-shield-exclamation"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.title}}{{model.title}}{{/model.title}}{{^model.title}}{{model.category|default:\'event\'}}{{/model.title}}</div>\n {{#model.description}}<div class="user-audit-detail">{{model.description}}</div>{{/model.description}}\n {{#model.category}}<div class="user-audit-meta"><span class="badge text-bg-secondary">{{model.category}}</span></div>{{/model.category}}\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.incidentsTable.onTabActivated=async()=>{await(this.eventsCollection?.fetch?.().catch(()=>{}))},this.objectTable=new r.ListView({collection:this.objectLogsCollection,searchable:!0,searchPlaceholder:"Search audit log…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},permissions:"view_logs",hideActivePillNames:["model_id","model_name"],emptyMessage:"No record changes logged.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-audit-row user-audit-row-{{model.level|levelTone}}">\n <div class="user-audit-icon"><i class="bi {{model.level|levelIcon}}"></i></div>\n <div class="user-audit-body">\n <div class="user-audit-title">{{#model.kind}}{{model.kind}}{{/model.kind}}{{^model.kind}}{{model.level|default:\'event\'}}{{/model.kind}}</div>\n <div class="user-audit-detail">{{model.log|default:\'(no message)\'}}</div>\n </div>\n <div class="user-audit-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '}),this.objectTable.onTabActivated=async()=>{await(this.objectLogsCollection?.fetch?.().catch(()=>{}))},this.tabView=new o.TabView({containerId:"user-audit-tabs",tabs:{Activity:this.activityTable,Events:this.incidentsTable,"Audit Log":this.objectTable},activeTab:"Activity"}),this.addChild(this.tabView)}}class UserApiKeysSection extends t.View{constructor(e={}){super({className:"user-api-keys-section",template:'\n <div class="detail-section-eyebrow">API Keys</div>\n <div data-container="user-api-keys-token"></div>\n <div data-container="user-api-keys-table"></div>\n ',...e}),this.apiKeys=[],this.generatedToken=null}async onInit(){this.tokenView=new t.View({containerId:"user-api-keys-token",template:'\n {{#hasToken|bool}}\n <div class="alert alert-success">\n <div class="fw-semibold mb-2">Generated API Key</div>\n <div class="d-flex gap-2 align-items-center">\n <code class="flex-grow-1">{{token}}</code>\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="copy-token"><i class="bi bi-clipboard"></i></button>\n </div>\n <div class="small mt-2 text-danger fw-semibold"><i class="bi bi-exclamation-circle me-1"></i>This token will not be shown again. Copy it now.</div>\n </div>\n {{/hasToken|bool}}\n '}),Object.defineProperty(this.tokenView,"token",{get:()=>this.generatedToken||""}),Object.defineProperty(this.tokenView,"hasToken",{get:()=>!!this.generatedToken}),this.tokenView.onActionCopyToken=async()=>this.onActionCopyToken(),this.addChild(this.tokenView),this.tableView=new l.TableView({containerId:"user-api-keys-table",collection:this.apiKeys,showAdd:!1,showExport:!1,showFullscreen:!1,searchable:!1,paginated:!1,sortable:!0,emptyMessage:"No API keys for this user.",columns:[{key:"name",label:"Key",sortable:!0,template:'\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-key text-secondary"></i>\n <div class="min-w-0">\n <div class="fw-semibold small">{{model.name|default:\'API Key\'}}</div>\n <div class="text-secondary small"><code>{{tokenPreview}}</code></div>\n </div>\n </div>\n '},{key:"is_active",label:"Status",width:"100px",template:'\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n '},{key:"created",label:"Created",formatter:"date",sortable:!0,width:"120px"},{key:"expires",label:"Expires",formatter:"default('Never')",width:"120px"},{key:"last_used",label:"Last used",formatter:"relative",width:"140px"},{key:"allowed_ips",label:"Allowed IPs",template:'{{#hasIps|bool}}{{ipsLabel}}{{/hasIps|bool}}{{^hasIps|bool}}<span class="text-secondary">Any</span>{{/hasIps|bool}}'}],actions:["delete"],onItemDelete:async e=>this._revokeKey(e),toolbarButtons:[{label:"Generate Key",icon:"bi bi-plus-lg",variant:"primary",handler:()=>this.onActionGenerateKey()}]}),this.addChild(this.tableView)}async onAfterRender(){await super.onAfterRender(),this._loadedOnce||(this._loadedOnce=!0,this._loadKeys().catch(()=>{}))}async _loadKeys(){try{const e=await t.rest.GET("/api/account/api_keys",{user:this.model.id},{},{dataOnly:!0}),i=e.success&&Array.isArray(e.data)?e.data:[];this.apiKeys=i.map(e=>this._decorate(e)),this.emit("count:changed",this.apiKeys.length),this.tableView&&(this.tableView.collection=this.apiKeys,this.tableView.isMounted()&&this.tableView.render().catch(()=>{}))}catch(e){this.apiKeys=[],this.emit("count:changed",0)}}_decorate(e){const t=Array.isArray(e.allowed_ips)?e.allowed_ips:[];return{...e,tokenPreview:e.token_prefix?`${e.token_prefix}…`:"••••••••",hasIps:t.length>0,ipsLabel:t.length?t.join(", "):""}}async _revokeKey(e){const i=e?.get?.("id")??e?.id;if(!i)return;if(!(await a.Modal.confirm("Revoke this API key? Any applications using it will lose access immediately.","Revoke API Key")))return;const s=await t.rest.DELETE(`/api/account/api_keys/${i}`,{},{},{dataOnly:!0});s.success?(this.getApp()?.toast?.success("API key revoked"),this.generatedToken=null,await this._refreshTokenView(),await this._loadKeys()):this.getApp()?.toast?.error(s.message||"Failed to revoke API key")}async onActionGenerateKey(){const e=await a.Modal.form({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",required:!0,placeholder:"e.g., CI/CD Pipeline, Mobile App",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)},s=(e.allowed_ips||"").trim();s&&(i.allowed_ips=s.split(",").map(e=>e.trim()).filter(Boolean));const n=await t.rest.POST("/api/auth/manage/generate_api_key",i,{},{dataOnly:!0});return n.success&&n.data?.token?(this.generatedToken=n.data.token,this.getApp()?.toast?.success("API key generated"),await this._refreshTokenView(),await this._loadKeys()):this.getApp()?.toast?.error(n.message||"Failed to generate API key"),!0}async onActionCopyToken(){if(!this.generatedToken)return!0;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}async _refreshTokenView(){this.tokenView?.isMounted()&&await this.tokenView.render()}}class UserView extends n.DetailView{constructor(e={}){const i=e.model||new t.User(e.data||{}),s=i.get("id"),a=new t.UserDeviceList({params:{user:s,size:25}}),d=new c.PushDeviceList({params:{user:s,size:25}}),h=new l.MemberList({params:{user:s,size:10}}),u=new c.LoginEventList({params:{user:s,size:10}}),m=new c.IncidentEventList({params:{size:25,model_name:"account.User",model_id:s,sort:"-created"}}),p=new l.LogList({params:{size:25,uid:s,sort:"-created"}}),b=new l.LogList({params:{size:25,model_name:"account.User",model_id:s,sort:"-created"}}),g=new UserOverviewSection({model:i,devicesCollection:a,pushDevicesCollection:d,membersCollection:h,loginsCollection:u,activityCollection:p,eventsCollection:m,objectLogsCollection:b}),v=new UserProfileSection({model:i}),y=new UserPermissionsSection({model:i}),w=new UserApiKeysSection({model:i}),f=new r.ListView({collection:h,title:"Groups",searchable:!0,searchPlaceholder:"Search groups…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",hideActivePillNames:["user"],viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},emptyMessage:"This user has no group memberships.",itemTemplate:'\n <div class="user-feed-row" role="button">\n <div class="user-feed-meta">\n <strong>{{model.group.name|default:\'—\'}}</strong>\n {{#model.group.kind}}<span class="badge text-bg-secondary">{{model.group.kind}}</span>{{/model.group.kind}}\n <span class="ms-auto text-secondary small">Joined {{model.created|date|default:\'—\'}}</span>\n </div>\n {{#model.permissions|keys}}\n <div class="user-feed-body small text-secondary">\n {{#model.permissions|keys}}<span class="badge text-bg-light border me-1">{{.}}</span>{{/model.permissions|keys}}\n </div>\n {{/model.permissions|keys}}\n </div>\n '}),_=new UserDevicesSection({model:i,devicesCollection:a,pushDevicesCollection:d}),k=new LoginLocationMapView({userId:s,height:300,mapStyle:"dark",viewMode:"list",listZoom:2.3}),T=new r.ListView({collection:u,searchable:!0,searchPlaceholder:"Search logins…",paginated:!0,paginationMode:"pages",pageSize:5,clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},hideActivePillNames:["user"],emptyMessage:"No login events on file.",...n.groupByDay("created"),itemTemplate:'\n <div class="user-login-row">\n <span class="user-login-dot user-login-dot-{{model.event_type|loginTone}}"></span>\n <div class="user-login-body">\n <div class="user-login-title">{{#model.city}}{{model.city}}{{/model.city}}{{^model.city}}—{{/model.city}}{{#model.region}}, {{model.region}}{{/model.region}}{{#model.country_code}} · {{model.country_code}}{{/model.country_code}}</div>\n <div class="user-login-meta small text-secondary">\n <code>{{model.ip_address}}</code>{{#model.source}} · {{model.source}}{{/model.source}}\n </div>\n </div>\n <div class="user-login-time" title="{{model.created|datetime}}">{{model.created|relative}}</div>\n </div>\n '});T.onTabActivated=async()=>{await(T.collection?.fetch())};const M=new o.TabView({tabs:{Map:k,Logins:T},activeTab:"Map"}),I=new UserAuditSection({model:i,eventsCollection:m,activityCollection:p,objectLogsCollection:b}),P=new AdminNotificationsSection({model:i}),L=new AdminPersonalSection({model:i}),E=new AdminSecuritySection({model:i}),D=new AdminConnectedSection({model:i}),V=new AdminMetadataSection({model:i}),$=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:g},{key:"Profile",label:"Profile",icon:"bi-person",view:v},{key:"Personal",label:"Personal",icon:"bi-card-text",view:L},{key:"Security",label:"Security",icon:"bi-shield-lock",view:E},{key:"OAuth",label:"OAuth",icon:"bi-link-45deg",view:D},{type:"divider",label:"Access"},{key:"Groups",label:"Groups",icon:"bi-people",view:f},{key:"Permissions",label:"Permissions",icon:"bi-shield-check",view:y,permissions:["users","manage_users"]},{key:"ApiKeys",label:"API Keys",icon:"bi-key",view:w},{type:"divider",label:"Activity"},{key:"Devices",label:"Devices",icon:"bi-laptop",view:_},{key:"Logins",label:"Logins",icon:"bi-geo-alt",view:M},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:I,permissions:"view_logs"},{type:"divider",label:"Settings"},{key:"Notifications",label:"Notifications",icon:"bi-bell",view:P},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:V}],R=["users","manage_users"],N=[{label:"Edit User",action:"edit-user",icon:"bi-pencil",permissions:R},{label:"Change Avatar",action:"change-avatar",icon:"bi-image",permissions:R},{label:"Clear Avatar",action:"clear-avatar",icon:"bi-person-x",permissions:R},{type:"divider"},{label:"Change Password",action:"change-password",icon:"bi-key",permissions:R},{label:"Send Password Reset",action:"reset-password",icon:"bi-envelope"},{label:"Send Magic Login Link",action:"send-magic-link",icon:"bi-link-45deg"},{type:"divider"},{label:"Clear Rate Limit",action:"clear-rate-limit",icon:"bi-shield-slash",permissions:R},{label:"Revoke All Sessions",action:"revoke-all-sessions",icon:"bi-box-arrow-right",permissions:R}];super({className:"user-view",...e,model:i,header:{icon:"bi-person-circle",iconToneFn:e=>e.get("is_active")?e.get("is_superuser")?"danger":e.get("is_staff")?"info":"primary":null,iconHtml:e=>{const i=e.get("avatar");return i&&i.url?`<button type="button" class="dh-icon-action" data-action="change-avatar" data-bs-toggle="tooltip" title="Change avatar">${t.dataFormatter.apply("avatar",i)}</button>`:null},titleField:"display_name",titleFn:e=>e.get("display_name")||e.get("username")||e.get("email")||(null!=e.get("id")?`User #${e.get("id")}`:"Loading user…"),subtitlePath:"_subtitle",subtitlePlaceholder:"No contact info on file",chips:[{text:e=>e.get("is_superuser")?"Superuser":e.get("is_staff")?"Staff":null,variant:"info",when:e=>e.get("is_staff")||e.get("is_superuser")},{icon:"bi-shield-check",text:"Email verified",variant:"light",when:e=>!!e.get("is_email_verified")},{icon:"bi-shield-check",text:"Phone verified",variant:"light",when:e=>!!e.get("is_phone_verified")&&!!e.get("phone_number")},{text:"2FA enabled",variant:"light",when:e=>!!e.get("requires_mfa")},{icon:"bi-buildings",text:e=>e.get("org")?.name||null,variant:"light",tooltip:"Open organization",action:"view-org",when:e=>!!e.get("org")?.id}],actions:[],auxFn:e=>function(e,i,s){const a=function(e){const t=e?.get?.("last_activity");if(null==t)return!1;const i="number"==typeof t&&t<1e11?1e3*t:new Date(t).getTime();return!!Number.isFinite(i)&&Date.now()-i<3e5}(e),n=e.get("last_activity")||e.get("last_login"),o=n&&t.dataFormatter.apply("relative",n)||"",l=a?"Online":o?"Offline":"No activity",r=o?a?`active ${o}`:`last active ${o}`:"",c=a?" is-online":"",d=!!e.get("is_active"),h=function(e){return"anonymized"===A(e)}(e),u=d?null:function(e){return e?.get?.("is_active")?{label:"Active",variant:"success"}:S[A(e)]||{label:"Inactive",variant:"secondary"}}(e),m=u?`<span class="badge text-bg-${u.variant}">${x(u.label)}</span>`:"",p=Number(s?.retry_after_seconds),b=Number.isFinite(p)&&p>0?Math.floor(p):0,g=b>0?`<span class="badge text-bg-danger" title="Login attempts throttled. Use Clear Rate Limit in the kebab menu to reset."><i class="bi bi-clock-history me-1"></i>Login locked ${x(String(b))}s</span>`:"",v=h||!i?"":`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${d?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${d?"Active":"Inactive"}</span>\n </label>\n `,y=function(e){if(!e?.get?.("is_active"))return null;const t=C(e)?.warning;return t?.sent_at?{sent_at:t.sent_at,days:t.days_until_disable_at_send}:null}(e),w=y?`\n <div class="dh-aux-warning">\n <i class="bi bi-exclamation-triangle"></i>\n <span>Inactivity warning sent — ${x(String(y.days||"?"))} days until auto-disable</span>\n <a href="#" data-action="reset-inactivity">Reset</a>\n </div>\n `:"";return`\n <div class="dh-aux-top">\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${c}"></span>\n <span>${x(l)}</span>\n </span>\n ${m}\n ${g}\n ${v}\n </div>\n ${r?`<span class="dh-aux-meta">${x(r)}</span>`:""}\n ${w}\n `}(e,this.isAdminCaller,this.throttle),contextMenu:{items:N}},sections:$,activeSection:"Overview"}),this.devicesCollection=a,this.pushDevicesCollection=d,this.membersCollection=h,this.loginsCollection=u,this.eventsCollection=m,this.activityCollection=p,this.objectLogsCollection=b,this.overviewSection=g,this.profileSection=v,this.personalSection=L,this.securitySection=E,this.connectedSection=D,this.permissionsSection=y,this.apiKeysSection=w,this.groupsSection=f,this.devicesSection=_,this.locationsSection=M,this.auditSection=I,this.notificationsSection=P,this.metadataSection=V,this.throttle=null,this._refreshComputedFields()}async onAfterBuild(){this.apiKeysSection.on("count:changed",e=>{this.setBadge("ApiKeys",e>0?{text:String(e),variant:"muted"}:null)});const e=e=>e?.meta?.count??e?.models?.length??0,t=()=>{const t=e(this.devicesCollection)+e(this.pushDevicesCollection);this.setBadge("Devices",t>0?{text:String(t),variant:"muted"}:null)},i=()=>{const t=e(this.eventsCollection)+e(this.activityCollection)+e(this.objectLogsCollection);this.setBadge("Audit",t>0?{text:String(t),variant:"muted"}:null)};this.membersCollection.on("fetch:success",()=>{const t=e(this.membersCollection);this.setBadge("Groups",t>0?{text:String(t),variant:"muted"}:null)},this),this.devicesCollection.on("fetch:success",t,this),this.pushDevicesCollection.on("fetch:success",t,this),this.eventsCollection.on("fetch:success",i,this),this.activityCollection.on("fetch:success",i,this),this.objectLogsCollection.on("fetch:success",i,this),this.loginsCollection.on("fetch:success",()=>{this._refreshComputedFields(),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})},this),this.devicesCollection.fetch().catch(()=>{}),this.pushDevicesCollection.fetch().catch(()=>{}),this.membersCollection.fetch().catch(()=>{}),this.loginsCollection.fetch().catch(()=>{}),this.eventsCollection.fetch().catch(()=>{}),this.activityCollection.fetch().catch(()=>{}),this.objectLogsCollection.fetch().catch(()=>{}),this.isAdminCaller&&this._refreshThrottle().catch(()=>{})}async _refreshThrottle(){try{const e=await t.rest.GET("/api/auth/manage/throttle",{user_id:this.model.id,key:"login"});this.throttle=e?.success&&e.data?e.data.data||e.data:null}catch{this.throttle=null}this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["users","manage_users"]))}async _setVerification(e,t,i){const s=t?"Mark as verified":"Mark as unverified";if(await a.Modal.confirm(`${s} <strong>${x(i.toLowerCase())}</strong> for this user?`,`${s} ${i}`))try{const s=await this.model.save({[e]:t});if(s?.status>=400)throw new Error("Save failed");this.model.set(e,t),this.getApp()?.toast?.success(`${i} ${t?"marked verified":"marked unverified"}`),this.profileSection?.isMounted()&&this.profileSection.render().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}catch(n){this.getApp()?.toast?.error(`Failed to update: ${n.message}`)}}_refreshComputedFields(){const e=this.model,t=[];e.get("email")&&t.push(e.get("email")),e.get("phone_number")&&t.push(e.get("phone_number")),e.attributes._subtitle=t.join(" · ")}async onActionSendMagicLink(){const e=this.model.get("email");if(!e)return this.getApp()?.toast?.error("User has no email on file"),!0;if(!(await a.Modal.confirm(`Send a magic login link to <strong>${x(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 onActionResetPassword(){const e=this.model.get("email");if(!e)return this.getApp()?.toast?.error("User has no email on file"),!0;if(!(await a.Modal.confirm(`Send a password reset email to <strong>${x(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 onActionEditDisplayName(){const e=await a.Modal.prompt("Display name:","Edit Display Name",{defaultValue:this.model.get("display_name")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({display_name:e.trim()},"Display name"),!0)}async onActionEditUsername(){const e=await a.Modal.prompt("Username:","Edit Username",{defaultValue:this.model.get("username")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({username:e.trim()},"Username"),!0)}async onActionChangeEmail(){const e=await a.Modal.prompt("Email address:","Change Email",{defaultValue:this.model.get("email")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({email:e.trim()},"Email"),!0)}async onActionChangePhone(){const e=await a.Modal.prompt("Phone number:","Change Phone",{defaultValue:this.model.get("phone_number")||""});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({phone_number:e.trim()},"Phone number"),!0)}async onActionSetPhone(){const e=await a.Modal.prompt("Phone number:","Set Phone",{placeholder:"+1 555 123 4567"});return"string"!=typeof e||!e.trim()||(await this._savePersonalField({phone_number:e.trim()},"Phone number"),!0)}async onActionRemovePhone(){return!(await a.Modal.confirm("Clear this user's phone number?","Clear Phone"))||(await this._savePersonalField({phone_number:null},"Phone number"),!0)}async _savePersonalField(e,t){const i=await this.model.save(e);200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this._fullRefresh()):this.getApp()?.toast?.error(i.message||`Failed to update ${t.toLowerCase()}`)}async onActionEditAccount(){return await a.Modal.modelForm({title:"Edit account",model:this.model,size:"md",formConfig:{fields:[{name:"is_active",type:"switch",label:"Active",columns:6},{name:"is_staff",type:"switch",label:"Staff",columns:6},{name:"requires_mfa",type:"switch",label:"Requires MFA",columns:6},{name:"metadata.timezone",type:"text",label:"Timezone",columns:12,tooltip:"IANA timezone, e.g. America/Los_Angeles"}]}})&&await this._fullRefresh(),!0}async onActionManageLinked(){let e;try{e=await t.rest.GET("/api/account/oauth_connection",{user:this.model.id})}catch{e=null}const i=e?.data?.results||e?.data||[],s=Array.isArray(i)?i:[],n=new t.View({template:()=>s.length?s.map(e=>{const i=T[e.provider]||"bi-link-45deg",s=e.created&&t.dataFormatter.apply("relative",e.created)||"";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi ${x(i)} fs-5"></i></div>\n <div class="detail-flat-row-value">\n <div class="fw-semibold small text-capitalize">${x(e.provider||"")}</div>\n <div class="text-secondary small">${x(e.email||"")}${s?` · Connected ${x(s)}`:""}</div>\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="btn btn-sm btn-outline-danger" data-action="unlink" data-id="${x(e.id)}"><i class="bi bi-x-lg me-1"></i>Unlink</button>\n </div>\n </div>\n `}).join(""):'<div class="text-center text-secondary py-3"><i class="bi bi-plug fs-3 d-block mb-2"></i>No connected accounts</div>'});return n.onActionUnlink=async(e,i)=>{const n=i.dataset.id,o=s.find(e=>String(e.id)===String(n)),l=o?.provider||"this account";if(!(await a.Modal.confirm(`Unlink ${l} for this user?`,"Unlink Account")))return!0;const r=await t.rest.DELETE(`/api/account/oauth_connection/${n}`);return r.success?(this.getApp()?.toast?.success(`${l} account unlinked`),this.profileSection?.isMounted()&&await this.profileSection.render()):this.getApp()?.toast?.error(r.message||"Failed to unlink account"),!0},await a.Modal.dialog({title:"Linked accounts",body:n,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionManagePasskeys(){const e=new l.PasskeyList({params:{user:this.model.id}});try{await e.fetch()}catch{}const i=e.models||[],s=new t.View({template:()=>i.length?i.map(e=>{const i=e.toJSON?e.toJSON():e,s=i.created&&t.dataFormatter.apply("date",i.created)||"—",a=i.last_used&&t.dataFormatter.apply("relative",i.last_used)||"never";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-fingerprint fs-5 text-primary"></i></div>\n <div class="detail-flat-row-value">\n <div class="fw-semibold small">${x(i.friendly_name||"Unnamed Passkey")}</div>\n <div class="text-secondary small">Created ${x(s)} · Last used ${x(a)} · ${x(String(i.sign_count||0))} uses</div>\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="edit-passkey" data-id="${x(i.id)}"><i class="bi bi-pencil"></i></button>\n <button type="button" class="btn btn-sm btn-outline-danger" data-action="delete-passkey" data-id="${x(i.id)}"><i class="bi bi-trash"></i></button>\n </div>\n </div>\n `}).join(""):'<div class="text-center text-secondary py-3"><i class="bi bi-fingerprint fs-3 d-block mb-2"></i>No passkeys registered</div>'});return s.onActionEditPasskey=async(e,t)=>{const s=t.dataset.id,n=i.find(e=>String(e.id)===String(s));return n&&await a.Modal.modelForm({title:"Edit Passkey",model:n,fields:l.PasskeyForms.edit.fields,size:"sm"}),!0},s.onActionDeletePasskey=async(e,t)=>{const s=t.dataset.id;if(await a.Modal.confirm("Delete this passkey?","Delete Passkey")){const e=i.find(e=>String(e.id)===String(s));e&&(await e.destroy(),this.getApp()?.toast?.success("Passkey deleted"))}return!0},await a.Modal.dialog({title:"Passkeys",body:s,size:"md",buttons:[{text:"Close",class:"btn-outline-secondary",dismiss:!0}]}),!0}async onActionEditUser(){return await a.Modal.modelForm({title:"Edit User",model:this.model,size:"md",formConfig:t.User.EDIT_FORM})&&await this._fullRefresh(),!0}async onActionToggleActive(e,i){const s=!!i.checked;i.disabled=!0;try{if(s){const e=await t.rest.POST(`/api/user/${this.model.id}`,{reactivate:{}});if(!e.success||e.status>=400)throw new Error(e.message||"Reactivate failed");e.data?.data?this.model.set(e.data.data):this.model.set("is_active",!0),this.getApp()?.toast?.success("User reactivated")}else{const e=await a.Modal.form({title:"Disable User",size:"sm",submitText:"Disable",fields:[{name:"reason",type:"select",label:"Reason",cols:12,help:'Optional. Defaults to "admin" (manual block) if left blank.',options:[{value:"",text:"(let backend default)"},{value:"admin",text:"Admin — block / policy violation"},{value:"abuse",text:"Abuse — banned"},{value:"inactive",text:"Inactive — idle account"}]},{name:"note",type:"textarea",label:"Note",cols:12,rows:3,placeholder:"Optional note about why this user is being disabled."}]});if(null===e||!1===e||0===e)return i.checked=!0,!0;const s={};e.reason&&(s.reason=e.reason),e.note&&(s.note=e.note);const n=await t.rest.POST(`/api/user/${this.model.id}`,{disable:s});if(!n.success||n.status>=400)throw new Error(n.message||"Disable failed");n.data?.data?this.model.set(n.data.data):this.model.set("is_active",!1),this.getApp()?.toast?.success("User disabled")}}catch(n){i.checked=!s,this.getApp()?.toast?.error(n.message||"Action failed")}finally{i&&i.isConnected&&(i.disabled=!1)}return!0}async onActionResetInactivity(e){e?.preventDefault?.();try{const e=await t.rest.POST(`/api/user/${this.model.id}`,{reactivate:{note:"Inactivity warning reset"}});if(!e.success||e.status>=400)throw new Error(e.message||"Reset failed");e.data?.data&&this.model.set(e.data.data),this.getApp()?.toast?.success("Inactivity warning cleared")}catch(i){this.getApp()?.toast?.error(i.message||"Failed to reset")}return!0}async onActionForceVerifyEmail(){return!(await a.Modal.confirm(`Mark <strong>${x(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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to verify email"),!0)}async onActionForceVerifyPhone(){return this.model.get("phone_number")?!(await a.Modal.confirm(`Mark <strong>${x(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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to verify phone"),!0):(this.getApp()?.toast?.error("User has no phone number"),!0)}async onActionUnverifyEmail(){return this._setVerification("is_email_verified",!1,"Email")}async onActionUnverifyPhone(){return this._setVerification("is_phone_verified",!1,"Phone")}async onActionRevokeAllSessions(){if(!(await a.Modal.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 onActionClearRateLimit(){if(!this.throttle?.retry_after_seconds)return this.getApp()?.toast?.info("No active rate limit to clear"),!0;if(!(await a.Modal.confirm("Clear the login rate-limit on this user? They will be able to attempt sign-in immediately.","Clear Rate Limit")))return!0;const e=await t.rest.POST("/api/auth/manage/clear_rate_limit",{key:"login",user_id:this.model.id});return e.success?(this.getApp()?.toast?.success("Rate limit cleared"),await this._refreshThrottle()):this.getApp()?.toast?.error(e.message||"Failed to clear rate limit"),!0}async onActionChangePassword(){const e=this.getApp(),t=await a.Modal.form({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({new_password:t.password});return 200===i.status?e?.toast?.success("Password updated"):e?.toast?.error(i.message||"Failed to set password"),!0}async onActionViewOrg(){const e=this.model.get("org");if(!e?.id)return!0;const i=new t.Group({id:e.id});try{await i.fetch()}catch{}if(!i.id)return this.getApp()?.toast?.error("Organization not found"),!0;const s=t.Group.VIEW_CLASS;return s?(await a.Modal.detail(new s({model:i})),!0):(this.getApp()?.toast?.error("GroupView not registered"),!0)}async onActionImpersonate(){if(!(await a.Modal.confirm(`Sign in as <strong>${x(this.model.get("display_name")||this.model.get("email")||"this user")}</strong>?`,"Impersonate")))return!0;const e=await t.rest.POST("/api/auth/impersonate",{user:this.model.id});return e.success?(this.getApp()?.toast?.success("Impersonation started"),window.location.reload()):this.getApp()?.toast?.error(e.message||"Failed to impersonate"),!0}async onActionChangeAvatar(){const e=await a.Modal.updateModelImage({model:this.model,field:"avatar",title:"Change Avatar",upload:!0},{name:"avatar",size:"lg",imageSize:{width:200,height:200},placeholder:"Upload an avatar image"});return e&&200===e.status&&(this.getApp()?.toast?.success("Avatar updated"),await this._fullRefresh()),!0}async onActionClearAvatar(){return!(await a.Modal.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"),await this._fullRefresh()):this.getApp()?.toast?.error("Failed to clear avatar"),!0)}async onActionDeleteUser(){const e=this.model.get("display_name")||this.model.get("email")||`User #${this.model.id}`;if(!(await a.Modal.confirm({title:"Delete User",message:`Are you sure you want to delete <strong>${x(e)}</strong>? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"})))return!0;try{await this.model.destroy(),this.getApp()?.toast?.success("User deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("user:deleted",{model:this.model})}catch(t){this.getApp()?.toast?.error(`Failed to delete: ${t.message}`)}return!0}async _fullRefresh(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.profileSection?.isMounted()&&await this.profileSection.render()}async showTab(e){return this.showSection(e)}getActiveTab(){return this.sideNav?.getActiveSection?.()??null}static create(e={}){return new UserView(e)}}function L(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}UserView.VIEW_CLASS=UserView,t.User.VIEW_CLASS=UserView,t.User.MODEL_REF="account.User";const E=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,UserApiKeysSection:UserApiKeysSection,UserAuditSection:UserAuditSection,UserDevicesSection:UserDevicesSection,UserOverviewSection:UserOverviewSection,UserPermissionsSection:UserPermissionsSection,UserProfileSection:UserProfileSection,UserView:UserView,default:UserView},Symbol.toStringTag,{value:"Module"}));class UserTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_users",pageName:"Manage Users",router:"admin/users",Collection:t.UserList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-last_activity",is_active:"true"},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:"xxl",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,searchPlaceholder:"Search name, email, or username",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,i){e.preventDefault();const s=this.collection.get(i.dataset.id);await a.Modal.modelForm({model:s,size:"lg",title:`Edit Permissions for "${s._.username}"`,fields:t.UserForms.permissions.fields})}async onActionChangePassword(e,i){const s=this.collection.get(i.dataset.id),n=await a.Modal.form({title:`Change Password for "${s._.username}"`,fields:[{type:"text",name:"username",value:s.get("email")||s.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 a=await s.save({new_password:n.new_password});this.onPasswordChange(a)||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 i=this.collection.get(t.dataset.id),s=await i.save({send_invite:!0});return s.success?(this.getApp().toast.success("Invite sent successfully"),!0):(s.data&&s.data.error?this.getApp().toast.error(s.data.error):this.getApp().toast.error("Failed to send invite"),!1)}}function D(e){return e&&"object"==typeof e?Object.values(e).filter(e=>!0===e).length:0}const V={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"};class MemberOverviewSection extends t.View{constructor(e={}){super({className:"member-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="member-kpi-role"></div>\n <div data-container="member-kpi-status"></div>\n <div data-container="member-kpi-joined"></div>\n <div data-container="member-kpi-perms"></div>\n </div>\n\n <div class="detail-section-eyebrow">This membership</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">User</div>\n <div class="detail-flat-row-value">\n {{#userDisplayName}}<a href="#" data-action="view-user">{{userDisplayName}}</a>{{/userDisplayName}}\n {{^userDisplayName}}<span class="text-secondary fst-italic">Not set</span>{{/userDisplayName}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email</div>\n <div class="detail-flat-row-value">\n {{#userEmail}}{{userEmail}}{{/userEmail}}\n {{^userEmail}}<span class="text-secondary fst-italic">Not set</span>{{/userEmail}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Group</div>\n <div class="detail-flat-row-value">\n {{#groupName}}<a href="#" data-action="view-group">{{groupName}}</a>{{/groupName}}\n {{^groupName}}<span class="text-secondary fst-italic">Not set</span>{{/groupName}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Role</div>\n <div class="detail-flat-row-value">\n {{#hasRole|bool}}<span class="badge text-bg-primary">{{roleLabel}}</span>{{/hasRole|bool}}\n {{^hasRole|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasRole|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Joined</div>\n <div class="detail-flat-row-value">\n {{#hasCreated|bool}}{{model.created|epoch|datetime}} &middot; {{model.created|epoch|relative}}{{/hasCreated|bool}}\n {{^hasCreated|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCreated|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Invited by</div>\n <div class="detail-flat-row-value">\n {{#invitedBy}}{{invitedBy}}{{/invitedBy}}\n {{^invitedBy}}<span class="text-secondary fst-italic">—</span>{{/invitedBy}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Recent activity in this group</div>\n <div data-container="member-overview-activity"></div>\n ',...e}),this.logsCollection=e.logsCollection||null}get userDisplayName(){return this.model.get("user")?.display_name||""}get userEmail(){return this.model.get("user")?.email||""}get groupName(){return this.model.get("group")?.name||""}get roleLabel(){return this.model.get("metadata")?.role||""}get hasRole(){return!!this.roleLabel}get hasCreated(){return null!=this.model.get("created")}get invitedBy(){const e=this.model.get("metadata")||{};return e.invited_by_name||e.invited_by||""}get permsCount(){return D(this.model.get("permissions"))}get isActive(){return!!this.model.get("is_active")}async onInit(){const e=this.model;this.kpiRole=new n.MetricCard({containerId:"member-kpi-role",label:"Role",value:this.roleLabel||"—"}),this.kpiStatus=new n.MetricCard({containerId:"member-kpi-status",label:"Status",value:this.isActive?"Active":"Inactive",tone:this.isActive?"success":"warning"});const i=e.get("created"),s=null!=i?t.dataFormatter.apply(i,["epoch","relative"]):"—";this.kpiJoined=new n.MetricCard({containerId:"member-kpi-joined",label:"Joined",value:s||"—"}),this.kpiPerms=new n.MetricCard({containerId:"member-kpi-perms",label:"Perms granted",value:String(this.permsCount)}),[this.kpiRole,this.kpiStatus,this.kpiJoined,this.kpiPerms].forEach(e=>this.addChild(e)),this.activityTimeline=new n.Timeline({containerId:"member-overview-activity",limit:5,emptyText:"No recorded activity for this membership yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline)}async onAfterRender(){await super.onAfterRender(),this.logsCollection&&!this._wired&&(this.logsCollection.on("fetch:success",()=>{this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())},this),this._wired=!0)}_buildActivityItems(){return(this.logsCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=V[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?this.escapeHtml(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"]);return{tone:s,headline:String(a),detail:o,when:l}})}async onActionViewUser(e){e?.preventDefault?.(),this.emit("action:view-user")}async onActionViewGroup(e){e?.preventDefault?.(),this.emit("action:view-group")}}class MemberPermissionsSection extends t.View{constructor(e={}){super({className:"member-permissions-section",template:'\n <div class="detail-section-eyebrow">Group permissions</div>\n <p class="text-secondary small mb-3">Per-group grants. Toggles autosave as soon as you flip them.</p>\n <div data-container="member-perms-group"></div>\n\n <div class="detail-section-eyebrow mt-4">System permissions <span class="text-secondary fw-normal">(read-only)</span></div>\n <p class="text-secondary small mb-3">User-record permissions. Edit via the user\'s permissions page.</p>\n <div data-container="member-perms-system"></div>\n ',...e})}async onInit(){this.formView=new o.FormView({containerId:"member-perms-group",fields:l.Member.PERMISSION_FIELDS,model:this.model,autosaveModelField:!0}),this.addChild(this.formView),this.systemPermsView=new t.View({containerId:"member-perms-system",className:"member-system-perms",template:'\n {{#hasSystemPerms|bool}}\n {{#systemPermRows}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><code>{{key}}</code></div>\n <div class="detail-flat-row-value">\n <div class="form-check form-switch m-0">\n <input class="form-check-input" type="checkbox" disabled {{#enabled|bool}}checked{{/enabled|bool}} aria-label="{{key}}">\n </div>\n </div>\n </div>\n {{/systemPermRows}}\n {{/hasSystemPerms|bool}}\n {{^hasSystemPerms|bool}}\n <div class="text-center text-body-secondary py-3">\n <p class="mb-0 small">No system permissions on this user, or the user graph does not expose them.</p>\n </div>\n {{/hasSystemPerms|bool}}\n ',model:this.model}),Object.defineProperty(this.systemPermsView,"hasSystemPerms",{get:()=>{const e=this.model?.get?.("user")?.permissions;return!(!e||"object"!=typeof e||!Object.keys(e).length)}}),Object.defineProperty(this.systemPermsView,"systemPermRows",{get:()=>{const e=this.model?.get?.("user")?.permissions;return e&&"object"==typeof e?Object.keys(e).sort().map(t=>({key:t,enabled:!!e[t]})):[]}}),this.addChild(this.systemPermsView)}}class MemberView extends n.DetailView{constructor(e={}){const t=e.model||new l.Member(e.data||{}),i=t.get("id"),s=new l.LogList({params:{size:25,model_name:"account.Member",model_id:i,sort:"-created"}}),a=new MemberOverviewSection({model:t,logsCollection:s}),n=new MemberPermissionsSection({model:t}),o=new l.TableView({collection:s,title:"Audit",eyebrow:"Section · Audit",showFullscreen:!1,searchable:!1,hideActivePillNames:["model_name","model_id"],permissions:"view_logs",tableOptions:{striped:!1,hover:!0},columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",width:"180px",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,formatter:"badge",width:"110px"},{key:"kind",label:"Kind"},{key:"log",label:"Log"}]}),r=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:a},{key:"Permissions",label:"Permissions",icon:"bi-shield-lock",view:n},{type:"divider",label:"Activity"},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:o,permissions:"view_logs"}],c=[{icon:"bi-envelope",text:e=>e.get("user")?.email||null,variant:"light",when:e=>!!e.get("user")?.email},{icon:"bi-people",text:e=>e.get("group")?.kind||null,variant:"info",when:e=>!!e.get("group")?.kind},{icon:"bi-person-badge",text:e=>e.get("metadata")?.role||null,variant:"primary",when:e=>!!e.get("metadata")?.role},{text:e=>{const t=D(e.get("permissions"));return t>0?`${t} ${1===t?"perm":"perms"} granted`:null},variant:"light",when:e=>D(e.get("permissions"))>0}];super({className:"member-view",...e,model:t,header:{icon:"bi-person-badge",titleFn:e=>`${e.get("user")?.display_name||"User"} in ${e.get("group")?.name||"Group"}`,subtitlePath:"_subtitle",chips:c,activeField:"is_active",actions:[{label:"Edit role",icon:"bi-pencil",action:"edit-role",title:"Edit role and membership details"},{label:"Remove",icon:"bi-person-dash",action:"remove-from-group",title:"Remove from group"}],contextMenu:{items:[{label:"View user",action:"view-user",icon:"bi-person"},{label:"View group",action:"view-group",icon:"bi-people"},{label:"Audit log",action:"view-audit",icon:"bi-clock-history"},{type:"divider"},{label:"Remove from group",action:"remove-from-group",icon:"bi-person-dash",danger:!0}]}},sections:r,activeSection:"Overview"}),this.logsCollection=s,this.overviewSection=a,this.permissionsSection=n,this.auditSection=o,this._refreshComputedFields()}async onAfterBuild(){this.overviewSection.on("action:view-user",()=>this.onActionViewUser()),this.overviewSection.on("action:view-group",()=>this.onActionViewGroup());const e=()=>{const e=this.logsCollection.totalCount??this.logsCollection.models?.length??0;this.setBadge("Audit",e>0?{text:String(e),variant:"muted"}:null)};this.logsCollection.on("fetch:success",e,this),this.logsCollection.models?.length&&e(),this.logsCollection.fetch().catch(()=>{})}_refreshComputedFields(){const e=this.model,i=e.get("metadata")?.role||"Member",s=e.get("created"),a=[i];if(null!=s){const e=t.dataFormatter.apply(s,["epoch","relative"]);e&&a.push(`joined ${e}`)}e.attributes._subtitle=a.join(" · ")}async onActionEditRole(){await a.Modal.modelForm({title:"Edit membership",model:this.model,size:"md",formConfig:l.MemberForms.edit})&&(this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render())}async onActionViewUser(){const e=this.model.get("user")?.id;if(!e)return!0;const i=t.User.VIEW_CLASS;if(i){const s=new t.User({id:e});if(await s.fetch(),!s.id)return a.Modal.alert({message:`Could not find User with ID: ${e}`,type:"warning"}),!0;const n=new i({model:s});await a.Modal.detail(n)}else await a.Modal.showModelById(t.User,e);return!0}async onActionViewGroup(){const e=this.model.get("group")?.id;if(!e)return!0;const i=t.Group.VIEW_CLASS;if(i){const s=new t.Group({id:e});if(await s.fetch(),!s.id)return a.Modal.alert({message:`Could not find Group with ID: ${e}`,type:"warning"}),!0;const n=new i({model:s});await a.Modal.detail(n)}else await a.Modal.showModelById(t.Group,e);return!0}async onActionViewAudit(){await this.showSection("Audit")}async onActionRemoveFromGroup(){const e=this.model.get("user")?.display_name||"this user",t=this.model.get("group")?.name||"this group";if(!(await a.Modal.confirm(`Remove <strong>${this.escapeHtml(e)}</strong> from <strong>${this.escapeHtml(t)}</strong>? This cannot be undone.`,"Remove from group")))return!0;try{const e=await this.model.destroy();if(e&&e.success){this.getApp()?.toast?.success("Member removed"),this.emit("member:removed",{model:this.model});const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}else this.getApp()?.toast?.error("Failed to remove member")}catch(i){this.getApp()?.toast?.error(`Failed to remove member: ${i.message}`)}return!0}static create(e={}){return new MemberView(e)}}MemberView.VIEW_CLASS=MemberView,l.Member.VIEW_CLASS=MemberView,l.Member.MODEL_REF="account.Member",l.Member.VIEW_CLASS=MemberView;class MemberTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_members",pageName:"Manage Members",router:"admin/members",Collection:l.MemberList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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",visibility:"xl"}],searchPlaceholder:"Search name, email, or username",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 $={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."}]}},R=t.MOJOUtils.escapeHtml;function N(e){if(!e)return"";const i=t.Group.GroupKinds?.[e];if(i)return i;const s=String(e);return s.charAt(0).toUpperCase()+s.slice(1)}const F={error:"danger",critical:"danger",warning:"warning",warn:"warning",info:"info"};class GroupOverviewSection extends t.View{constructor(e={}){super({className:"group-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="group-kpi-members"></div>\n <div data-container="group-kpi-subgroups"></div>\n <div data-container="group-kpi-apikeys"></div>\n <div data-container="group-kpi-activity"></div>\n </div>\n\n <div class="detail-section-eyebrow">This group</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Name</div>\n <div class="detail-flat-row-value">{{model.name|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Parent</div>\n <div class="detail-flat-row-value">\n {{#hasParent|bool}}<a href="#" data-action="view-parent">{{parentName}}</a>{{/hasParent|bool}}\n {{^hasParent|bool}}<span class="text-secondary fst-italic">None — top-level group</span>{{/hasParent|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Hierarchy</div>\n <div data-container="group-overview-hierarchy"></div>\n\n <div class="detail-section-eyebrow">Recent activity</div>\n <div data-container="group-overview-activity"></div>\n ',...e}),this.membersCollection=e.membersCollection||null,this.subGroupsCollection=e.subGroupsCollection||null,this.apiKeysCollection=e.apiKeysCollection||null,this.auditCollection=e.auditCollection||null}get hasKind(){return!!this.model?.get?.("kind")}get kindLabel(){return N(this.model?.get?.("kind"))}get hasParent(){return!!this.model?.get?.("parent")?.id}get parentName(){const e=this.model?.get?.("parent");return e?.id?e.name||`#${e.id}`:""}async onInit(){const e=this.model;this.kpiMembers=new n.MetricCard({containerId:"group-kpi-members",label:"Members",value:this._memberCount()}),this.kpiSubGroups=new n.MetricCard({containerId:"group-kpi-subgroups",label:"Sub-Groups",value:this._subGroupCount()}),this.kpiApiKeys=new n.MetricCard({containerId:"group-kpi-apikeys",label:"API Keys",value:this._apiKeyCount()}),this.kpiActivity=new n.MetricCard({containerId:"group-kpi-activity",label:"Last activity",value:this._lastActivityLabel()}),[this.kpiMembers,this.kpiSubGroups,this.kpiApiKeys,this.kpiActivity].forEach(e=>this.addChild(e)),this.hierarchyTree=new GroupHierarchyTree({containerId:"group-overview-hierarchy",model:e,subGroupsCollection:this.subGroupsCollection,membersCollection:this.membersCollection}),this.addChild(this.hierarchyTree),this.activityTimeline=new n.Timeline({containerId:"group-overview-activity",limit:5,emptyText:"No recorded activity for this group yet.",items:()=>this._buildActivityItems()}),this.addChild(this.activityTimeline),this._wireCollection(this.membersCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.subGroupsCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.apiKeysCollection,()=>this._refreshAfterFetch()),this._wireCollection(this.auditCollection,()=>this._refreshActivity())}_wireCollection(e,t){e&&!e._groupOverviewWired&&(e.on("fetch:success",t,this),e._groupOverviewWired=!0)}_memberCount(){return this.membersCollection?.models?.length??0}_subGroupCount(){return this.subGroupsCollection?.models?.length??0}_apiKeyCount(){return this.apiKeysCollection?.models?.length??0}_lastActivityLabel(){const e=this.model.get("last_activity");return e&&t.dataFormatter.apply(e,["epoch","relative"])||"—"}_refreshAfterFetch(){this.kpiMembers?.setValue(this._memberCount()),this.kpiSubGroups?.setValue(this._subGroupCount()),this.kpiApiKeys?.setValue(this._apiKeyCount()),this.kpiActivity?.setValue(this._lastActivityLabel()),this.hierarchyTree?.isMounted()&&this.hierarchyTree.render().catch(()=>{})}_refreshActivity(){this.activityTimeline?.isMounted()&&this.activityTimeline.setItems(()=>this._buildActivityItems())}_buildActivityItems(){return(this.auditCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=F[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?R(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"])||"";return{tone:s,headline:String(a),detail:o,when:l}})}async onActionViewParent(e){e?.preventDefault?.(),this.emit("navigate:parent")}}class GroupHierarchyTree extends t.View{constructor(e={}){super({className:"group-hierarchy-tree small font-monospace",template:'\n <div class="group-hierarchy-tree-rows">\n {{#hasParent|bool}}\n <a href="#" data-action="view-parent" data-id="{{parentId}}" class="link-secondary">{{parentName}}</a><br>\n {{/hasParent|bool}}\n {{^hasParent|bool}}\n <span class="text-secondary">Top-level group</span><br>\n {{/hasParent|bool}}\n {{{selfLine}}}\n {{#hasSubGroups|bool}}\n <div class="ms-4 mt-1">{{{childLines}}}</div>\n {{/hasSubGroups|bool}}\n </div>\n ',...e}),this.subGroupsCollection=e.subGroupsCollection||null,this.membersCollection=e.membersCollection||null}get _parent(){return this.model?.get?.("parent")||null}get hasParent(){return!!this._parent?.id}get parentId(){return this._parent?.id?String(this._parent.id):""}get parentName(){return this._parent?.id?this._parent.name||`#${this._parent.id}`:""}get _subGroups(){return this.subGroupsCollection?.models||[]}get hasSubGroups(){return this._subGroups.length>0}get _memberCount(){return this.membersCollection?.models?.length??0}get selfLine(){const e=this.model,t=this._subGroups.length,i=this._memberCount,s=1===i?"member":"members",a=1===t?"sub-group":"sub-groups";return`└─ <strong class="text-body">${R(e.get("name")||"—")}</strong> · ${i} ${s} · ${t} ${a}`}get childLines(){const e=this._subGroups;return e.map((t,i)=>{const s=i===e.length-1?"└─":"├─",a=t.get("id");return`${s} <a href="#" data-action="view-subgroup" data-id="${R(String(a))}">${R(t.get("name")||`#${a}`)}</a>`}).join("<br>")}async onActionViewParent(e,t){e?.preventDefault?.(),this.emit("navigate:parent",t?.dataset?.id)}async onActionViewSubgroup(e,t){e?.preventDefault?.(),this.emit("navigate:subgroup",t?.dataset?.id)}}class GroupIdentitySection extends t.View{constructor(e={}){super({className:"group-identity-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">Profile</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Name</div>\n <div class="detail-flat-row-value">{{model.name|default:\'—\'}}</div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Kind</div>\n <div class="detail-flat-row-value">\n {{#hasKind|bool}}<span class="badge text-bg-primary">{{kindLabel}}</span>{{/hasKind|bool}}\n {{^hasKind|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasKind|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-kind" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ID</div>\n <div class="detail-flat-row-value"><code>{{model.id}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Parent</div>\n <div class="detail-flat-row-value">\n {{#hasParent|bool}}<a href="#" data-action="view-parent">{{parentName}}</a>{{/hasParent|bool}}\n {{^hasParent|bool}}<span class="text-secondary fst-italic">None — top-level group</span>{{/hasParent|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Settings</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Timezone</div>\n <div class="detail-flat-row-value">\n {{#hasTimezone|bool}}<code>{{timezone}}</code>{{/hasTimezone|bool}}\n {{^hasTimezone|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasTimezone|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-timezone" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">EOD hour</div>\n <div class="detail-flat-row-value">\n {{#hasEodHour|bool}}{{eodHourLabel}}{{/hasEodHour|bool}}\n {{^hasEodHour|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEodHour|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-eod-hour" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Domain</div>\n <div class="detail-flat-row-value">\n {{#hasDomain|bool}}<code>{{domain}}</code>{{/hasDomain|bool}}\n {{^hasDomain|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasDomain|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-domain" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Auth domain</div>\n <div class="detail-flat-row-value">\n {{#hasAuthDomain|bool}}<code>{{authDomain}}</code>{{/hasAuthDomain|bool}}\n {{^hasAuthDomain|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasAuthDomain|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-auth-domain" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Short name</div>\n <div class="detail-flat-row-value">\n {{#hasShortName|bool}}<code>{{shortName}}</code>{{/hasShortName|bool}}\n {{^hasShortName|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasShortName|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-short-name" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Portal</div>\n <div class="detail-flat-row-value">\n {{#hasPortal|bool}}<a href="{{portal}}" target="_blank" rel="noopener">{{portal}}</a>{{/hasPortal|bool}}\n {{^hasPortal|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasPortal|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-portal" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Email template</div>\n <div class="detail-flat-row-value">\n {{#hasEmailTemplate|bool}}<code>{{emailTemplate}}</code>{{/hasEmailTemplate|bool}}\n {{^hasEmailTemplate|bool}}<span class="text-secondary fst-italic">Not set</span>{{/hasEmailTemplate|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-bs-toggle="tooltip" data-action="edit-email-template" title="Edit"><i class="bi bi-pencil"></i></button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Dates</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Created</div>\n <div class="detail-flat-row-value">\n {{#hasCreated|bool}}<code>{{model.created|epoch|datetime}}</code>{{/hasCreated|bool}}\n {{^hasCreated|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCreated|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Modified</div>\n <div class="detail-flat-row-value">\n {{#hasModified|bool}}<code>{{model.modified|epoch|datetime}}</code>{{/hasModified|bool}}\n {{^hasModified|bool}}<span class="text-secondary fst-italic">—</span>{{/hasModified|bool}}\n </div>\n </div>\n ',...e})}get hasKind(){return!!this.model?.get?.("kind")}get kindLabel(){return N(this.model?.get?.("kind"))}get hasParent(){return!!this.model?.get?.("parent")?.id}get parentName(){const e=this.model?.get?.("parent");return e?.id?e.name||`#${e.id}`:""}get _meta(){return this.model?.get?.("metadata")||{}}get hasTimezone(){return!!this._meta.timezone}get timezone(){return this._meta.timezone||""}get hasEodHour(){const e=this._meta.eod_hour;return null!=e&&""!==e}get eodHourLabel(){const e=this._meta.eod_hour;return this.hasEodHour?`${String(e).padStart(2,"0")}:00`:""}get hasDomain(){return!!this._meta.domain}get domain(){return this._meta.domain||""}get hasAuthDomain(){return!!this._meta.auth_domain}get authDomain(){return this._meta.auth_domain||""}get hasShortName(){return!!this._meta.short_name}get shortName(){return this._meta.short_name||""}get hasPortal(){return!!this._meta.portal}get portal(){return this._meta.portal||""}get hasEmailTemplate(){return!!this._meta.email_template}get emailTemplate(){return this._meta.email_template||""}get hasAnySettings(){return this.hasTimezone||this.hasEodHour||this.hasDomain||this.hasAuthDomain||this.hasShortName||this.hasPortal||this.hasEmailTemplate}get hasCreated(){return null!=this.model?.get?.("created")}get hasModified(){return null!=this.model?.get?.("modified")}async onActionViewParent(e){e?.preventDefault?.(),this.emit("navigate:parent")}async onActionEditName(){const e=await a.Modal.prompt("Group name:","Edit Name",{defaultValue:this.model.get("name")||""});return"string"!=typeof e||!e.trim()||(await this._saveField({name:e.trim()},"Name"),!0)}async onActionEditKind(){const e=Object.entries(t.Group.GroupKinds||{}).map(([e,t])=>({value:e,text:t})),i=await a.Modal.form({title:"Edit Kind",size:"sm",fields:[{name:"kind",type:"select",label:"Kind",cols:12,options:[{value:"",text:"(none)"},...e]}],data:{kind:this.model.get("kind")||""}});return!i||(await this._saveField({kind:i.kind||null},"Kind"),!0)}async onActionEditTimezone(){const e=this.model.get("metadata")||{},t=await a.Modal.form({title:"Edit Timezone",size:"sm",fields:[{name:"timezone",type:"select",label:"Timezone",cols:12,options:[{value:"",text:"(none)"},{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:{timezone:t.timezone||null}},"Timezone"),!0)}async onActionEditEodHour(){const e=this.model.get("metadata")||{},t=await a.Modal.form({title:"Edit EOD Hour",size:"sm",fields:[{name:"eod_hour",type:"number",label:"EOD hour",cols:12,min:0,max:23,placeholder:"0–23",tooltip:"Hour of the day (24h, 0–23) when this group rolls over."}],data:{eod_hour:e.eod_hour??""}});if(!t)return!0;const i=t.eod_hour,s=""===i||null==i?null:Math.max(0,Math.min(23,parseInt(i,10)||0));return await this._saveField({metadata:{eod_hour:s}},"EOD hour"),!0}async onActionEditDomain(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Domain:","Edit Domain",{defaultValue:e.domain||""});return"string"!=typeof t||await this._saveField({metadata:{domain:t.trim()||null}},"Domain"),!0}async onActionEditAuthDomain(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Auth domain (used for white-label login pages):","Edit Auth Domain",{defaultValue:e.auth_domain||"",placeholder:"auth.example.com"});return"string"!=typeof t||await this._saveField({metadata:{auth_domain:t.trim()||null}},"Auth domain"),!0}async onActionEditShortName(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Short name:","Edit Short Name",{defaultValue:e.short_name||""});return"string"!=typeof t||await this._saveField({metadata:{short_name:t.trim()||null}},"Short name"),!0}async onActionEditPortal(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Portal URL:","Edit Portal",{defaultValue:e.portal||"",placeholder:"https://…"});return"string"!=typeof t||await this._saveField({metadata:{portal:t.trim()||null}},"Portal"),!0}async onActionEditEmailTemplate(){const e=this.model.get("metadata")||{},t=await a.Modal.prompt("Email template name:","Edit Email Template",{defaultValue:e.email_template||""});return"string"!=typeof t||await this._saveField({metadata:{email_template:t.trim()||null}},"Email template"),!0}async _saveField(e,t){const i=await this.model.save(e);i&&200===i.status?(this.getApp()?.toast?.success(`${t} updated`),await this.render()):this.getApp()?.toast?.error(i?.message||`Failed to update ${t.toLowerCase()}`)}}class GroupPermissionsSection extends t.View{constructor(e={}){super({className:"group-permissions-section",template:'\n <div class="detail-section-eyebrow">Group permissions</div>\n {{#hasPermissions|bool}}\n {{#permissionRows}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><code>{{key}}</code></div>\n <div class="detail-flat-row-value">\n <div class="form-check form-switch m-0">\n <input class="form-check-input" type="checkbox" disabled {{#enabled|bool}}checked{{/enabled|bool}} aria-label="{{key}}">\n </div>\n </div>\n </div>\n {{/permissionRows}}\n {{/hasPermissions|bool}}\n {{^hasPermissions|bool}}\n <div class="text-center text-body-secondary py-4">\n <i class="bi bi-shield-lock fs-1 d-block mb-2"></i>\n <p class="mb-0 small">No group-scoped permissions defined. Permissions on members and API keys\n are managed from their own records.</p>\n </div>\n {{/hasPermissions|bool}}\n ',...e})}get _perms(){return(this.model?.get?.("metadata")||{}).permissions||this.model?.get?.("permissions")||null}get hasPermissions(){const e=this._perms;return!(!e||"object"!=typeof e||!Object.keys(e).length)}get permissionRows(){const e=this._perms;return e&&"object"==typeof e?Object.keys(e).sort().map(t=>({key:t,enabled:!!e[t]})):[]}}class GroupAuditTimelineSection extends t.View{constructor(e={}){const{auditCollection:t,...i}=e;super({className:"group-audit-section",template:'\n <div class="detail-section-eyebrow">Audit</div>\n <div data-container="group-audit-timeline"></div>\n ',...i}),this.auditCollection=t||null}async onInit(){this.timeline=new n.Timeline({containerId:"group-audit-timeline",emptyText:"No audit entries recorded for this group yet.",items:()=>this._buildItems()}),this.addChild(this.timeline)}async onAfterRender(){await super.onAfterRender(),this.auditCollection&&!this._wired&&(this.auditCollection.on("fetch:success",()=>{this.timeline?.isMounted()&&this.timeline.setItems(()=>this._buildItems())},this),this._wired=!0)}_buildItems(){return(this.auditCollection?.models||[]).map(e=>{const i=String(e.get("level")||"").toLowerCase(),s=F[i]||"default",a=e.get("kind")||e.get("level")||"event",n=e.get("log"),o=n?R(String(n)):"",l=t.dataFormatter.apply(e.get("created"),["epoch","relative"])||"";return{tone:s,headline:String(a),detail:o,when:l}})}}class GroupView extends n.DetailView{constructor(e={}){const i=e.model||new t.Group(e.data||{}),s=i.get("id"),a=new l.MemberList({params:{group:s,size:10}}),n=new t.GroupList({params:{parent:s,size:10}}),o=new ApiKeyList({params:{group:s,size:10}}),r=new c.IncidentEventList({params:{size:10,model_name:"account.Group",model_id:s,sort:"-created"}}),d=new l.LogList({params:{size:25,model_name:"account.Group",model_id:s,sort:"-created"}}),h=new GroupOverviewSection({model:i,membersCollection:a,subGroupsCollection:n,apiKeysCollection:o,auditCollection:d}),u=new GroupIdentitySection({model:i}),m=new l.TableView({collection:a,title:"Members",showFullscreen:!1,searchable:!1,hideActivePillNames:["group"],showAdd:!0,addButtonLabel:"Invite",clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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}]}),p=new l.TableView({collection:n,title:"Sub-Groups",showFullscreen:!1,searchable:!1,hideActivePillNames:["parent"],showAdd:!0,addButtonLabel:"Add Group",clickAction:"view",itemView:GroupView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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 text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"created",label:"Created",formatter:"date",sortable:!0}]}),b=new l.TableView({collection:o,title:"API Keys",showFullscreen:!1,searchable:!1,hideActivePillNames:["group"],showAdd:!0,addButtonLabel:"Create Key",clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},addFormConfig:{...$.create,defaults:{group:s}},columns:[{key:"name",label:"Name",sortable:!0},{key:"is_active",label:"Status",width:"80px",template:'\n {{#model.is_active|bool}}<span class="badge text-bg-success">Active</span>{{/model.is_active|bool}}\n {{^model.is_active|bool}}<span class="badge text-bg-secondary">Inactive</span>{{/model.is_active|bool}}'},{key:"permissions|keys|badge",label:"Permissions"},{key:"created",label:"Created",formatter:"datetime",sortable:!0}]}),g=new GroupPermissionsSection({model:i}),v=new l.TableView({collection:r,title:"Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["model_name","model_id"],columns:[{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),y=new GroupAuditTimelineSection({model:i,auditCollection:d}),w=new AdminMetadataSection({model:i}),f=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:h},{key:"Identity",label:"Identity",icon:"bi-card-text",view:u},{type:"divider",label:"Membership"},{key:"Members",label:"Members",icon:"bi-people",view:m},{key:"SubGroups",label:"Sub-Groups",icon:"bi-diagram-3",view:p},{type:"divider",label:"Access"},{key:"ApiKeys",label:"API Keys",icon:"bi-key",view:b},{key:"Permissions",label:"Permissions",icon:"bi-shield-lock",view:g},{type:"divider",label:"Activity"},{key:"Events",label:"Events",icon:"bi-calendar-event",view:v},{key:"Audit",label:"Audit",icon:"bi-clock-history",view:y,permissions:"view_logs"},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:w}],_=i.get("kind"),k=function(e){const t=String(e||"").toLowerCase();return"org"===t||"organization"===t||"division"===t||"department"===t?"bi-buildings":"region"===t||"location"===t?"bi-geo-alt":"project"===t?"bi-kanban":"merchant"===t||"partner"===t||"client"===t||"reseller"===t?"bi-shop":"iso"===t||"sales"===t?"bi-briefcase":"route"===t?"bi-signpost-2":"inventory"===t?"bi-box-seam":"qa"===t||"test"===t||"testing"===t?"bi-clipboard-check":"bi-people-fill"}(_),x=[{text:e=>N(e.get("kind"))||null,variant:"primary",when:e=>!!e.get("kind")},{icon:"bi-people",text:()=>{const e=a.models?.length??0;return e?`${e} ${1===e?"member":"members"}`:null},variant:"light",when:()=>(a.models?.length??0)>0},{icon:"bi-diagram-3",text:()=>{const e=n.models?.length??0;return e?`${e} ${1===e?"sub-group":"sub-groups"}`:null},variant:"light",when:()=>(n.models?.length??0)>0},{text:e=>e.get("metadata")?.timezone||null,variant:"light",when:e=>!!e.get("metadata")?.timezone},{text:e=>{const t=e.get("metadata")?.eod_hour;return null==t||""===t?null:`EOD ${String(t).padStart(2,"0")}:00`},variant:"light"},{icon:"bi-globe",text:"Has portal",variant:"light",when:e=>!!e.get("metadata")?.portal}],S=N(_)||"Group",C=["groups","manage_groups"],A=["manage_groups"],T=[{label:`Edit ${S}`,action:"edit-group",icon:"bi-pencil",permissions:C},{label:"Invite Member",action:"invite-member",icon:"bi-person-plus",permissions:C},{label:`Add Sub-${S}`,action:"add-child-group",icon:"bi-diagram-3",permissions:C}];i.get("parent")?.id&&T.push({label:"View Parent",action:"view-parent-menu",icon:"bi-arrow-up-right-square"}),T.push({type:"divider"}),T.push(i.get("is_active")?{label:`Deactivate ${S}`,action:"state-toggle",icon:"bi-toggle-off",permissions:A}:{label:`Activate ${S}`,action:"state-toggle",icon:"bi-toggle-on",permissions:A}),T.push({type:"divider"}),T.push({label:`Delete ${S}`,action:"delete-group",icon:"bi-trash",danger:!0,permissions:A}),super({className:"group-view",...e,model:i,header:{icon:k,titleField:"name",subtitlePath:"_subtitle",chips:x,actions:[],auxFn:e=>function(e,i,s){const a=!!e.get("is_active"),n=s?`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${a?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${a?"Active":"Inactive"}</span>\n </label>\n `:"",o=e.get("last_activity"),l=i?.models?.length??0;let r="";if(o){const e=t.dataFormatter.apply(o,["epoch","relative"]);e&&(r=`Last activity ${R(String(e))}`)}else l>0&&(r=`${l} ${1===l?"member":"members"}`);return`\n <div class="dh-aux-top">${n}</div>\n ${r?`<span class="dh-aux-meta">${r}</span>`:""}\n `}(e,a,this.isAdminCallerDestructive),contextMenu:{items:T}},sections:f,activeSection:"Overview"}),this.membersCollection=a,this.subGroupsCollection=n,this.apiKeysCollection=o,this.eventsCollection=r,this.auditCollection=d,this.overviewSection=h,this.identitySection=u,this.membersSection=m,this.subGroupsSection=p,this.apiKeysSection=b,this.permissionsSection=g,this.eventsSection=v,this.auditSection=y,this.metadataSection=w,this._refreshComputedFields()}async onAfterBuild(){this.overviewSection.on("navigate:parent",e=>this._openGroupById(e??this.model.get("parent")?.id)),this.overviewSection.on("navigate:subgroup",e=>this._openGroupById(e)),this.identitySection.on("navigate:parent",()=>this._openGroupById(this.model.get("parent")?.id)),this.membersCollection.on("fetch:success",()=>{const e=this.membersCollection.models?.length??0;this.setBadge("Members",e>0?{text:String(e),variant:"muted"}:null)},this),this.subGroupsCollection.on("fetch:success",()=>{const e=this.subGroupsCollection.models?.length??0;this.setBadge("SubGroups",e>0?{text:String(e),variant:"muted"}:null)},this),this.apiKeysCollection.on("fetch:success",()=>{const e=this.apiKeysCollection.models?.length??0;this.setBadge("ApiKeys",e>0?{text:String(e),variant:"muted"}:null)},this),this.auditCollection.on("fetch:success",()=>{const e=this.auditCollection.models?.length??0;this.setBadge("Audit",e>0?{text:String(e),variant:"muted"}:null)},this);const e=()=>{this.headerView?.isMounted()&&this.headerView.render().catch(()=>{}),this._refreshComputedFields()};this.membersCollection.on("fetch:success",e,this),this.subGroupsCollection.on("fetch:success",e,this),this.membersCollection.fetch().catch(()=>{}),this.subGroupsCollection.fetch().catch(()=>{}),this.apiKeysCollection.fetch().catch(()=>{}),this.eventsCollection.fetch().catch(()=>{}),this.auditCollection.fetch().catch(()=>{})}_refreshComputedFields(){const e=this.model,t=e.get("parent");e.attributes._subtitle=t?.name?String(t.name):""}_kindNoun(){return N(this.model.get("kind"))||"Group"}get isAdminCaller(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.(["groups","manage_groups"]))}get isAdminCallerDestructive(){const e=this.getApp()?.activeUser;return!(!e||!e.get?.("is_superuser")&&!e.hasPermission?.("manage_groups"))}async onActionEditGroup(){return await a.Modal.modelForm({title:`Edit ${this._kindNoun()} — ${this.model.get("name")}`,model:this.model,size:"lg",formConfig:t.GroupForms.detailed})&&await this._fullRefresh(),!0}async onActionInviteMember(e){e?.preventDefault&&(e.preventDefault(),e.stopPropagation());const t=await a.Modal.form({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(),s=await i.rest.POST("/api/group/member/invite",{group:this.model.id,email:t.email});return s.success?(i.toast.success("User invited successfully"),await this.membersCollection.fetch()):i.toast.error(s.message||"Failed to invite user"),!0}async onActionAddChildGroup(){const e=await a.Modal.form({title:`Add Sub-${this._kindNoun()} to ${this.model.get("name")}`,size:"sm",fields:t.GroupForms.create.fields.filter(e=>"parent"!==e.name)});if(!e)return!0;e.parent=this.model.id;const i=new t.Group(e),s=await i.save();return 200===s.status||201===s.status?(this.getApp()?.toast?.success("Sub-group created"),await this.subGroupsCollection.fetch()):this.getApp()?.toast?.error(s.message||"Failed to create sub-group"),!0}async onActionViewParentMenu(){const e=this.model.get("parent");return!e?.id||(await this._openGroupById(e.id),!0)}async _openGroupById(e){if(!e)return;const i=new t.Group({id:e});try{await i.fetch()}catch{}i.id?await a.Modal.detail(new GroupView({model:i})):this.getApp()?.toast?.error("Group not found")}async onActionToggleActive(e,t){const i=!!t.checked;t.disabled=!0;try{this.model.set("is_active",i);const e=await this.model.save({is_active:i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.emit("detail:updated")}catch(s){this.model.set("is_active",!i)}finally{t&&t.isConnected&&(t.disabled=!1)}return!0}async onActionStateToggle(){const e=!this.model.get("is_active"),t=e?"activate":"deactivate",i=e?"Activate":"Deactivate",s=this._kindNoun();if(!(await a.Modal.confirm(`Are you sure you want to ${t} <strong>${R(this.model.get("name")||"")}</strong>?`,`${i} ${s}`)))return!0;try{const t=await this.model.save({is_active:e});if(t&&t.status&&t.status>=400)throw new Error("Save failed");this.getApp()?.toast?.success("Group "+(e?"activated":"deactivated")),await this._fullRefresh()}catch(n){this.getApp()?.toast?.error(`Failed to ${t}: ${n.message}`)}return!0}async onActionDeleteGroup(){const e=this._kindNoun();if(!(await a.Modal.confirm({title:`Delete ${e}`,message:`Are you sure you want to delete <strong>${R(this.model.get("name")||"")}</strong>? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"})))return!0;try{await this.model.destroy(),this.getApp()?.toast?.success("Group deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("group:deleted",{model:this.model})}catch(t){this.getApp()?.toast?.error(`Failed to delete: ${t.message}`)}return!0}async _fullRefresh(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.identitySection?.isMounted()&&await this.identitySection.render(),this.permissionsSection?.isMounted()&&await this.permissionsSection.render()}}GroupView.VIEW_CLASS=GroupView,t.Group.VIEW_CLASS=GroupView,t.Group.MODEL_REF="account.Group",t.Group.VIEW_CLASS=GroupView;class GroupTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_groups",pageName:"Manage Groups",router:"admin/groups",Collection:t.GroupList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-id",is_active:"true"},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"name",label:"Display Name"},{key:"kind|badge",label:"Kind",filter:{type:"select",options:t.Group.GroupKindOptions}},{key:"member_count",label:"Members",sortable:!0,align:"right",visibility:"lg",class:"text-muted"},{key:"is_active|yesnoicon",label:"Enabled",visibility:"xl"},{key:"parent.name",label:"Parent",formatter:"default('-')",visibility:"lg",class:"text-muted fs-8"},{key:"created",label:"Created",className:"text-muted fs-8",formatter:"epoch|datetime",visibility:"xl"},{key:"last_activity",label:"Activity",className:"text-muted fs-8",formatter:"relative",visibility:"xl"}],filters:[{key:"is_active",label:"Active",type:"boolean",trueLabel:"Active",falseLabel:"Inactive"}],searchPlaceholder:"Search name or kind",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 i=this.collection.get(t.dataset.id);this.getApp().setActiveGroup(i)}}t.UserDevice.VIEW_CLASS=DeviceView;class UserDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_user_devices",pageName:"User Devices",router:"admin/user/devices",Collection:t.UserDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search user, IP, or device ID",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"duid",label:"Device ID",sortable:!0,formatter:"truncate_middle(16)",visibility:"xxl"},{key:"user.display_name",label:"User",sortable:!0,formatter:"default('—')"},{key:"device_info.user_agent.family",label:"Browser",formatter:"default('—')",visibility:"lg"},{key:"device_info.os.family",label:"OS",formatter:"default('—')",visibility:"xl"},{key:"last_ip",label:"Last IP",sortable:!0},{key:"first_seen",label:"First Seen",formatter:"epoch|datetime",visibility:"xl"},{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 l.TableView({collection:new c.LoginEventList({params:{size:20}}),searchable:!0,searchPlaceholder:"Search user, IP, or city",sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showExport:!0,dayRangeFilter:!0,...n.groupByDay("created"),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('—')",visibility:"xl"},{key:"country_code",label:"Country",sortable:!0},{key:"source",label:"Source",sortable:!0,visibility:"xl"},{key:"is_new_country",label:"New Country",formatter:"boolean",sortable:!0,width:"110px"}]});t.onTabActivated=async()=>{await(t.collection?.fetch())},this.tabView=new o.TabView({containerId:"tabs",tabs:{Map:e,Logins:t},activeTab:"Map"}),this.addChild(this.tabView)}}const O=e=>{if(!e||"string"!=typeof e||2!==e.length)return"";const t=127462,i=e.toUpperCase();return String.fromCodePoint(t+(i.charCodeAt(0)-65))+String.fromCodePoint(t+(i.charCodeAt(1)-65))},B={none:"success",low:"success",medium:"warning",high:"danger",critical:"danger"};class GeoIPOverviewSection extends t.View{constructor(e={}){super({className:"geoip-overview-section",template:'\n <div class="detail-section-eyebrow">Overview</div>\n\n <div data-container="geoip-overview-status"></div>\n\n <div class="detail-kpi-grid">\n <div data-container="geoip-kpi-threat"></div>\n <div data-container="geoip-kpi-events"></div>\n <div data-container="geoip-kpi-lastseen"></div>\n <div data-container="geoip-kpi-logins"></div>\n </div>\n\n <div class="detail-section-eyebrow">Location &amp; network</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Country</div>\n <div class="detail-flat-row-value">\n {{#hasCountry|bool}}{{{countryDisplay}}}{{/hasCountry|bool}}\n {{^hasCountry|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCountry|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Region · City</div>\n <div class="detail-flat-row-value">\n {{#hasLocation|bool}}{{regionCityDisplay}}{{/hasLocation|bool}}\n {{^hasLocation|bool}}<span class="text-secondary fst-italic">—</span>{{/hasLocation|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Coordinates</div>\n <div class="detail-flat-row-value">\n {{#hasCoords|bool}}<code>{{coordsDisplay}}</code>{{/hasCoords|bool}}\n {{^hasCoords|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCoords|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN · ISP</div>\n <div class="detail-flat-row-value">\n {{#hasAsnOrIsp|bool}}{{{asnIspDisplay}}}{{/hasAsnOrIsp|bool}}\n {{^hasAsnOrIsp|bool}}<span class="text-secondary fst-italic">—</span>{{/hasAsnOrIsp|bool}}\n </div>\n </div>\n\n <div data-container="geoip-overview-map"></div>\n {{#hasCoords|bool}}\n <div class="detail-flat-row-action">\n <button type="button" class="detail-section-action" data-action="open-on-map" data-bs-toggle="tooltip" title="Open on map">\n <i class="bi bi-box-arrow-up-right"></i>\n </button>\n </div>\n {{/hasCoords|bool}}\n ',...e}),this._mapMounted=!1}get hasCountry(){return!(!this.model.get("country_code")&&!this.model.get("country_name"))}get countryDisplay(){const e=this.model.get("country_code")||"",t=this.model.get("country_name")||"",i=O(e);return`${i?`${i} `:""}${this.escapeHtml(t||e||"—")}${e?` <code class="text-secondary small">${this.escapeHtml(e)}</code>`:""}`}get hasLocation(){return!(!this.model.get("region")&&!this.model.get("city"))}get regionCityDisplay(){return[this.model.get("region")||"",this.model.get("city")||""].filter(Boolean).join(" · ")}get hasCoords(){const e=this.model.get("latitude"),t=this.model.get("longitude");return null!=e&&null!=t}get coordsDisplay(){return`${this.model.get("latitude")}, ${this.model.get("longitude")}`}get hasAsnOrIsp(){return!(!this.model.get("asn")&&!this.model.get("isp"))}get asnIspDisplay(){const e=this.model.get("asn"),t=this.model.get("asn_org"),i=this.model.get("isp"),s=[];if(e){const i=`<code>${this.escapeHtml(String(e))}</code>`,a=t?` ${this.escapeHtml(t)}`:"";s.push(`${i}${a}`)}return i&&s.push(this.escapeHtml(i)),s.join(" · ")}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"geoip-overview-status",model:e,tone:e=>this._statusTone(e),state:e=>this._statusState(e),headline:e=>this._statusHeadline(e),meta:e=>this._statusMeta(e),actions:e=>this._statusActions(e)}),this.addChild(this.statusPanel),this.kpiThreat=new n.MetricCard({containerId:"geoip-kpi-threat",label:"Threat score",value:()=>{const e=this.model.get("risk_score");return null!=e?`${e} / 100`:"—"},tone:B[(e.get("threat_level")||"unknown").toLowerCase()]||"default"}),this.kpiEvents=new n.MetricCard({containerId:"geoip-kpi-events",label:"Incident events",value:()=>{const e=this.model.get("event_count")??this.model.get("incident_count");return null!=e?String(e):"—"},tone:(e.get("event_count")??e.get("incident_count")??0)>0?"warning":"default"}),this.kpiLastSeen=new n.MetricCard({containerId:"geoip-kpi-lastseen",label:"Last seen",value:this.model.get("last_seen")?this.model._formatRelative(this.model.get("last_seen")):"—"}),this.kpiLogins=new n.MetricCard({containerId:"geoip-kpi-logins",label:"Login attempts",value:()=>{const e=this.model.get("login_attempts")??this.model.get("login_count");return null!=e?String(e):"—"}}),[this.kpiThreat,this.kpiEvents,this.kpiLastSeen,this.kpiLogins].forEach(e=>this.addChild(e))}_statusTone(e){if(e.get("is_blocked"))return"danger";if(e.get("is_whitelisted"))return"success";const t=(e.get("threat_level")||"").toLowerCase();return e.get("is_threat")||["high","critical"].includes(t)?"danger":e.get("is_suspicious")||"medium"===t?"warning":"success"}_statusState(e){if(e.get("is_blocked"))return"Blocked";if(e.get("is_whitelisted"))return"Whitelisted";const t=(e.get("threat_level")||"").toLowerCase();return e.get("is_threat")||["high","critical"].includes(t)?"Allowed · high risk":e.get("is_suspicious")||"medium"===t?"Allowed · elevated risk":"Allowed"}_statusHeadline(e){if(e.get("is_blocked")){const t=e.get("blocked_reason");return t?`Blocked: ${t}`:"Currently blocked"}if(e.get("is_whitelisted")){const t=e.get("whitelisted_reason");return t?`Whitelisted: ${t}`:"On whitelist"}const t=(e.get("threat_level")||"").toLowerCase();if(e.get("is_threat")||["high","critical"].includes(t))return`Active threat (${t||"high"})`;if(e.get("is_suspicious")||"medium"===t){const i=[];return e.get("is_vpn")&&i.push("VPN"),e.get("is_tor")&&i.push("Tor"),e.get("is_proxy")&&i.push("proxy"),e.get("is_datacenter")&&i.push("datacenter"),i.length?`${i.join(" / ")} signal detected`:"Suspicious"+(t?` · ${t}`:"")}return"No active threat signals"}_statusMeta(e){const t=e.get("risk_score"),i=e.get("last_seen"),s=e=>e?this.model._formatRelative(e):null,a=e=>e?this.model._formatDateTime(e):null;if(e.get("is_blocked")){const t=e.get("blocked_until"),i=e.get("blocked_at");return`Blocked ${t?`until <strong>${this.escapeHtml(a(t)||"")}</strong>`:"permanently"}${i?` · ${this.escapeHtml(s(i)||"")}`:""}`}return e.get("is_whitelisted")?"This IP bypasses the firewall":`Risk score <strong>${this.escapeHtml(String(t??"—"))}</strong>${i?` · last seen ${this.escapeHtml(s(i)||"")}`:""}`}_statusActions(e){const t=[];return e.get("is_blocked")?t.push({label:"Unblock",action:"unblock",icon:"bi-unlock",variant:"outline-success"}):t.push({label:"Block 24h",action:"block",icon:"bi-slash-circle",variant:"danger"}),e.get("is_whitelisted")||t.push({label:"Whitelist",action:"whitelist",icon:"bi-shield-check",variant:"outline-secondary"}),t}async onAfterMount(){await(super.onAfterMount?.());const e=this.model,t=e.get("latitude"),i=e.get("longitude");if(null==t||null==i||this._mapMounted)return;const a=[e.get("city")||"",e.get("region")||"",e.get("country_name")||""].filter(Boolean).join(", ");this.mapView=new s.MapView({containerId:"geoip-overview-map",markers:[{lat:t,lng:i,popup:`<strong>${this.escapeHtml(e.get("ip_address")||"")}</strong><br>${this.escapeHtml(a)}`}],tileLayer:"light",zoom:4,height:200}),this.addChild(this.mapView),await this.mapView.render(),this._mapMounted=!0}async onActionOpenOnMap(){const e=this.model.get("latitude"),t=this.model.get("longitude");null!=e&&null!=t&&window.open(`https://www.google.com/maps/search/?api=1&query=${e},${t}`,"_blank")}}class GeoIPNetworkSection extends t.View{constructor(e={}){super({className:"geoip-network-section",template:'\n <div class="detail-section-eyebrow">Identity</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">IP address</div>\n <div class="detail-flat-row-value"><code>{{model.ip_address|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">IP version</div>\n <div class="detail-flat-row-value">{{model.ip_version|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Subnet</div>\n <div class="detail-flat-row-value">\n {{#model.subnet}}<code>{{model.subnet}}</code>{{/model.subnet}}\n {{^model.subnet}}<span class="text-secondary fst-italic">—</span>{{/model.subnet}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reverse DNS</div>\n <div class="detail-flat-row-value">\n {{#model.reverse_dns}}<code class="small">{{model.reverse_dns}}</code>{{/model.reverse_dns}}\n {{^model.reverse_dns}}<span class="text-secondary fst-italic">—</span>{{/model.reverse_dns}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Carrier · ASN · ISP</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN</div>\n <div class="detail-flat-row-value">\n {{#model.asn}}<code>{{model.asn}}</code>{{/model.asn}}\n {{^model.asn}}<span class="text-secondary fst-italic">—</span>{{/model.asn}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ASN org</div>\n <div class="detail-flat-row-value">{{model.asn_org|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ISP</div>\n <div class="detail-flat-row-value">{{model.isp|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Connection</div>\n <div class="detail-flat-row-value">{{model.connection_type|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Mobile carrier</div>\n <div class="detail-flat-row-value">{{model.mobile_carrier|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Hosting flags</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Cloud provider</div>\n <div class="detail-flat-row-value">{{{model.is_cloud|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Datacenter</div>\n <div class="detail-flat-row-value">{{{model.is_datacenter|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Mobile</div>\n <div class="detail-flat-row-value">{{{model.is_mobile|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">VPN</div>\n <div class="detail-flat-row-value">{{{model.is_vpn|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Tor exit</div>\n <div class="detail-flat-row-value">{{{model.is_tor|yesnoicon}}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Proxy</div>\n <div class="detail-flat-row-value">{{{model.is_proxy|yesnoicon}}}</div>\n </div>\n ',...e})}}class GeoIPRiskSection extends t.View{constructor(e={}){super({className:"geoip-risk-section",template:'\n <div class="detail-section-eyebrow">Summary</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Threat level</div>\n <div class="detail-flat-row-value">\n <span class="badge text-bg-{{threatLevelTone}}">{{threatLevelLabel}}</span>\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Risk score</div>\n <div class="detail-flat-row-value">\n {{#hasScore|bool}}<strong>{{model.risk_score}}</strong> / 100{{/hasScore|bool}}\n {{^hasScore|bool}}<span class="text-secondary fst-italic">—</span>{{/hasScore|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Provider</div>\n <div class="detail-flat-row-value">{{model.provider|default:\'unknown\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Last checked</div>\n <div class="detail-flat-row-value">{{model.last_seen|relative|default:\'—\'}}</div>\n </div>\n\n <div class="detail-section-eyebrow">Reputation flags</div>\n {{#firedFlags.length}}\n {{#firedFlags}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">{{label}}</div>\n <div class="detail-flat-row-value">\n <span class="badge text-bg-{{tone}}"><i class="bi {{icon}} me-1"></i>{{title}}</span>\n {{#detail}}<span class="text-secondary">· {{detail}}</span>{{/detail}}\n </div>\n </div>\n {{/firedFlags}}\n {{/firedFlags.length}}\n {{^firedFlags.length}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value text-secondary fst-italic">No reputation flags fired.</div>\n </div>\n {{/firedFlags.length}}\n ',...e})}get threatLevelLabel(){return this.model.get("threat_level")||"unknown"}get threatLevelTone(){const e=(this.model.get("threat_level")||"").toLowerCase();return B[e]||"secondary"}get hasScore(){return null!=this.model.get("risk_score")}get firedFlags(){const e=this.model;return[{key:"is_threat",label:"threat",icon:"bi-shield-exclamation",tone:"danger",title:"Active threat",detail:"Marked as an active threat"},{key:"is_suspicious",label:"suspicious",icon:"bi-question-octagon",tone:"warning",title:"Suspicious",detail:"Flagged suspicious by enrichment"},{key:"is_known_attacker",label:"attacker",icon:"bi-exclamation-octagon-fill",tone:"danger",title:"Known attacker",detail:"Recorded in attacker feeds"},{key:"is_known_abuser",label:"abuser",icon:"bi-exclamation-triangle-fill",tone:"danger",title:"Known abuser",detail:"Recorded in abuse feeds"},{key:"is_vpn",label:"vpn",icon:"bi-shield-shaded",tone:"warning",title:"VPN exit",detail:"Detected as a VPN exit node"},{key:"is_tor",label:"tor",icon:"bi-shield-lock",tone:"danger",title:"Tor exit",detail:"Detected as a Tor exit node"},{key:"is_proxy",label:"proxy",icon:"bi-diagram-3",tone:"warning",title:"Open proxy",detail:"Detected as an open proxy"}].filter(t=>!!e.get(t.key))}}class GeoIPBlockSection extends t.View{constructor(e={}){super({className:"geoip-block-section",template:'\n <div class="detail-section-eyebrow">\n Block\n <div class="detail-flat-row-action">\n {{#model.is_blocked|bool}}\n <button type="button" class="detail-section-action" data-action="unblock" data-bs-toggle="tooltip" title="Unblock"><i class="bi bi-unlock"></i></button>\n {{/model.is_blocked|bool}}\n {{^model.is_blocked|bool}}\n <button type="button" class="detail-section-action" data-action="block" data-bs-toggle="tooltip" title="Block IP"><i class="bi bi-slash-circle"></i></button>\n {{/model.is_blocked|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_blocked|bool}}<span class="badge text-bg-danger"><i class="bi bi-slash-circle me-1"></i>Blocked</span>{{/model.is_blocked|bool}}\n {{^model.is_blocked|bool}}<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Allowed</span>{{/model.is_blocked|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reason</div>\n <div class="detail-flat-row-value">{{model.blocked_reason|default:\'—\'}}</div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Blocked at</div>\n <div class="detail-flat-row-value">\n {{#model.blocked_at}}<code>{{model.blocked_at|datetime}}</code>{{/model.blocked_at}}\n {{^model.blocked_at}}<span class="text-secondary fst-italic">—</span>{{/model.blocked_at}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Blocked until</div>\n <div class="detail-flat-row-value">\n {{#model.blocked_until}}<code>{{model.blocked_until|datetime}}</code>{{/model.blocked_until}}\n {{^model.blocked_until}}<span class="text-secondary fst-italic">Permanent / —</span>{{/model.blocked_until}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Block count</div>\n <div class="detail-flat-row-value">{{blockCountDisplay}}</div>\n </div>\n\n <div class="detail-section-eyebrow">\n Whitelist\n <div class="detail-flat-row-action">\n {{#model.is_whitelisted|bool}}\n <button type="button" class="detail-section-action" data-action="unwhitelist" data-bs-toggle="tooltip" title="Remove whitelist"><i class="bi bi-x-circle"></i></button>\n {{/model.is_whitelisted|bool}}\n {{^model.is_whitelisted|bool}}\n <button type="button" class="detail-section-action" data-action="whitelist" data-bs-toggle="tooltip" title="Whitelist"><i class="bi bi-shield-check"></i></button>\n {{/model.is_whitelisted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value">\n {{#model.is_whitelisted|bool}}<span class="badge text-bg-info"><i class="bi bi-shield-check me-1"></i>Whitelisted</span>{{/model.is_whitelisted|bool}}\n {{^model.is_whitelisted|bool}}<span class="badge text-bg-secondary">Not whitelisted</span>{{/model.is_whitelisted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Reason</div>\n <div class="detail-flat-row-value">{{model.whitelisted_reason|default:\'—\'}}</div>\n </div>\n ',...e})}get blockCountDisplay(){const e=this.model.get("block_count");return null!=e?String(e):"0"}async onActionBlock(){this.emit("action:block")}async onActionUnblock(){this.emit("action:unblock")}async onActionWhitelist(){this.emit("action:whitelist")}async onActionUnwhitelist(){this.emit("action:unwhitelist")}}class GeoIPActivitySection extends t.View{constructor(e={}){const{eventsTable:t,logsTable:i,...s}=e;super({className:"geoip-activity-section",template:'\n <div class="detail-section-eyebrow">Activity</div>\n <div data-container="geoip-activity-tabs"></div>\n ',...s}),this.eventsTable=t,this.logsTable=i}async onInit(){const e={};this.eventsTable&&(e.Events=this.eventsTable),this.logsTable&&(e.Logs=this.logsTable),this.tabView=new o.TabView({containerId:"geoip-activity-tabs",tabs:e,activeTab:"Events"}),this.addChild(this.tabView)}}class GeoIPMetadataSection extends t.View{constructor(e={}){super({className:"geoip-metadata-section",template:'\n <div class="detail-section-eyebrow">Metadata</div>\n <div data-container="geoip-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"geoip-metadata-card",model:this.model,data:e=>e.attributes||{},knownKeys:[{key:"id",label:"Record ID",formatter:e=>null!=e?`<code>${this.escapeHtml(String(e))}</code>`:'<span class="text-secondary fst-italic">—</span>'},{key:"provider",label:"Provider"},{key:"created",label:"Created",formatter:"datetime"},{key:"modified",label:"Modified",formatter:"datetime"},{key:"last_seen",label:"Last seen",formatter:"datetime"},{key:"expires_at",label:"Expires at",formatter:"datetime"}],rawLabel:"Raw record JSON",rawCollapsed:!0}),this.addChild(this.knownFields)}}class GeoIPView extends n.DetailView{constructor(e={}){const t=e.model||new n.GeoLocatedIP(e.data||{}),i=t.get("ip_address");t._formatRelative||(t._formatRelative=e=>{if(null==e)return"";const t="number"==typeof e&&e<1e11?1e3*e:new Date(e).getTime();if(!Number.isFinite(t))return"";const i=Math.round((Date.now()-t)/1e3);return i<0?"just now":i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`},t._formatDateTime=e=>{if(null==e)return"";const t="number"==typeof e&&e<1e11?1e3*e:new Date(e).getTime();return Number.isFinite(t)?new Date(t).toLocaleString():""});const s=new c.IncidentEventList({params:{source_ip:i,size:25,sort:"-created"}}),a=new l.LogList({params:{ip:i,size:25,sort:"-created"}}),o=new GeoIPOverviewSection({model:t}),r=new GeoIPNetworkSection({model:t}),d=new GeoIPRiskSection({model:t}),h=new GeoIPBlockSection({model:t}),u=new GeoIPMetadataSection({model:t}),m=new l.TableView({collection:s,title:"Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["source_ip"],columns:[{key:"id",label:"ID",sortable:!0,width:"60px"},{key:"created",label:"Date",formatter:"datetime",sortable:!0,width:"160px"},{key:"category|badge",label:"Category"},{key:"title",label:"Title"}]}),p=new l.TableView({collection:a,title:"Logs",permissions:"view_logs",showFullscreen:!1,searchable:!1,hideActivePillNames:["ip"],columns:[{key:"created",label:"Timestamp",sortable:!0,formatter:"epoch|datetime",width:"180px",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,width:"90px",filter:{type:"select",options:[{value:"info",label:"Info"},{value:"warning",label:"Warning"},{value:"error",label:"Error"}]}},{key:"kind",label:"Kind",width:"120px",filter:{type:"text"}},{key:"log",label:"Log"}]}),b=new GeoIPActivitySection({model:t,eventsTable:m,logsTable:p}),g=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:o},{key:"Network",label:"Network",icon:"bi-diagram-3",view:r},{key:"Risk",label:"Risk & Reputation",icon:"bi-shield-exclamation",view:d},{type:"divider",label:"Enforcement"},{key:"Block",label:"Block & Whitelist",icon:"bi-slash-circle",view:h},{type:"divider",label:"Activity"},{key:"Activity",label:"Activity",icon:"bi-list-ul",view:b,permissions:"view_logs"},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:u}],v=[{text:e=>{const t=e.get("country_code"),i=O(t),s=e.get("country_name");return t||s?`${i?`${i} `:""}${s||t}`:null},variant:"light",when:e=>!(!e.get("country_code")&&!e.get("country_name"))},{icon:"bi-exclamation-triangle-fill",text:e=>`Threat: ${e.get("threat_level")}`,variant:"danger",when:e=>["high","critical"].includes((e.get("threat_level")||"").toLowerCase())},{icon:"bi-exclamation-triangle",text:e=>`Threat: ${e.get("threat_level")}`,variant:"warning",when:e=>"medium"===(e.get("threat_level")||"").toLowerCase()},{icon:"bi-shield-check",text:e=>`Threat: ${e.get("threat_level")}`,variant:"light",when:e=>{const t=(e.get("threat_level")||"").toLowerCase();return t&&!["high","critical","medium"].includes(t)}},{text:e=>null!=e.get("risk_score")?`Risk score ${e.get("risk_score")}`:null,variant:"light",when:e=>null!=e.get("risk_score")},{icon:"bi-shield-shaded",text:"VPN",variant:"warning",when:e=>!!e.get("is_vpn")},{icon:"bi-shield-lock",text:"Tor",variant:"danger",when:e=>!!e.get("is_tor")},{icon:"bi-diagram-3",text:"Proxy",variant:"warning",when:e=>!!e.get("is_proxy")},{icon:"bi-cloud-fill",text:"Cloud",variant:"info",when:e=>!!e.get("is_cloud")},{icon:"bi-hdd-stack",text:"Datacenter",variant:"warning",when:e=>!!e.get("is_datacenter")},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",when:e=>!!e.get("is_blocked")},{icon:"bi-shield-check",text:"Whitelisted",variant:"success",when:e=>!!e.get("is_whitelisted")}];super({className:"geoip-view",...e,model:t,header:{icon:"bi-globe2",iconToneFn:e=>{const t=!!e.get("is_blocked"),i=!!e.get("is_threat"),s=!!e.get("is_suspicious"),a=(e.get("threat_level")||"").toLowerCase();return t||i||["high","critical"].includes(a)?"danger":s||"medium"===a?"warning":"info"},titleFn:e=>e.get("ip_address")||"—",subtitlePath:"_subtitle",chips:v,actions:[],contextMenu:{items:[{label:"Refresh geolocation",action:"refresh-geoip",icon:"bi-arrow-clockwise"},{label:"Refresh threat data",action:"threat-analysis",icon:"bi-shield-exclamation"},{label:"View on map",action:"view-on-map",icon:"bi-map"},{type:"divider"},{label:"Edit Location",action:"edit-location",icon:"bi-geo-alt"},{label:"Edit Network",action:"edit-network",icon:"bi-diagram-3"},{label:"Edit Security",action:"edit-security",icon:"bi-shield-lock"},{type:"divider"},{label:"Block 24h",action:"block-ip",icon:"bi-slash-circle"},{label:"Unblock",action:"unblock-ip",icon:"bi-unlock"},{label:"Whitelist",action:"whitelist-ip",icon:"bi-shield-check"},{label:"Remove whitelist",action:"unwhitelist-ip",icon:"bi-x-circle"},{type:"divider"},{label:"Delete record",action:"delete-geoip",icon:"bi-trash",danger:!0}]}},sections:g,activeSection:"Overview"}),this.eventsCollection=s,this.logsCollection=a,this.overviewSection=o,this.networkSection=r,this.riskSection=d,this.blockSection=h,this.activitySection=b,this.metadataSection=u,this.eventsTable=m,this.logsTable=p,this._refreshComputedFields()}async onAfterBuild(){this.blockSection.on("action:block",()=>this.onActionBlockIp()),this.blockSection.on("action:unblock",()=>this.onActionUnblockIp()),this.blockSection.on("action:whitelist",()=>this.onActionWhitelistIp()),this.blockSection.on("action:unwhitelist",()=>this.onActionUnwhitelistIp());const e=()=>{const e=this.eventsCollection.totalCount??this.eventsCollection.models?.length??0,t=e+(this.logsCollection.totalCount??this.logsCollection.models?.length??0),i=e>10?"warning":"muted";this.setBadge("Activity",t>0?{text:String(t),variant:i}:null)};this.eventsCollection.on("fetch:success",e,this),this.logsCollection.on("fetch:success",e,this),this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{})}async onActionBlock(){return this.onActionBlockIp()}async onActionUnblock(){return this.onActionUnblockIp()}async onActionWhitelist(){return this.onActionWhitelistIp()}_refreshComputedFields(){const e=this.model,t=[],i=[e.get("city"),e.get("region"),e.get("country_name")].filter(Boolean).join(", ");i&&t.push(i);const s=e.get("asn"),a=e.get("isp");if(s||a){const e=s?`ASN ${s}`:"",i=a?` (${a})`:"";t.push(`${e}${i}`.trim())}const n=e.get("reverse_dns");n&&t.push(`reverse ${n}`),e.attributes._subtitle=t.length?t.join(" · "):""}async _refreshFromModel(){this._refreshComputedFields(),this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render(),this.riskSection?.isMounted()&&await this.riskSection.render(),this.blockSection?.isMounted()&&await this.blockSection.render()}async onActionRefreshGeoip(){try{await this.model.save({refresh:!0}),this.getApp()?.toast?.info(`Refresh requested for ${this.model.get("ip_address")}`),await this.model.fetch(),await this._refreshFromModel()}catch(e){this.getApp()?.toast?.error(e.message||"Failed to refresh geolocation")}}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(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to refresh threat data")}finally{t&&(t.disabled=!1)}return!0}async onActionViewOnMap(){const e=this.model.get("latitude"),t=this.model.get("longitude");if(null==e||null==t)return void this.getApp()?.toast?.warning("No coordinates available for this IP");const i=`https://www.google.com/maps/search/?api=1&query=${e},${t}`;window.open(i,"_blank")}async onActionEditLocation(){await a.Modal.modelForm({title:`Edit Location — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_LOCATION_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Location updated"))}async onActionEditSecurity(){await a.Modal.modelForm({title:`Edit Security — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_SECURITY_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Security settings updated"))}async onActionEditNetwork(){await a.Modal.modelForm({title:`Edit Network — ${this.model.get("ip_address")}`,model:this.model,formConfig:n.GeoLocatedIP.EDIT_NETWORK_FORM})&&(await this._refreshFromModel(),this.getApp()?.toast?.success("Network information updated"))}async onActionBlockIp(){const e=await a.Modal.form({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:0}]});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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await a.Modal.form({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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await a.Modal.form({title:"Whitelist IP",icon:"bi-shield-check",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"),await this.model.fetch(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}async onActionUnwhitelistIp(){if(!(await a.Modal.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(),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to remove from whitelist"),!0}async onActionDeleteGeoip(){if(!(await a.Modal.confirm(`Are you sure you want to delete the GeoIP record for "${this.model.get("ip_address")}"?`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})))return;const e=await this.model.destroy();if(e?.success){this.getApp()?.toast?.success("GeoIP record deleted");const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}this.emit("geoip:deleted",{model:this.model})}}static async show(e){const t=await n.GeoLocatedIP.lookup(e);if(t){const e=new GeoIPView({model:t});return void(await a.Modal.detail(e))}a.Modal.alert({message:`Could not find geolocation data for IP: ${e}`,type:"warning"})}}GeoIPView.VIEW_CLASS=GeoIPView,n.GeoLocatedIP.VIEW_CLASS=GeoIPView,n.GeoLocatedIP.MODEL_REF="account.GeoLocatedIP",n.GeoLocatedIP.VIEW_CLASS=GeoIPView;class GeoLocatedIPTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_system_geoip",pageName:"GeoIP Cache",router:"admin/system/geoip",Collection:n.GeoLocatedIPList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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:"boolean"},{key:"is_vpn",label:"VPN",type:"boolean"},{key:"is_tor",label:"TOR",type:"boolean"}],searchPlaceholder:"Search IP, country, or ISP",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 n.GeoLocatedIP.lookup(e.ip);t&&await this.showItemDialog(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 &lt;token&gt;</code>\n </p>\n </div>\n </div>\n </div>\n '}async onInit(){const t=this.model.get("is_active"),i=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(i)}async onActionEditKey(){const e=this.getApp();await e.showModelForm({title:`Edit API Key — ${this.model.get("name")}`,model:this.model,formConfig:$.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=$.create,ApiKey.EDIT_FORM=$.edit;class ApiKeyTablePage extends n.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,searchPlaceholder:"Search name or group",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,i=await e.showForm({model:t,...$.create});if(!i)return;const s=await t.save(i);if(!s?.data?.status)return void e.showError(s?.data?.error||"Failed to create API key");const a=s.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 j=[{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 H(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"}),this.pageSubtitle="AWS CloudWatch resource monitoring"}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 <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h1 class="h3 mb-1">CloudWatch Monitoring</h1>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n </div>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all charts">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n <div class="cw-grid" id="cw-grid">\n ${j.map((e,t)=>`<div id="cw-chart-${t}"></div>`).join("")}\n </div>\n </div>\n `}async onInit(){this.charts=[];for(let e=0;e<j.length;e++){const t=j[e],i=new CloudWatchChart({containerId:`cw-chart-${e}`,account:t.account,category:t.category,title:t.title,height:160,yAxis:H(t.unit),responsive:!0,showGranularity:!0,showDateRange:!0,defaultDateRange:"24h",granularity:"hours"});this.addChild(i,{lazyMount:e>=4}),this.charts.push(i)}}async onEnter(){await super.onEnter(),this.scheduleRefresh(()=>this._refreshMounted(),3e5,{tier:"slow"})}async _refreshMounted(){await Promise.allSettled(this.charts.filter(e=>e&&(!e._lazyMount||e._lazyTriggered)&&"function"==typeof e.refresh).map(e=>e.refresh()))}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}}class StatusStripPanel extends t.View{constructor(e={}){super({...e,className:`sd-status-strip-panel ${e.className||""}`.trim()})}async getTemplate(){return'\n <div class="sd-section-head">\n <h2 class="sd-eyebrow">Pulse</h2>\n <span class="sd-section-meta text-muted small">Today · vs yesterday</span>\n </div>\n <div data-container="strip"></div>\n '}async onInit(){this.strip=new KPIStrip({containerId:"strip",account:"incident",granularity:"days",sparklineDays:7,tiles:[{rest:{endpoint:"/api/incident/incident",params:{status:"new",_mode:"count"}},sparklineSlug:"incidents",key:"new-incidents",label:"New Incidents",severity:"critical",tone:"bad"},{slug:"auth:failures",label:"Failed Auth",tone:"bad",severity:"warn"},{slug:"incidents",label:"Incidents",tone:"bad"},{slug:"incident_events",label:"Events",tone:"bad"},{slug:"firewall:blocks",label:"Firewall Blocks",tone:"bad"},{slug:"bouncer:blocks",label:"Bouncer Blocks",tone:"bad"},{slug:"login:new_country",label:"New-Country Logins",tone:"bad"},{rest:{endpoint:"/api/system/geoip",params:{is_blocked:!0,_mode:"count"}},key:"active-blocks",label:"Active Blocks",tone:"bad"}]}),this.strip.on?.("tile:click",e=>this._onTileClick(e)),this.addChild(this.strip)}refresh(){return this.strip?.refresh()}_onTileClick({slug:e,key:t}){"new-incidents"!==t?"active-blocks"!==t?e&&this._openHistoryDrawer(e):this.getApp()?.showPage?.("system/system/geoip",{is_blocked:"true"}):this.getApp()?.showPage?.("system/incidents",{status:"new"})}_openHistoryDrawer(e){const t=new i.MetricsChart({slugs:[e],account:"incident",granularity:"days",defaultDateRange:"30d",chartType:"line",compactHeader:!0,showGranularity:!1,showTypeSwitch:!1,showDateRange:!1,showLegend:!1,height:280});a.Modal.drawer({eyebrow:"Metric History",title:this._humanizeSlug(e),meta:[{icon:"bi bi-calendar3",text:"Last 30 days"},{icon:"bi bi-bar-chart-line",text:"Daily buckets"}],view:t,size:"lg"})}_humanizeSlug(e){return String(e).split(/[:_]/).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(" ")}}class EventListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item event-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled event\')}}</div>\n <div class="ili-eyebrow">{{model.created|relative}}</div>\n </div>\n <div class="ili-row ili-meta">\n {{#hasCategory|bool}}<span class="ili-chip ili-chip-cat">{{model.category}}</span>{{/hasCategory|bool}}\n {{#hasSource|bool}}<span class="ili-meta-text">{{source}}</span>{{/hasSource|bool}}\n {{#hasScope|bool}}<span class="ili-meta-dim">{{model.scope}}</span>{{/hasScope|bool}}\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get source(){return this.model?.get("hostname")||this.model?.get("source_ip")||""}get hasSource(){return!!this.source}get hasCategory(){return!!this.model?.get("category")}get hasScope(){const e=this.model?.get("scope");return!!e&&e!==this.model?.get("category")}}const U={new:"info",open:"success",in_progress:"warning",pending:"warning",paused:"warning",qa:"info",resolved:"muted",closed:"muted",ignored:"muted"},z={security:"danger",incident:"danger",bug:"warning",qa:"warning",feature:"primary",ticket:"primary",fulfillment:"success",new_user:"muted",new_group:"muted"};class TicketListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item ticket-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled ticket\')}}</div>\n {{#hasStatus|bool}}<span class="ili-chip ili-chip-{{statusTone}}">{{model.status}}</span>{{/hasStatus|bool}}\n </div>\n <div class="ili-row ili-meta">\n {{#hasPriority|bool}}<span class="ili-pri ili-pri-{{priorityTone}}">{{model.priority}}</span>{{/hasPriority|bool}}\n {{#hasCategory|bool}}<span class="ili-meta-text"><span class="ili-dot ili-dot-{{categoryTone}}"></span>{{categoryLabel}}</span>{{/hasCategory|bool}}\n <span>{{model.created|relative}}</span>\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get hasStatus(){return!!this.model?.get("status")}get statusTone(){return U[this.model?.get("status")]||"muted"}get _priority(){return parseInt(this.model?.get("priority"),10)}get hasPriority(){return Number.isFinite(this._priority)}get priorityTone(){const e=this._priority;return Number.isFinite(e)?e>=8?"hi":e>=5?"md":"lo":"lo"}get hasCategory(){return!!this.model?.get("category")}get categoryTone(){return z[this.model?.get("category")]||"muted"}get categoryLabel(){return String(this.model?.get("category")||"").replace(/_/g," ")}}const q={new:"info",open:"success",paused:"warning",qa:"info",resolved:"muted",ignored:"muted"};class IncidentListItem extends r.ListViewItem{constructor(e={}){super({className:"list-view-item incident-list-item",...e}),this.template='\n <div class="ili-card">\n <div class="ili-row">\n <div class="ili-title" title="{{model.title}}">{{model.title|default(\'Untitled incident\')}}</div>\n {{#hasStatus|bool}}<span class="ili-chip ili-chip-{{statusTone}}">{{model.status}}</span>{{/hasStatus|bool}}\n </div>\n <div class="ili-row ili-meta">\n {{#hasPriority|bool}}<span class="ili-pri ili-pri-{{priorityTone}}">{{model.priority}}</span>{{/hasPriority|bool}}\n {{#hasMetaLine|bool}}<span class="ili-meta-text">{{metaLine}}</span>{{/hasMetaLine|bool}}\n <span>{{model.created|relative}}</span>\n <span class="ili-id">#{{model.id}}</span>\n </div>\n </div>\n '}get hasStatus(){return!!this.model?.get("status")}get statusTone(){return q[this.model?.get("status")]||"muted"}get _priority(){return parseInt(this.model?.get("priority"),10)}get hasPriority(){return Number.isFinite(this._priority)}get priorityTone(){const e=this._priority;return Number.isFinite(e)?e>=8?"hi":e>=5?"md":"lo":"lo"}get metaLine(){const e=this.model?.get("scope"),t=this.model?.get("category"),i=[e,t].filter(e=>e&&""!==e);return[...new Set(i)].join(" · ")}get hasMetaLine(){return!!this.metaLine}}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 i="";return t.forEach((e,t)=>{if(!e.trim())return void(i+='<div class="stack-trace-line">&nbsp;</div>');if(0===t&&(e.includes("Error:")||e.includes("Exception:")))return void(i+=`<div class="stack-trace-line stack-trace-error">${this.escapeHtml(e)}</div>`);let s=e.match(/(.+?)\s*\(([^:]+):(\d+):(\d+)\)/);if(s){const[,e,t,a,n]=s;return void(i+=`<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(s=e.match(/^\s*at\s+([^:]+):(\d+):(\d+)/),s){const[,e,t,a]=s;return void(i+=`<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(s=e.match(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)/),s){const[,e,t,a]=s;return void(i+=`<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 ")?i+=`<div class="stack-trace-line stack-trace-file">${this.escapeHtml(e)}</div>`:i+=`<div class="stack-trace-line stack-trace-context">${this.escapeHtml(e)}</div>`}),i}updateStackTrace(e){this.stackTrace=e,this.render()}}const G=[{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 ${G.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,i]=e;this.selectedType=t,this.fieldValues={};const s=G.find(e=>e.value===t);if(s)if(i.startsWith("?")){const e=new URLSearchParams(i);for(const t of s.fields){const i=e.get(t.name);null!==i&&(this.fieldValues[t.name]=i)}}else 1===s.fields.length&&(this.fieldValues[s.fields[0].name]=i)}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 i=G.find(e=>e.value===this.selectedType);if(i)for(const s of i.fields)void 0!==s.default&&(this.fieldValues[s.name]=s.default);return this._renderFields(),this._updatePreview(),this._emitChange(),!0}_renderFields(){const e=this.element?.querySelector("#hb-fields");if(!e)return;const t=G.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 i=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">${i}</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,i=t.dataset.field;return i&&(this.fieldValues[i]=t.value,this._updatePreview(),this._emitChange()),!0}_updatePreview(){const e=this.element?.querySelector("#hb-preview");if(!e)return;const t=G.find(e=>e.value===this.selectedType);if(!t)return void(e.style.display="none");const i=t.build(this.fieldValues),s=t.preview(this.fieldValues);e.style.display="block",e.innerHTML=`\n <div class="hb-desc"><i class="bi ${t.icon} me-1"></i>${s}</div>\n <div class="hb-raw"><code>${i}</code></div>\n `}_emitChange(){const e=this.getValue();this.emit("change",e)}getValue(){const e=G.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()}}}const J={block:{label:"Block IP",icon:"bi-slash-circle",tone:"danger"},email:{label:"Email",icon:"bi-envelope",tone:"info"},sms:{label:"SMS",icon:"bi-chat-dots",tone:"info"},notify:{label:"Push notification",icon:"bi-bell",tone:"info"},ticket:{label:"Create ticket",icon:"bi-ticket-detailed",tone:"warning"},job:{label:"Run job",icon:"bi-gear-wide-connected",tone:"primary"},llm:{label:"LLM triage",icon:"bi-stars",tone:"primary"}};function W(e){return e&&"string"==typeof e?e.split(",").map(e=>e.trim()).filter(Boolean).map(e=>{const t=e.match(/^([a-zA-Z]+):\/\/(.*)$/),i=t?t[1].toLowerCase():null,s=i&&J[i]?J[i]:{label:e,icon:"bi-question-circle",tone:"default"};return{raw:e,scheme:i,label:s.label,icon:s.icon,tone:s.tone,detail:t?t[2]:""}}):[]}function K(e){if(!e)return"";const t=Math.floor(Date.now()/1e3)-Number(e);return t<60?"just now":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)}h ago`:`${Math.floor(t/86400)}d ago`}function Y(e){const t=c.BundleByOptions.find(t=>t.value===e);return t?t.label:0===e||void 0===e?"No bundling":String(e)}function Q(e){const t=c.BundleMinutesOptions.find(t=>t.value===e);return t?t.label:null==e?"No limit — bundle forever":`${e} minutes`}class RuleSetOverviewSection extends t.View{constructor(e={}){super({className:"ruleset-overview-section",template:'\n <div class="ruleset-kpi-grid mb-2">\n <div data-container="kpi-status"></div>\n <div data-container="kpi-incidents"></div>\n <div data-container="kpi-last-fired"></div>\n <div data-container="kpi-match"></div>\n </div>\n <div class="ruleset-overview-pair">\n <div class="card ruleset-overview-card">\n <h6><i class="bi bi-funnel me-1"></i>What triggers this rule</h6>\n <ul>\n <li>Event category is <code>{{model.category}}</code></li>\n <li>{{bundleSummary}}</li>\n <li>{{thresholdSummary}}</li>\n <li>{{retriggerSummary}}</li>\n </ul>\n </div>\n <div class="card ruleset-overview-card">\n <h6><i class="bi bi-tools me-1"></i>What happens when it fires</h6>\n <ul id="overview-handler-summary">\x3c!-- filled in onInit --\x3e</ul>\n </div>\n </div>\n ',...e}),this.incidentsCollection=e.incidentsCollection||null}async onInit(){const e=this.model,t=!!e.get("is_active"),i=e.get("trigger_count"),s=e.get("trigger_window"),a=e.get("retrigger_every"),o=e.get("bundle_by"),l=e.get("bundle_minutes");this.bundleSummary=0===o||void 0===o?"Each event creates its own incident (no bundling)":`Bundles by ${Y(o).replace(/^By\s+/i,"").toLowerCase()} for ${Q(l).toLowerCase()}`,this.thresholdSummary=null==i||0===i?"Fires immediately on the first event":null==s?`Fires after ${i} events accumulate`:`Fires after ${i} events within ${s} minutes`,this.retriggerSummary=null==a?"Fires once per bundle (no re-trigger)":`Re-fires every ${a} additional events`;const r=this._readIncidentCount(),c=this._readLastFired();this.kpiStatus=new n.MetricCard({containerId:"kpi-status",label:"Status",value:t?"Active":"Inactive",valueIcon:t?"bi-check-circle-fill":"bi-pause-circle-fill",tone:t?"success":"default"}),this.kpiIncidents=new n.MetricCard({containerId:"kpi-incidents",label:"Incidents (30d)",value:null==r?"—":r,tone:r>0?"warning":"default",action:"view-incidents"}),this.kpiLastFired=new n.MetricCard({containerId:"kpi-last-fired",label:"Last fired",value:c?K(c):"Never"}),this.kpiMatch=new n.MetricCard({containerId:"kpi-match",label:"Match logic",value:this._shortMatchLabel(e.get("match_by"))}),this.addChild(this.kpiStatus),this.addChild(this.kpiIncidents),this.addChild(this.kpiLastFired),this.addChild(this.kpiMatch),this.incidentsCollection&&this.incidentsCollection.on("fetch:success",()=>this._refreshFromCollection(),this)}_shortMatchLabel(e){return 1===e?"ANY rule matches":"ALL rules match"}_readIncidentCount(){return this.incidentsCollection?this.incidentsCollection.totalCount??this.incidentsCollection.models?.length??null:null}_readLastFired(){const e=this.incidentsCollection?.models?.[0];return e?.get?.("created")??null}_refreshFromCollection(){const e=this._readIncidentCount(),t=this._readLastFired();this.kpiIncidents&&this.kpiIncidents.setValue(null==e?"—":e),this.kpiLastFired&&this.kpiLastFired.setValue(t?K(t):"Never")}async onAfterRender(){await super.onAfterRender();const e=this.element?.querySelector("#overview-handler-summary");if(!e)return;const t=W(this.model.get("handler"));if(!t.length)return void(e.innerHTML="<li>No handler configured — incidents are recorded but no action is taken</li>");const i=t.some(e=>"llm"===e.scheme),s=t.map(e=>`<li><i class="bi ${e.icon} me-1 text-body-secondary"></i>${this.escapeHtml(e.label)}${e.detail?` <code class="small">${this.escapeHtml(e.detail)}</code>`:""}</li>`);i||s.push('<li class="text-body-secondary"><i class="bi bi-stars me-1"></i>No LLM triage configured — <a href="#" data-action="go-agent">add an agent prompt</a></li>'),e.innerHTML=s.join("")}async onActionGoAgent(e){e.preventDefault(),this.emit("navigate","Agent")}async onActionViewIncidents(e){e?.preventDefault?.(),this.emit("navigate","Incidents")}}class RuleSetConditionsSection extends t.View{constructor(e={}){super({className:"ruleset-conditions-section",template:'<div data-container="conditions-table"></div>',...e}),this.rulesetId=e.rulesetId,this.collection=e.collection}async onInit(){this.tableView=new l.TableView({containerId:"conditions-table",collection:this.collection,title:"Conditions",eyebrow:this._buildEyebrowLabel(),showFullscreen:!1,searchable:!1,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["parent"],columns:[{key:"id",label:"ID",width:"60px",template:'<span class="text-body-tertiary font-monospace">{{model.id}}</span>'},{key:"name",label:"Name"},{key:"field_name",label:"Field",template:"<code>{{model.field_name}}</code>"}],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.rulesetId}}),this.addChild(this.tableView),this.collection.on("fetch:success",()=>this._updateCount(),this),this.collection.models?.length&&this._updateCount()}_buildEyebrowLabel(){const e=this.collection?.models?.length??0,t=this.parent?.model?.get("match_by")??this.model?.get?.("match_by");return 0===e?"Loading conditions…":`${e} ${1===e?"condition":"conditions"} · ${1===t?"ANY must match":"ALL must match"}`}_updateCount(){this.tableView?.setEyebrow(this._buildEyebrowLabel()),this.emit("count:changed",this.collection?.models?.length??0)}}class RuleSetTriggeringSection extends t.View{constructor(e={}){super({className:"ruleset-triggering-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model,t=e.get("match_by"),i=e.get("bundle_by"),s=e.get("bundle_minutes"),a=e.get("trigger_count"),n=e.get("trigger_window"),o=e.get("retrigger_every"),l=function(e){const t=c.MatchByOptions.find(t=>t.value===e);return t?t.label:String(e)}(t),r=0===i||void 0===i?"No bundling":Y(i).replace(/^By\s+/i,""),d=0===i?"Each event becomes its own incident.":`Group events from the same source over <strong>${this.escapeHtml(Q(s).toLowerCase())}</strong> into one incident.`,h=null==a||0===a,u=h?'<span class="rs-flow-empty">Fires immediately</span>':`After <strong>${this.escapeHtml(String(a))}</strong> events`,m=h?"Handler chain runs as soon as the first matching event arrives.":null==n?"All events counted (no time window). Until the threshold, the incident stays in <code>pending</code>.":`Counted within <strong>${this.escapeHtml(String(n))}</strong> minutes.`,p=null==o,b=p?'<span class="rs-flow-empty">Fire once only</span>':`Every <strong>${this.escapeHtml(String(o))}</strong> events`,g=p?"Handler runs once when the threshold is first crossed; subsequent events are appended silently.":"Re-fires the handler chain after every N additional events.";return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">Match → Bundle → Threshold → Re-trigger</div>\n <h5 class="mb-0">Triggering</h5>\n </div>\n <button class="btn btn-outline-secondary btn-sm" data-action="edit-all"><i class="bi bi-pencil"></i> Edit all</button>\n </div>\n <div class="rs-flow">\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 1</div>\n <div class="rs-flow-title">Match <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="general"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${this.escapeHtml(l)}</div>\n <div class="rs-flow-hint">Each condition under "Conditions" must match the event for the rule to apply.</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 2</div>\n <div class="rs-flow-title">Bundle <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="bundling"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${this.escapeHtml(r)}</div>\n <div class="rs-flow-hint">${d}</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 3</div>\n <div class="rs-flow-title">Threshold <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="thresholds"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${u}</div>\n <div class="rs-flow-hint">${m}</div>\n </div>\n <div class="rs-flow-step">\n <div class="rs-flow-num">STEP 4</div>\n <div class="rs-flow-title">Re-trigger <button class="btn btn-link p-0 text-body-secondary" data-action="edit-step" data-tab="thresholds"><i class="bi bi-pencil"></i></button></div>\n <div class="rs-flow-value">${b}</div>\n <div class="rs-flow-hint">${g}</div>\n </div>\n </div>\n <div class="alert alert-secondary small mb-0">\n <i class="bi bi-info-circle me-1 text-primary"></i>\n Events accumulate in <code>pending</code> status. Once the trigger count is crossed, the\n <a href="#" data-action="go-handler">handler chain</a> fires and the incident becomes <code>new</code>.\n Leave Threshold empty to fire on the first event.\n </div>\n `}async onActionEditStep(e,t){const i=t?.dataset?.tab||"general";this.emit("action:edit-step",i)}async onActionEditAll(){this.emit("action:edit-ruleset")}async onActionGoHandler(e){e.preventDefault(),this.emit("navigate","Handler")}}class RuleSetHandlerChainSection extends t.View{constructor(e={}){super({className:"ruleset-handler-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=W(this.model.get("handler")),t=0===e.length;return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">${t?"0 handlers in chain":1===e.length?"1 handler in chain":`${e.length} handlers in chain`}</div>\n <h5 class="mb-0">Handler</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="edit-chain"><i class="bi bi-tools me-1"></i>Edit chain</button>\n </div>\n ${t?'\n <div class="text-center text-body-secondary py-4 border rounded">\n <i class="bi bi-tools fs-1 d-block mb-2"></i>\n <p class="mb-3">No handler configured. Incidents are recorded but no action runs.</p>\n <button class="btn btn-primary" data-action="edit-chain"><i class="bi bi-plus-lg me-1"></i>Configure handler chain</button>\n </div>\n ':`\n <div class="rs-chain">\n ${e.map(e=>`\n <div class="rs-chain-step tone-${e.tone}">\n <div class="rs-chain-icon"><i class="bi ${e.icon}"></i></div>\n <div style="min-width: 0;">\n <div class="rs-chain-label">${this.escapeHtml(e.label)}</div>\n ${e.detail?`<div class="rs-chain-detail">${this.escapeHtml(e.detail)}</div>`:""}\n </div>\n </div>\n `).join("")}\n </div>\n <div class="rs-chain-raw">{{model.handler}}</div>\n `}\n <div class="alert alert-secondary small mt-3 mb-0">\n <strong>Tip:</strong>\n Chain handlers with commas — e.g. <code>block://?ttl=86400, ticket://?priority=8, llm://</code>.\n Add <code>llm://</code> to use the prompt configured in <a href="#" data-action="go-agent">Agent Prompt</a>.\n </div>\n `}async onActionEditChain(){this.emit("action:edit-handler")}async onActionGoAgent(e){e.preventDefault(),this.emit("navigate","Agent")}}class RuleSetAgentPromptSection extends t.View{constructor(e={}){super({className:"ruleset-agent-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model.get("metadata")?.agent_prompt||"",t=W(this.model.get("handler")).some(e=>"llm"===e.scheme),i=e.length;return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">metadata.agent_prompt</div>\n <h5 class="mb-0">Agent Prompt</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="save-prompt" id="rs-prompt-save">\n <i class="bi bi-save me-1"></i>Save prompt\n </button>\n </div>\n ${t?'\n <div class="alert alert-info small d-flex align-items-center mb-3">\n <i class="bi bi-stars me-2"></i>\n <span>Used by the <code>llm://</code> handler in your chain when triaging incidents.</span>\n </div>\n ':'\n <div class="alert alert-warning small d-flex align-items-center mb-3">\n <i class="bi bi-exclamation-triangle me-2"></i>\n <span>Add <code>llm://</code> to your <a href="#" data-action="go-handler">handler chain</a> to use this prompt — it\'s saved either way.</span>\n </div>\n '}\n <textarea class="rs-prompt" data-action="prompt-input" data-action-debounce="200" spellcheck="false" placeholder="You are a security analyst triaging…">${this.escapeHtml(e)}</textarea>\n <div class="d-flex justify-content-between align-items-center mt-2">\n <small class="text-body-secondary">\n <i class="bi bi-info-circle me-1"></i>\n The LLM handler receives this prompt plus a structured incident summary on every fire.\n </small>\n <small class="text-body-secondary font-monospace" id="rs-prompt-counter">${i} chars</small>\n </div>\n `}async onAfterRender(){await super.onAfterRender(),this._lastSaved=this.model.get("metadata")?.agent_prompt||"",this._currentValue=this._lastSaved}async onActionPromptInput(e,t){this._currentValue=t.value;const i=this.element?.querySelector("#rs-prompt-counter");i&&(i.textContent=`${this._currentValue.length} chars`)}async onActionGoHandler(e){e.preventDefault(),this.emit("navigate","Handler")}async onActionSavePrompt(e,t){const i=this._currentValue??"";t.disabled=!0;try{const e=await this.model.save({"metadata.agent_prompt":i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this._lastSaved=i;const t={...this.model.get("metadata")||{},agent_prompt:i};this.model.set("metadata",t),this.getApp()?.toast?.success("Agent prompt saved")}catch(s){this.getApp()?.toast?.error(`Failed to save: ${s.message}`)}finally{t.disabled=!1}}async refresh(){this.isMounted()&&await this.render()}focusTextarea(){const e=this.element?.querySelector(".rs-prompt");e&&e.focus()}}class RuleSetIncidentsSection extends t.View{constructor(e={}){super({className:"ruleset-incidents-section",template:'<div data-container="incidents-table"></div>',...e}),this.rulesetId=e.rulesetId,this.collection=e.collection,this.rangeValue=e.range||"30d"}async onInit(){this.tableView=new l.TableView({containerId:"incidents-table",collection:this.collection,title:"Incidents",eyebrow:this._buildEyebrowLabel(),showFullscreen:!1,showRefresh:!0,tableOptions:{striped:!1,hover:!0},hideActivePillNames:["rule_set","created__gte"],columns:[{key:"created",label:"Created",sortable:!0,width:"140px",template:'<div class="font-monospace small">{{{model.created|epoch|datetime}}}</div><div class="text-body-secondary small">{{{model.created|epoch|relative}}}</div>'},{key:"status",label:"Status",width:"90px",formatter:"badge"},{key:"priority",label:"Pri",width:"60px",template:"{{{priorityPill}}}",formatter:e=>function(e){const t=parseInt(e,10);if(isNaN(t))return'<span class="text-body-tertiary">—</span>';let i,s;return t>=8?(i="bg-danger",s="text-danger-emphasis"):t>=5?(i="bg-warning",s="text-warning-emphasis"):(i="bg-secondary",s="text-secondary-emphasis"),`<span class="badge ${i} ${s} font-monospace">${t}</span>`}(e)},{key:"title",label:"Title",class:"rs-incident-title",formatter:"default('—')"}],showAdd:!1,paginated:!0,size:10,searchable:!1,filterable:!1,dayRangeFilter:{value:this.rangeValue}}),this.tableView.on("range:change",({value:e})=>{this.rangeValue=e,this._updateEyebrow()}),this.addChild(this.tableView),this.collection.on("fetch:success",()=>this._updateEyebrow(),this)}_buildEyebrowLabel(){const e=this.collection?.totalCount??this.collection?.models?.length??0,t="1d"===this.rangeValue?1:"7d"===this.rangeValue?7:"90d"===this.rangeValue?90:30;return`${e} ${1===e?"incident":"incidents"} in last ${t} ${1===t?"day":"days"}`}_updateEyebrow(){this.tableView?.setEyebrow(this._buildEyebrowLabel())}}class RuleSetMetadataSection extends t.View{constructor(e={}){super({className:"ruleset-metadata-section",...e}),this.template=()=>this._buildTemplate()}_buildTemplate(){const e=this.model.get("metadata")||{},t=0===Object.keys(e).length,i=["reasoning","assistant_proposed","delete_on_resolution","agent_prompt"].some(t=>void 0!==e[t]&&null!==e[t]&&""!==e[t]);return`\n <div class="d-flex justify-content-between align-items-baseline mb-3">\n <div>\n <div class="text-body-secondary text-uppercase small fw-semibold" style="letter-spacing: 0.05em;">${t?"No metadata yet":"Every key on ruleset.metadata"}</div>\n <h5 class="mb-0">Metadata</h5>\n </div>\n <button class="btn btn-primary btn-sm" data-action="edit-metadata">\n <i class="bi bi-pencil me-1"></i>${t?"Add metadata":"Edit JSON"}\n </button>\n </div>\n ${t?'\n <div class="text-center text-body-secondary py-4 border rounded">\n <i class="bi bi-braces fs-1 d-block mb-2"></i>\n <p class="mb-3 small">No metadata is set on this RuleSet. Use metadata for arbitrary configuration the framework doesn\'t know about — runbook URLs, on-call rotations, custom flags, etc.</p>\n <button class="btn btn-primary btn-sm" data-action="edit-metadata">\n <i class="bi bi-plus-lg me-1"></i>Add metadata\n </button>\n </div>\n ':`\n ${i?'\n <h6 class="text-body-secondary small text-uppercase mt-2 mb-2" style="letter-spacing: 0.06em;">Known fields</h6>\n <div data-container="metadata-known"></div>\n ':""}\n <h6 class="text-body-secondary small text-uppercase mt-3 mb-2" style="letter-spacing: 0.06em;">Raw JSON</h6>\n <pre class="bg-body-tertiary border rounded p-3 small mb-0" style="white-space: pre-wrap; word-break: break-word;"><code>{{model.metadata|json}}</code></pre>\n `}\n `}async onInit(){await this._buildKnownView()}async _buildKnownView(){const e=this.model.get("metadata")||{};if(!["reasoning","assistant_proposed","delete_on_resolution","agent_prompt"].some(t=>void 0!==e[t]&&null!==e[t]&&""!==e[t]))return;const t="string"==typeof e.agent_prompt?e.agent_prompt.length:0,i={get:t=>e[t],attributes:e,on(){},off(){}},s=[];void 0!==e.reasoning&&null!==e.reasoning&&""!==e.reasoning&&s.push({name:"reasoning",label:"Reasoning",cols:12}),void 0!==e.assistant_proposed&&s.push({name:"assistant_proposed",label:"Assistant Proposed",formatter:"yesnoicon",cols:6}),void 0!==e.delete_on_resolution&&s.push({name:"delete_on_resolution",label:"Delete on Resolution",formatter:"yesnoicon",cols:6}),void 0!==e.agent_prompt&&null!==e.agent_prompt&&s.push({name:"agent_prompt",label:"Agent Prompt",template:e.agent_prompt?`<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Configured · ${t} chars</span>`:'<span class="badge text-bg-secondary">Not configured</span>',cols:6}),this.knownView=new d.default({containerId:"metadata-known",model:i,columns:2,showEmptyValues:!1,fields:s}),this.addChild(this.knownView)}}class RuleSetView extends n.DetailView{constructor(e={}){const t=e.model||new c.RuleSet(e.data||{}),i=t.get("id"),s=new c.IncidentList({params:{rule_set:i,created__gte:Math.floor(Date.now()/1e3)-2592e3,sort:"-created"}}),a=new c.RuleList({params:{parent:i}}),n=new RuleSetOverviewSection({model:t,incidentsCollection:s}),o=new RuleSetConditionsSection({model:t,rulesetId:i,collection:a}),l=new RuleSetTriggeringSection({model:t}),r=new RuleSetHandlerChainSection({model:t}),d=new RuleSetAgentPromptSection({model:t}),h=new RuleSetIncidentsSection({model:t,rulesetId:i,collection:s}),u=new RuleSetMetadataSection({model:t}),m=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:n},{key:"Conditions",label:"Conditions",icon:"bi-funnel",view:o},{key:"Triggering",label:"Triggering",icon:"bi-stopwatch",view:l},{key:"Handler",label:"Handler",icon:"bi-tools",view:r},{key:"Agent",label:"Agent Prompt",icon:"bi-stars",view:d},{type:"divider",label:"Activity"},{key:"Incidents",label:"Incidents",icon:"bi-shield-exclamation",view:h},{type:"divider",label:"Detail"},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:u}];super({className:"ruleset-view",...e,model:t,header:{icon:"bi-gear-wide-connected",titleField:"name",subtitlePath:"metadata.reasoning",subtitlePlaceholder:"No reasoning provided — click to add one",subtitleEditAction:"edit-header",chips:[{icon:"bi-tag-fill",textPath:"category",variant:"primary"},{icon:"bi-flag",text:e=>`Priority ${e.get("priority")}`,variant:"secondary"},{icon:"bi-hash",text:e=>`ID ${e.get("id")}`,variant:"light"},{icon:"bi-stars",text:"AI-proposed",variant:"warning",when:e=>e.get("metadata")?.assistant_proposed},{icon:"bi-clock-history",text:e=>{const t=e.get("modified");return t?`Modified ${K(t)}`:null},variant:"light"}],activeField:"is_active",actions:[{label:"Edit",icon:"bi-pencil",action:"edit-header",title:"Edit details"}],contextMenu:{items:[{label:"Edit RuleSet",action:"edit-ruleset",icon:"bi-pencil"},{label:"Edit Handler Chain",action:"edit-handler",icon:"bi-tools"},{label:"Edit Agent Prompt",action:"edit-agent-prompt",icon:"bi-stars"},{type:"divider"},{label:"View Incidents",action:"view-incidents",icon:"bi-shield-exclamation"},{type:"divider"},{label:"Delete RuleSet",action:"delete-ruleset",icon:"bi-trash",danger:!0}]}},sections:m,activeSection:"Overview"}),this.incidentsCollection=s,this.conditionsCollection=a,this.overviewSection=n,this.conditionsSection=o,this.triggeringSection=l,this.handlerSection=r,this.agentSection=d,this.incidentsSection=h,this.metadataSection=u,this.incidentsCollection.fetch().catch(()=>{}),this.conditionsCollection.fetch().catch(()=>{})}async onAfterBuild(){const e=e=>this.showSection(e);this.overviewSection.on("navigate",e),this.triggeringSection.on("navigate",e),this.handlerSection.on("navigate",e),this.agentSection.on("navigate",e),this.triggeringSection.on("action:edit-step",e=>this.onActionEditTriggeringStep(e)),this.triggeringSection.on("action:edit-ruleset",()=>this.onActionEditRuleset()),this.handlerSection.on("action:edit-handler",()=>this.onActionEditHandler());const t=()=>{const e=this.conditionsCollection.models?.length??0;this.setBadge("Conditions",e>0?{text:String(e),variant:"muted"}:null)};this.conditionsCollection.on("fetch:success",t,this),this.conditionsCollection.models?.length&&t();const i=()=>{const e=this.incidentsCollection.totalCount??this.incidentsCollection.models?.length??0;this.setBadge("Incidents",e>0?{text:String(e),variant:e>10?"warning":"muted"}:null)};this.incidentsCollection.on("fetch:success",i,this),this.incidentsCollection.models?.length&&i()}async onActionEditHeader(){await a.Modal.modelForm({title:"Edit RuleSet details",model:this.model,size:"md",formConfig:{fields:[{name:"name",type:"text",label:"Name",required:!0,columns:12},{name:"category",type:"combo",label:"Scope / Category",options:c.CommonCategoryOptions,allowCustom:!0,required:!0,columns:8},{name:"priority",type:"number",label:"Priority",required:!0,columns:4},{name:"metadata.reasoning",type:"textarea",label:"Reasoning",rows:4,columns:12,tooltip:"Why this rule exists — shown as the header subtitle."}]}})&&await this._fullRefresh()}async onActionEditTriggeringStep(e){const t=this._triggeringMiniForm(e);t&&await a.Modal.modelForm({title:t.title,model:this.model,size:"md",formConfig:{fields:t.fields}})&&await this._fullRefresh()}_triggeringMiniForm(e){switch(e){case"general":case"match":return{title:"Edit match logic",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">Controls how multiple <strong>conditions</strong> combine when evaluating an event.</p>'},{name:"match_by",type:"select",label:"Match Logic",options:c.MatchByOptions,columns:12,tooltip:"ALL = every condition must match. ANY = at least one"}]};case"bundling":case"bundle":return{title:"Edit bundling",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">How matched events are grouped into a single incident.</p>'},{name:"bundle_by",type:"select",label:"Bundle By",options:c.BundleByOptions,columns:6,tooltip:"How to group related events into one incident"},{name:"bundle_minutes",type:"select",label:"Bundle Window",options:c.BundleMinutesOptions,columns:6,tooltip:"Events outside this window create a new incident"},{name:"bundle_by_rule_set",type:"switch",label:"Bundle by RuleSet",columns:12,tooltip:"Group events matched by this rule into the same incident"}]};case"thresholds":case"threshold":return{title:"Edit threshold",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">Events accumulate in <code>pending</code> until this threshold is reached. Leave empty to fire on the first event.</p>'},{name:"trigger_count",type:"number",label:"Trigger Count",placeholder:"Empty = fire immediately",columns:6,tooltip:"Number of events before the handler fires"},{name:"trigger_window",type:"number",label:"Trigger Window (min)",placeholder:"Empty = no time limit",columns:6,tooltip:"Only count events within this many minutes"}]};case"retrigger":return{title:"Edit re-trigger",fields:[{type:"html",columns:12,html:'<p class="small text-body-secondary mb-2">After the threshold is crossed, re-fire the handler chain every N additional events.</p>'},{name:"retrigger_every",type:"number",label:"Re-trigger Every",placeholder:"Empty = fire once only",columns:12,tooltip:"Re-fire handler every N additional events after initial trigger"}]};default:return null}}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. Known keys are also editable from their own sections (Reasoning from header Edit, Agent Prompt from its tab) — use this for anything else.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }',tooltip:"Must be a valid JSON object"}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object (e.g. `{}`), not an array or scalar.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{const e=await this.model.save({metadata:s});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._fullRefresh(),this.metadataSection?.isMounted()&&await this.metadataSection.render()}catch(n){this.getApp()?.toast?.error(`Failed to save metadata: ${n.message}`)}}async onActionEditRuleset(){await a.Modal.modelForm({title:`Edit RuleSet — ${this.model.get("name")}`,model:this.model,formConfig:c.RuleSet.EDIT_FORM})&&await this._fullRefresh()}async onActionEditHandler(){const e=new HandlerBuilderView({value:this.model.get("handler")||""});if("save"===await a.Modal.dialog({title:"Configure Handler Chain",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();if(!t)return;const i=await this.model.save({handler:t});if(i&&i.status&&i.status>=400)return void this.getApp()?.toast?.error("Failed to update handler");this.getApp()?.toast?.success("Handler updated"),await this._fullRefresh()}}async onActionEditAgentPrompt(){await this.showSection("Agent"),this.agentSection?.focusTextarea()}async onActionViewIncidents(){await this.showSection("Incidents")}async onActionDeleteRuleset(){if(await a.Modal.confirm({title:"Delete RuleSet",message:`Are you sure you want to delete "${this.model.get("name")}"? This cannot be undone.`,confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("RuleSet deleted");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: ${e.message}`)}}async _fullRefresh(){await this.headerView.render(),await this.triggeringSection.render(),await this.handlerSection.render(),await this.agentSection.refresh(),this.overviewSection.isMounted()&&await this.overviewSection.render()}}RuleSetView.VIEW_CLASS=RuleSetView,c.RuleSet.MODEL_REF="incident.RuleSet";class IncidentHistoryAdapter{constructor(e){this.incidentId=e,this.collection=new c.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 c.IncidentHistory,i=await t.save({parent:this.incidentId,note:e.text,kind:"comment",media:e.files&&e.files.length>0?e.files[0].id:null});return i.success&&await this.collection.fetch(),i}async _renderMarkdown(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}}class AssistantMessageView extends n.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 i=0;i<this.message.blocks.length;i++){const s=this.message.blocks[i],a=document.createElement("div");a.className="assistant-block mb-3",e.appendChild(a);try{"table"===s.type?await this._renderTableBlock(s,a):"chart"===s.type?await this._renderChartBlock(s,a):"stat"===s.type?this._renderStatBlock(s,a):"action"===s.type?this._renderActionBlock(s,a):"list"===s.type?this._renderListBlock(s,a):"alert"===s.type?this._renderAlertBlock(s,a):"progress"===s.type?this._renderProgressBlock(s,a):"file"===s.type?this._renderFileBlock(s,a):"context"===s.type&&this._renderContextBlock(s,a)}catch(t){console.error("Failed to render block:",s.type,t);const e=document.createElement("div");e.className="alert alert-warning small",e.textContent=`Failed to render ${s.type} block`,a.appendChild(e)}}}_createCollapsibleCard(e,{icon:t,title:i,subtitle:s}){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(i||"Data")}</span>\n ${s?`<span class="assistant-block-toggle-subtitle">${n(s)}</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,i){const s=(e.rows||[]).length,a=(e.columns||[]).length,{body:n}=this._createCollapsibleCard(i,{icon:"bi-table",title:e.title||"Table",subtitle:`${s} rows · ${a} columns`}),o=(e.columns||[]).map(e=>"string"==typeof e?{key:e,label:e}:e),r=o.map(e=>e.key),c=(e.rows||[]).map((e,i)=>{const s={id:i};return r.forEach((t,i)=>{s[t]=void 0!==e[i]?e[i]:""}),new t.Model(s)}),d=new t.Collection({preloaded:!0});d.add(c);const h=new l.TableView({collection:d,columns:o,paginated:!1,sortable:!1,searchable:!1,filterable:!1,showRefresh:!1,showAdd:!1});this._blockViews.push(h),this.addChild(h),n.appendChild(h.element),h.render(!1)}async _renderChartBlock(e,t){const s=e.chart_type||"line",a=(e.series||[]).length,n=e.labels?.length||0,o="pie"===s,{body:l,onShow:r}=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:o?`${n} segments`:`${a} series · ${n} points`}),c=document.createElement("div");c.className="assistant-chart-body",l.appendChild(c);const d={stacked:"stacked",grouped:"grouped",crosshair_tracking:"crosshairTracking",colors:"colors",show_legend:"showLegend",legend_position:"legendPosition"},h={cutout:"cutout",show_labels:"showLabels",show_percentages:"showPercentages",colors:"colors",legend_position:"legendPosition"},u=t=>Object.entries(t).reduce((t,[i,s])=>(void 0!==e[i]&&(t[s]=e[i]),t),{}),m={labels:e.labels||[],datasets:(e.series||[]).map(e=>{const t={label:e.name,data:e.values};return void 0!==e.color&&(t.color=e.color),void 0!==e.fill&&(t.fill=e.fill),void 0!==e.smoothing&&(t.smoothing=e.smoothing),t})};if(o){const e=new i.PieChart({width:180,height:180,legendPosition:"right",...u(h),data:m});this._blockViews.push(e),this.addChild(e),c.appendChild(e.element),e.render(!1)}else{const e=new i.SeriesChart({chartType:"area"===s?"line":s,fill:"area"===s,height:200,legendPosition:"top",...u(d),data:m});this._blockViews.push(e),this.addChild(e),c.appendChild(e.element),e.render(!1)}}_renderStatBlock(e,t){const i=e.items||[],s=document.createElement("div");s.className="d-flex flex-wrap gap-2",i.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 `,s.appendChild(t)}),t.appendChild(s)}_renderActionBlock(e,t){const i=this._escapeHtml.bind(this),s=document.createElement("div");s.className="assistant-action-card",s.innerHTML=`\n <div class="assistant-action-header">${i(e.title||"Action Required")}</div>\n ${e.description?`<div class="assistant-action-desc">${i(e.description)}</div>`:""}\n <div class="assistant-action-buttons"></div>\n `;const a=s.querySelector(".assistant-action-buttons");(e.actions||[]).forEach((t,s)=>{const n=document.createElement("button");n.className=0===s?"btn btn-sm btn-primary":"btn btn-sm btn-outline-secondary",n.textContent=t.label,n.addEventListener("click",()=>{a.innerHTML=`\n <div class="assistant-action-chosen-label">\n <i class="bi bi-check-circle-fill me-1"></i>\n You chose: <strong>${i(t.label)}</strong>\n </div>\n `;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(n)}),t.appendChild(s)}_renderListBlock(e,t){const i=this._escapeHtml.bind(this),s=document.createElement("div");s.className="assistant-list-card";let a="";e.title&&(a+=`<div class="assistant-list-title">${i(e.title)}</div>`),a+='<dl class="assistant-list-items">',(e.items||[]).forEach(e=>{a+=`\n <div class="assistant-list-row">\n <dt>${i(e.label)}</dt>\n <dd>${i(String(e.value??""))}</dd>\n </div>`}),a+="</dl>",s.innerHTML=a,t.appendChild(s)}_renderAlertBlock(e,t){const i=this._escapeHtml.bind(this),s=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"}[s]||"alert-info"}`,n.innerHTML=`\n <i class="bi ${a[s]||a.info} me-2"></i>\n <div class="assistant-alert-content">\n ${e.title?`<strong>${i(e.title)}</strong>`:""}\n <div>${i(e.message||"")}</div>\n </div>\n `,t.appendChild(n)}_renderProgressBlock(e,t){const i=this._escapeHtml.bind(this),s=e.steps||[],a=s.filter(e=>"done"===e.status).length,n=s.length>0?Math.round(a/s.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"};s.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">${i(e.description)}</span>\n ${e.summary?`<span class="step-summary">${i(e.summary)}</span>`:""}\n </div>\n </div>`}),o.innerHTML=`\n <div class="assistant-progress-header">\n <span class="assistant-progress-title">${i(e.title||"Plan")}</span>\n <span class="assistant-progress-counter">${a} of ${s.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)}_renderFileBlock(e,t){if(!e.filename||!e.url)return void console.warn("File block missing required fields (filename, url). type:",e.type);let i;try{const t=new URL(e.url,window.location.href);if("https:"!==t.protocol&&"http:"!==t.protocol)return void console.warn("File block URL rejected (invalid scheme).");i=e.url}catch{return void console.warn("File block URL rejected (unparseable).")}const s=this._escapeHtml.bind(this),a={csv:"bi-filetype-csv",xlsx:"bi-file-earmark-spreadsheet",pdf:"bi-filetype-pdf",json:"bi-filetype-json"}[e.format]||"bi-file-earmark-arrow-down",n=document.createElement("a");n.className="assistant-file-card",n.href=i,n.download=e.filename,n.target="_blank",n.rel="noopener";const o=[];null!=e.size&&o.push(this._formatBytes(e.size)),null!=e.row_count&&o.push(`${Number(e.row_count).toLocaleString()} rows`),n.innerHTML=`\n <span class="assistant-file-icon">\n <i class="bi ${a}"></i>\n </span>\n <div class="assistant-file-info">\n <span class="assistant-file-name">${s(e.filename)}</span>\n ${o.length?`<span class="assistant-file-stats">${o.join(" · ")}</span>`:""}\n ${e.expires_in?`<span class="assistant-file-expiry"><i class="bi bi-clock me-1"></i>${s(e.expires_in)}</span>`:""}\n </div>\n <span class="assistant-file-download" title="Download">\n <i class="bi bi-download"></i>\n </span>\n `,t.appendChild(n)}_renderContextBlock(e,t){const i=e.references;if(!i||0===i.length)return void t.remove();const s=this._escapeHtml.bind(this),a=this.getApp(),n=document.createElement("div");n.className="assistant-context-refs",i.forEach(e=>{const t=`${e.app_name}.${e.model_name}`,i=String(e.pk),o=e.label||`${e.model_name} #${i}`,l=a?.getModelByRef(t),r=l?.VIEW_CLASS,c=document.createElement("span");c.className=r?"assistant-context-chip clickable":"assistant-context-chip",r?(c.setAttribute("data-action","open-context-ref"),c.dataset.ref=t,c.dataset.pk=i,c.innerHTML=`<i class="bi bi-box-arrow-up-right"></i>${s(o)}`):c.textContent=o,n.appendChild(c)}),t.appendChild(n)}async onActionOpenContextRef(e,t){const i=t.dataset.ref,s=t.dataset.pk;if(!i||!/^\d+$/.test(s))return;const n=this.getApp(),o=n?.getModelByRef(i);o?.VIEW_CLASS&&a.Modal.showModelById(o,s)}_formatBytes(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":(e/1048576).toFixed(1)+" MB"}updateProgressStep(e,t,i,s){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-${i}`;const e=o.querySelector(".step-icon");e&&(e.className=`bi ${n[i]||n.pending} step-icon`);let t=o.querySelector(".step-summary");s&&(t||(t=document.createElement("span"),t.className="step-summary",o.querySelector(".step-content").appendChild(t)),t.textContent=s)}const l=a.querySelectorAll(".assistant-progress-step"),r=a.querySelectorAll(".step-done").length,c=a.querySelector(".assistant-progress-counter");c&&(c.textContent=`${r} of ${l.length}`);const d=a.querySelector(".progress-bar");d&&(d.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 i=e.classList.toggle("message-collapsed");t.innerHTML=i?'<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 i=this.getApp(),s=await i.rest.post("/api/docit/render",{markdown:t}),a=s?.data?.data?.html||s?.data?.html;if(a)return void(e.innerHTML=a)}catch(i){}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 i=t.innerHTML;return i=i.replace(/```(\w*)\n([\s\S]*?)```/g,(e,t,i)=>`<pre class="assistant-code-block"><code>${i.trim()}</code></pre>`),i=i.replace(/`([^`]+)`/g,'<code class="assistant-inline-code">$1</code>'),i=i.replace(/^### (.+)$/gm,'<h6 class="assistant-heading mt-3 mb-1">$1</h6>'),i=i.replace(/^## (.+)$/gm,'<h5 class="assistant-heading mt-3 mb-1">$1</h5>'),i=i.replace(/^# (.+)$/gm,'<h4 class="assistant-heading mt-3 mb-2">$1</h4>'),i=i.replace(/^---+$/gm,'<hr class="my-2 opacity-25">'),i=i.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>"),i=i.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>"),i=i.replace(/\*(.+?)\*/g,"<em>$1</em>"),i=i.replace(/((?:^- .+$\n?)+)/gm,e=>`<ul class="assistant-list mb-2">${e.trim().split("\n").map(e=>`<li>${e.replace(/^- /,"")}</li>`).join("")}</ul>`),i=i.replace(/\n{2,}/g,"</p><p>"),i=i.replace(/\n/g,"<br>"),i=`<p>${i}</p>`,i=i.replace(/<p>\s*<\/p>/g,""),i=i.replace(/<p>\s*(<h[456]|<hr|<ul|<pre|<\/ul>|<\/pre>)/g,"$1"),i=i.replace(/(<\/h[456]>|<hr[^>]*>|<\/ul>|<\/pre>)\s*<\/p>/g,"$1"),i}}AssistantMessageView._blockCounter=0;class AssistantConversationListView extends t.View{constructor(e={}){super({className:"assistant-conversation-list",...e}),this.collection=e.collection,this.activeId=null,this._searchTimeout=null}getTemplate(){return'\n <div class="conversation-list-header">\n <div class="conversation-search-wrapper mb-2">\n <input type="text" class="form-control form-control-sm conversation-search-input"\n placeholder="Search conversations..." data-ref="search-input">\n </div>\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();const e=this.element.querySelector('[data-ref="search-input"]');e&&e.addEventListener("input",()=>this._onSearchInput(e))}_onSearchInput(e){this._searchTimeout&&clearTimeout(this._searchTimeout),this._searchTimeout=setTimeout(async()=>{const t=e.value.trim();t?this.collection.params.search=t:delete this.collection.params.search,this.collection.params.start=0,await this.collection.fetch(),this._renderItems()},300)}_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 i=this._groupByDate(t);for(const[s,a]of i){const t=document.createElement("div");t.className="conversation-date-header px-3 py-1 text-muted small fw-semibold text-uppercase",t.textContent=s,e.appendChild(t),a.forEach(t=>{const i=t.get("id"),s=t.get("title")||t.get("summary")||"New conversation",a=t.get("modified")||t.get("created"),n=this._relativeTime(a),o=i===this.activeId,l=t.get("user"),r=l?.display_name||"",c=l?.avatar?.thumbnail||l?.avatar?.url||"",d=document.createElement("div");d.className="conversation-item px-3 py-2"+(o?" active":""),d.dataset.id=i,d.innerHTML=`\n <div class="d-flex align-items-start">\n ${c?`<img src="${this._escapeHtml(c)}" alt="" class="conversation-avatar">`:`<div class="conversation-avatar conversation-avatar-initials">${this._escapeHtml(this._initials(r))}</div>`}\n <div class="flex-grow-1 overflow-hidden">\n <div class="text-truncate conversation-title">${this._escapeHtml(s)}</div>\n <div class="conversation-meta text-muted">\n ${r?`<span>${this._escapeHtml(r)}</span>`:""}\n ${n?`<span>${n}</span>`:""}\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="${i}" title="Delete">\n <i class="bi bi-trash"></i>\n </button>\n </div>\n `,d.addEventListener("click",e=>{e.target.closest('[data-action="delete-conversation"]')||(this.setActive(i),this.emit("conversation:select",{id:i,model:t}))}),e.appendChild(d)})}if(this.collection.hasMore){const t=document.createElement("div");t.className="conversation-load-more text-center py-2",t.innerHTML='<button class="btn btn-sm btn-link text-muted" data-action="load-more">Load more</button>',e.appendChild(t)}}async onActionLoadMore(){await this.collection.nextPage(),this._renderItems()}_groupByDate(e){const i=/* @__PURE__ */new Date,s=new Date(i.getFullYear(),i.getMonth(),i.getDate()),a=new Date(s);a.setDate(a.getDate()-1);const n=/* @__PURE__ */new Map;n.set("Today",[]),n.set("Yesterday",[]),n.set("Earlier",[]),e.forEach(e=>{const i=e.get("created")||e.get("modified"),o=new Date(t.dataFormatter.normalizeEpoch(i)),l=new Date(o.getFullYear(),o.getMonth(),o.getDate());l>=s?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 a.Modal.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 s=this.collection.models.find(e=>String(e.get("id"))===String(i));s&&(await s.destroy(),await this.refresh(),this.emit("conversation:deleted",{id:i}))}async refresh(){await this.collection.fetch(),this._renderItems()}_relativeTime(e){if(!e)return"";const i=new Date(t.dataFormatter.normalizeEpoch(e));if(isNaN(i))return"";const s=Date.now()-i.getTime(),a=Math.floor(s/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`:`${i.toLocaleString("default",{month:"short"})} ${i.getDate()}`}_initials(e){return e?e.split(/\s+/).map(e=>e[0]).join("").toUpperCase().slice(0,2):"?"}_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={},this._requestStartTime=null}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 <img src="https://mojo-verify.s3.amazonaws.com/signatures/14e7aab75c2749cb846f7d57298691ac/mojo_ai_7c0322e9.png" alt="Mojo">\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-status d-none" data-ref="input-status"></div>\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message Mojo..." 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 c.AssistantConversationList,this.conversations.params.user=this.app?.activeUser?.id,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},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 i=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!i)return;const s=this.element.querySelector('[data-ref="input"]');s&&(s.value=i,this._autoResize(s)),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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e),this._setInputStatus(e?null:t),this._responseTimeout&&clearTimeout(this._responseTimeout),e?this._requestStartTime=null:this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4)}_setInputStatus(e){const t=this.element?.querySelector('[data-ref="input-status"]');t&&(e?(t.innerHTML=`${this._escapeHtml(e)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,t.classList.remove("d-none"),t._hasDismiss||(t._hasDismiss=!0,t.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))):(t.classList.add("d-none"),t.innerHTML=""))}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 c.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.chatView.showThinking("Thinking..."),this._requestStartTime=Date.now(),this._setInputEnabled(!1,"Waiting for response…"),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}),i=t?.data?.data||t?.data||t;i.conversation_id&&(this.conversationId=i.conversation_id),i.response&&this.chatView.addMessage(this._transformMessage(i.response)),this._setInputEnabled(!0)}catch(i){this._handleAPIError(i)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),text:e=>this._onText(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_text",this._wsHandlers.text),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_text",this._wsHandlers.text),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_text":this._onText(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,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){if(this._responseTimeout){if(this._requestStartTime&&Date.now()-this._requestStartTime>=3e5)return void this._onResponseTimeout();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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,i=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Mojo"}: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:i,tool_calls:s,_conversationId:this.conversationId}}static _collapseMessages(e){const t=[];for(const i of e){"assistant"===i.role&&i.tool_calls?.length>0&&(i.tool_calls=i.tool_calls.filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name)));const e=!!i.content,s=i.tool_calls?.length>0,a=i.blocks?.length>0;if("assistant"!==i.role||e||s||a){if("assistant"===i.role&&!e&&s&&!a){const e=t[t.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}}t.push(i)}}return t}static _parseBlocks(e){const t=/```assistant_block\s*\n([\s\S]*?)```/g,i=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress","file"]),s=[];let a;for(;null!==(a=t.exec(e));)try{const e=JSON.parse(a[1].trim());e&&i.has(e.type)&&s.push(e)}catch(n){}return{content:e.replace(t,"").replace(/\n{3,}/g,"\n\n").trim(),blocks:s}}_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");e&&(this.ws?.isConnected?(e.className="status-dot connected",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0)):this.ws?.isReconnecting?(e.className="status-dot reconnecting",e.title="Reconnecting...",this._setInputEnabled(!1,"Reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)):(e.className="status-dot disconnected",e.title="Disconnected",this._setInputEnabled(!1,"Disconnected — reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)))}_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)}}AssistantView.INTERNAL_TOOLS=/* @__PURE__ */new Set(["create_plan","update_plan","load_tools"]);class AssistantContextAdapter{constructor({app:e,modelName:t,pk:i,conversationId:s}){this.app=e,this.modelName=t,this.pk=i,this.conversationId=s,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 c.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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=/```assistant_block\s*\n([\s\S]*?)```/g,s=/* @__PURE__ */new Set(["table","chart","stat","action","list","alert","progress","file"]);let n;for(;null!==(n=e.exec(t));)try{const e=JSON.parse(n[1].trim());e&&s.has(e.type)&&i.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:"Mojo"}: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:i,tool_calls:s,_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="assistant-input-status d-none" data-ref="input-status"></div>\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 n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this.adapter}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},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 i={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(i),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Waiting for response…"),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}),i=e?.data?.data||e?.data||e;i.response&&this.chatView.addMessage(this.adapter._transformMessage({id:i.message_id||"resp-"+ ++this._messageIdCounter,role:"assistant",content:i.response,blocks:i.blocks||[],created:/* @__PURE__ */(new Date).toISOString()})),this._setInputEnabled(!0)}catch(s){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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e);const n=this.element?.querySelector('[data-ref="input-status"]');if(n)if(!e&&t){const e=e=>{const t=document.createElement("div");return t.textContent=e,t.innerHTML};n.innerHTML=`${e(t)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,n.classList.remove("d-none"),n._hasDismiss||(n._hasDismiss=!0,n.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))}else n.classList.add("d-none"),n.innerHTML="";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),text:e=>this._onText(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()},this.ws.on("message:assistant_thinking",this._wsHandlers.thinking),this.ws.on("message:assistant_text",this._wsHandlers.text),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))}_unsubscribeWS(){this.ws&&this._wsHandlers&&(this.ws.off("message:assistant_thinking",this._wsHandlers.thinking),this.ws.off("message:assistant_text",this._wsHandlers.text),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._wsHandlers={})}_dispatchWSMessage(e){const t=e?.data;if(t?.type)switch(t.type){case"assistant_thinking":this._onThinking(t);break;case"assistant_text":this._onText(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)}_adoptConversationId(e){e.conversation_id&&this.adapter&&!this.adapter.conversationId&&(this.adapter.conversationId=e.conversation_id)}_onThinking(e){this._isMyConversation(e)&&(this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this.adapter._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}_updateConnectionStatus(){const e=this.element?.querySelector(".status-dot");if(e)if(this.ws?.isConnected)e.style.background="#198754",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0);else{e.style.background="#dc3545",e.title="Disconnected";const t=this.element?.querySelector('[data-ref="input"]'),i=this.element?.querySelector('[data-ref="send-btn"]');t&&(t.disabled=!0),i&&i.classList.add("d-none");const s=this.element?.querySelector('[data-ref="input-status"]');s&&(s.textContent="Disconnected — reconnecting…",s.classList.remove("d-none"))}}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}async function Z(e,t){const i=e.getApp();if(!i)return;const s=e.model,n=s.get("id"),o=(s.get("metadata")||{}).assistant_conversation_id||null,l=new AssistantContextAdapter({app:i,modelName:t,pk:n,conversationId:o});l._onConversationCreated=async e=>{try{await s.save({metadata:{assistant_conversation_id:e}})}catch(t){}};const r=new AssistantContextChat({app:i,adapter:l});await a.Modal.show(r,{title:"Mojo",size:"xl"})}const X=t.MOJOUtils.escapeHtml;function ee(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"}function te(e,t){return e.length>t?e.slice(0,t)+"…":e}function ie(e){if(!e)return"";const t=Number(e),i=Math.floor(Date.now()/1e3)-t;return i<60?i<=1?"just now":`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}function se(e){const t=Math.max(0,Math.floor(e||0));return t<60?`${t}s`:t<3600?`${Math.floor(t/60)}m ${t%60}s`:t<86400?`${Math.floor(t/3600)}h ${Math.floor(t%3600/60)}m`:`${Math.floor(t/86400)}d ${Math.floor(t%86400/3600)}h`}function ae(e){const t=e.get("metadata")||{};return t.source_ip||t.ip||t.ip_address||null}const ne={new:{tone:"danger",icon:"bi-bell-fill",label:"New",help:"Unhandled — needs triage"},open:{tone:"danger",icon:"bi-folder2-open",label:"Open",help:"Claimed by an operator for investigation"},investigating:{tone:"warning",icon:"bi-search",label:"Investigating",help:"Actively being investigated"},paused:{tone:"info",icon:"bi-pause-circle-fill",label:"Paused",help:"On hold — waiting for external input"},resolved:{tone:"success",icon:"bi-check-circle-fill",label:"Resolved",help:"Root cause addressed"},closed:{tone:"success",icon:"bi-x-circle-fill",label:"Closed",help:"Closed — archived"},ignored:{tone:"info",icon:"bi-eye-slash-fill",label:"Ignored",help:"Noise — review periodically"},pending:{tone:"warning",icon:"bi-hourglass-split",label:"Pending",help:"Below trigger threshold — accumulating events"}},oe=/* @__PURE__ */new Set(["new","open","investigating","pending"]);function le(e){return ne[(e||"").toLowerCase()]||ne.new}function re(e){const t=parseInt(e,10)||5;return t>=8?{variant:"danger",label:"Critical"}:t>=6?{variant:"danger",label:"High"}:t>=4?{variant:"warning",label:"Medium"}:t>=2?{variant:"secondary",label:"Low"}:{variant:"secondary",label:"Info"}}function ce(e){const t=(e||"").toLowerCase();return"resolved"===t||"closed"===t?{icon:"bi-shield-check",tone:"success"}:"paused"===t||"ignored"===t?{icon:"bi-shield",tone:null}:"investigating"===t?{icon:"bi-shield-exclamation",tone:"warning"}:{icon:"bi-shield-exclamation",tone:"danger"}}class IncidentOverviewSection extends t.View{constructor(e={}){super({className:"incident-overview-section",template:'\n <div data-container="ov-status"></div>\n <div data-container="ov-llm-analysis"></div>\n <div class="detail-kpi-grid">\n <div data-container="ov-kpi-events"></div>\n <div data-container="ov-kpi-sources"></div>\n <div data-container="ov-kpi-last"></div>\n <div data-container="ov-kpi-related"></div>\n </div>\n <div class="detail-pair">\n <div data-container="ov-trigger"></div>\n <div data-container="ov-response"></div>\n </div>\n ',...e})}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"ov-status",model:e,tone:e=>function(e){const t=(e.get("status")||"").toLowerCase(),i=parseInt(e.get("priority"),10)||5;return oe.has(t)?i>=6?"danger":"warning":"resolved"===t||"closed"===t?"success":"paused"===t||"ignored"===t?"info":"primary"}(e),state:e=>this._panelState(e),headline:e=>this._panelHeadline(e),meta:e=>this._panelMeta(e),actions:e=>this._panelActions(e)}),this.addChild(this.statusPanel),this._mountLlmAnalysis();const t=e.get("event_count")??0,i=e.get("source_count")??(ae(e)?1:0),s=e.get("related_count"),a=ie(e.get("modified")||e.get("created"))||"—";this.kpiEvents=new n.MetricCard({containerId:"ov-kpi-events",label:"Events",value:String(t),tone:t>10?"danger":t>0?"warning":"default"}),this.kpiSources=new n.MetricCard({containerId:"ov-kpi-sources",label:"Sources",value:String(i)}),this.kpiLast=new n.MetricCard({containerId:"ov-kpi-last",label:"Last fired",value:a}),this.kpiRelated=new n.MetricCard({containerId:"ov-kpi-related",label:"Related",value:null!=s?String(s):"—"}),[this.kpiEvents,this.kpiSources,this.kpiLast,this.kpiRelated].forEach(e=>this.addChild(e)),this.triggerCard=new IncidentTriggerCard({containerId:"ov-trigger",model:e}),this.triggerCard.on("action:view-ruleset",()=>this.emit("action:view-ruleset")),this.triggerCard.on("action:view-source-ip",()=>this.emit("action:view-source-ip")),this.triggerCard.on("action:view-user",e=>this.emit("action:view-user",e)),this.addChild(this.triggerCard),this.responseCard=new IncidentResponseCard({containerId:"ov-response",model:e}),this.responseCard.on("action:view-ticket",e=>this.emit("action:view-ticket",e)),this.addChild(this.responseCard)}_mountLlmAnalysis(){const e=(this.model.get("metadata")||{}).llm_analysis;e&&!this.llmResultsView&&(this.llmResultsView=new LLMAnalysisResultsView({containerId:"ov-llm-analysis",analysis:e}),this.llmResultsView.on("analyze-llm",()=>this.emit("action:analyze-llm")),this.addChild(this.llmResultsView))}async refreshAnalysis(){this.llmResultsView&&(this.removeChild(this.llmResultsView),this.llmResultsView=null),this.isMounted()&&await this.render()}_panelState(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=Number(e.get("created"))||0,a=s&&oe.has(t)?se(Math.max(0,Math.floor(Date.now()/1e3)-s)):"";return a?`${i.label} · ${a}`:i.label}_panelHeadline(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=Number(e.get("created"))||0,a=Number(e.get("modified"))||0;if(oe.has(t)){const e=s?se(Math.max(0,Math.floor(Date.now()/1e3)-s)):"";return e?`In flight for ${e}`:"In flight"}return"resolved"===t||"closed"===t?`Resolved ${ie(a)}`:i.help||i.label}_panelMeta(e){const t=le(e.get("status")),i=e.get("event_count")??0,s=ae(e),a=e.get("source_count")??(s?1:0),n=Number(e.get("modified"))||0,o=n?ie(n):"",l=[];if(o&&l.push(`Last event <strong>${X(o)}</strong>`),i){const e=1===i?"event":"events",t=1===a?"source":"sources",s=a?` from ${X(String(a))} ${t}`:"";l.push(`${X(String(i))} ${e}${s}`)}return l.length||l.push(X(t.help||"")),l.join(" · ")}_panelActions(e){const t=(e.get("status")||"").toLowerCase(),i=[];return oe.has(t)?(i.push({label:"Resolve",action:"resolve",icon:"bi-check2-circle",variant:"success"}),i.push({label:"Assign",action:"assign",icon:"bi-person",variant:"outline-secondary"})):"resolved"===t||"closed"===t?i.push({label:"Re-open",action:"reopen",icon:"bi-arrow-counterclockwise",variant:"outline-primary"}):i.push({label:"Resolve",action:"resolve",icon:"bi-check2-circle",variant:"success"}),i}async onActionResolve(){this.emit("action:resolve")}async onActionAssign(){this.emit("action:assign")}async onActionReopen(){this.emit("action:reopen")}}class IncidentTriggerCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-funnel"></i>What triggered this</div>\n {{#hasRows|bool}}\n <div class="trigger-rows">\n {{#hasRule|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Rule</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-ruleset">{{ruleLabel}}</a>\n </div>\n </div>\n {{/hasRule|bool}}\n {{#hasCategory|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Category</div>\n <div class="detail-flat-row-value"><code>{{model.category}}</code></div>\n </div>\n {{/hasCategory|bool}}\n {{#hasScope|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Scope</div>\n <div class="detail-flat-row-value"><code>{{model.scope}}</code></div>\n </div>\n {{/hasScope|bool}}\n {{#hasSourceIp|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Source IP</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-source-ip"><code>{{sourceIp}}</code></a>\n </div>\n </div>\n {{/hasSourceIp|bool}}\n {{#hasTargetUser|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Targeted user</div>\n <div class="detail-flat-row-value">\n <a href="#" data-action="view-user" data-user="{{targetUser}}">{{targetUser}}</a>\n </div>\n </div>\n {{/hasTargetUser|bool}}\n {{#hasHostname|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Hostname</div>\n <div class="detail-flat-row-value"><code>{{model.hostname}}</code></div>\n </div>\n {{/hasHostname|bool}}\n {{#hasEventCount|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Events</div>\n <div class="detail-flat-row-value"><strong>{{model.event_count}}</strong></div>\n </div>\n {{/hasEventCount|bool}}\n </div>\n {{/hasRows|bool}}\n {{^hasRows|bool}}\n <div class="text-secondary small">No trigger context recorded.</div>\n {{/hasRows|bool}}\n </div>\n </div>\n ',...e})}get _ruleSet(){return this.model?.get?.("rule_set")}get hasRule(){const e=this._ruleSet;return!(!e||!("object"==typeof e?e.id||e.name:e))}get ruleLabel(){const e=this._ruleSet;if(!e)return"";if("object"==typeof e&&e.name)return e.name;const t="object"==typeof e?e.id:e;return t?`RuleSet #${t}`:""}get hasCategory(){return!!this.model?.get?.("category")}get hasScope(){return!!this.model?.get?.("scope")}get hasHostname(){return!!this.model?.get?.("hostname")}get hasEventCount(){const e=this.model?.get?.("event_count");return null!=e}get sourceIp(){return ae(this.model)||""}get hasSourceIp(){return!!this.sourceIp}get targetUser(){const e=this.model?.get?.("metadata")||{};return e.user||e.email||e.username||""}get hasTargetUser(){return!!this.targetUser}get hasRows(){return this.hasRule||this.hasCategory||this.hasScope||this.hasSourceIp||this.hasTargetUser||this.hasHostname||this.hasEventCount}async onActionViewRuleset(){this.emit("action:view-ruleset")}async onActionViewSourceIp(){this.emit("action:view-source-ip")}async onActionViewUser(e,t){const i=t?.dataset?.user;this.emit("action:view-user",i)}}class IncidentResponseCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-tools"></i>What happened next</div>\n <div data-container="response-timeline"></div>\n </div>\n </div>\n ',...e})}async onInit(){this.timeline=new n.Timeline({containerId:"response-timeline",model:this.model,emptyText:"No handler activity recorded yet.",items:e=>this._buildItems(e)}),this.addChild(this.timeline)}_buildItems(e){const t=e||this.model,i=t.get("metadata")||{},s=[],a=i.handler_chain||i.handler||(t.get("rule_set")&&"object"==typeof t.get("rule_set")?t.get("rule_set").handler:null);if(a){const e=String(a).split(",").map(e=>`<code>${X(e.trim())}</code>`).join(" → ");s.push({tone:"danger",headline:"Handler chain fired",detail:e,when:ie(t.get("created"))})}if(i.blocked_ip||i.ip_blocked){const e=i.blocked_ip||ae(t),a=i.block_ttl?` · expires in ${X(se(i.block_ttl))}`:"";s.push({tone:"warning",headline:"Source IP blocked",detail:`<code>${X(String(e||""))}</code>${a}`,when:ie(i.blocked_at||t.get("created"))})}const n=i.ticket_id||i.ticket;n&&s.push({tone:"warning",headline:"Ticket created",detail:`<a href="#" data-action="view-ticket" data-ticket="${X(String(n))}">#${X(String(n))}</a>`,when:ie(i.ticket_created_at||t.get("created"))});const o=i.llm_analysis;if(o){const e=o.verdict||o.classification||"",a=null!=o.confidence?` · confidence ${X(String(o.confidence))}`:"";s.push({tone:"info",headline:"LLM triage completed",detail:e?`Verdict: <strong>${X(String(e))}</strong>${a}`:"Analysis complete",when:ie(i.llm_analyzed_at||t.get("modified"))})}else i.analysis_in_progress&&s.push({tone:"info",headline:"LLM triage in progress",detail:"Polling for results…",when:"just now"});return s}async onActionViewTicket(e,t){e.preventDefault();const i=t?.dataset?.ticket;i&&this.emit("action:view-ticket",i)}}class LLMAnalysisResultsView extends t.View{constructor(e={}){const{analysis:t={},...i}=e;super({className:"llm-analysis-results mb-3",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-secondary small ms-2">AI-generated triage</span>\n </div>\n <button class="btn btn-outline-info btn-sm" data-action="re-analyze">\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-secondary mb-2"><i class="bi bi-chat-left-text me-1"></i>Summary</h6>\n <div class="bg-body-tertiary rounded 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-secondary 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 </div>\n {{/hasProposedRule|bool}}\n </div>\n </div>\n ',...i}),this.analysis=t,this.summary=t.summary||"",this.summaryHtml="",this.hasProposedRule=!!t.proposed_ruleset_id,this.proposedRulesetId=t.proposed_ruleset_id,this.mergedCount=(t.merged_incidents||[]).length,this.mergedIds=(t.merged_incidents||[]).join(", ")}async onBeforeRender(){this.summary&&!this.summaryHtml&&(this.summaryHtml=await async function(e,t){if(!t)return"";try{const i=e.getApp(),s=await i.rest.post("/api/docit/render",{markdown:t}),a=s?.data?.data?.html||s?.data?.html;if(a)return a}catch(i){}return`<pre class="detail-error-block">${X(t)}</pre>`}(this,this.summary))}async onActionReAnalyze(){this.emit("analyze-llm")}async onActionViewProposedRule(){if(this.proposedRulesetId)try{const e=new c.RuleSet({id:this.proposedRulesetId});await e.fetch(),await a.Modal.detail(new RuleSetView({model:e}))}catch(e){this.getApp()?.toast?.error("Could not load proposed RuleSet")}}}class IncidentSourceSection extends t.View{constructor(e={}){const{sourceIP:t,ipInfo:i,...s}=e;super({className:"incident-source-section",template:'\n {{^hasSourceIp|bool}}\n <div class="text-center text-secondary py-5">\n <i class="bi bi-globe fs-1 d-block mb-2"></i>\n <p class="mb-0">No source IP available for this incident.</p>\n </div>\n {{/hasSourceIp|bool}}\n {{#hasSourceIp|bool}}\n {{^hasGeoData|bool}}\n <div class="text-secondary py-3">\n <i class="bi bi-globe me-2"></i>No GeoIP data available for {{sourceIP}}\n </div>\n {{/hasGeoData|bool}}\n {{#hasGeoData|bool}}\n <div class="detail-section-eyebrow">\n Source IP\n <span class="ms-auto">\n {{{badgesHtml}}}\n </span>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Address</div>\n <div class="detail-flat-row-value">\n <a role="button" class="font-monospace fw-semibold text-decoration-none" data-action="view-geoip">{{sourceIP}}</a>\n </div>\n </div>\n {{#hasGeoLine|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Location</div>\n <div class="detail-flat-row-value">{{geoLine}}</div>\n </div>\n {{/hasGeoLine|bool}}\n {{#hasIspLine|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">ISP</div>\n <div class="detail-flat-row-value">{{ispLine}}</div>\n </div>\n {{/hasIspLine|bool}}\n {{#hasRiskScore|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Risk score</div>\n <div class="detail-flat-row-value"><code>{{riskScore}}</code></div>\n </div>\n {{/hasRiskScore|bool}}\n\n <div class="incident-source-actions d-flex flex-wrap gap-2">\n {{{actionsHtml}}}\n </div>\n\n <div data-container="src-network"></div>\n <div data-container="src-threat"></div>\n <div data-container="src-flags"></div>\n <div data-container="src-block"></div>\n {{/hasGeoData|bool}}\n {{/hasSourceIp|bool}}\n ',...s}),this.sourceIP=t,this.ipInfo=i||null,this.geoData=null,this.threatLevel="Unknown",this.threatBadgeClass="bg-secondary",this.isBlocked=!1,this.isWhitelisted=!1,this.blockedReason="",this.geoModel=null}get hasSourceIp(){return!!this.sourceIP}get hasGeoData(){return!!this.geoData}get geoLine(){if(!this.geoData)return"";const e=this.geoData;return[e.city,e.country_name,e.country_code?`(${e.country_code})`:null].filter(Boolean).join(" · ")}get hasGeoLine(){return!!this.geoLine}get ispLine(){if(!this.geoData)return"";const e=this.geoData;return[e.isp,e.asn,e.connection_type].filter(Boolean).join(" · ")}get hasIspLine(){return!!this.ispLine}get riskScore(){return null!=this.geoData?.risk_score?String(this.geoData.risk_score):""}get hasRiskScore(){return null!=this.geoData?.risk_score}get badgesHtml(){const e=this.geoData||{};return`${[e.is_tor&&'<span class="badge bg-danger-subtle text-danger" title="TOR Exit Node">TOR</span>',e.is_vpn&&'<span class="badge bg-warning-subtle text-warning" title="VPN Detected">VPN</span>',e.is_proxy&&'<span class="badge bg-info-subtle text-info" title="Proxy">Proxy</span>',e.is_datacenter&&'<span class="badge bg-secondary-subtle text-secondary" title="Datacenter">DC</span>',e.is_known_attacker&&'<span class="badge bg-danger" title="Known Attacker">Attacker</span>',e.is_known_abuser&&'<span class="badge bg-danger-subtle text-danger" title="Known Abuser">Abuser</span>'].filter(Boolean).join(" ")} <span class="badge ${this.threatBadgeClass}">${X(this.threatLevel)}</span>${this.isBlocked?`<span class="badge bg-danger ms-1" title="${X(this.blockedReason)}"><i class="bi bi-slash-circle me-1"></i>Blocked</span>`:""}${this.isWhitelisted?'<span class="badge bg-success ms-1"><i class="bi bi-check-circle me-1"></i>Whitelisted</span>':""}`.trim()}get actionsHtml(){const e=this.geoData||{},t=[];if(this.isBlocked?t.push('<button class="btn btn-outline-success btn-sm" data-action="unblock-ip"><i class="bi bi-unlock me-1"></i>Unblock IP</button>'):t.push('<button class="btn btn-outline-danger btn-sm" data-action="block-ip"><i class="bi bi-slash-circle me-1"></i>Block IP</button>'),this.isWhitelisted||t.push('<button class="btn btn-outline-primary btn-sm" data-action="whitelist-ip"><i class="bi bi-check-circle me-1"></i>Whitelist</button>'),t.push('<button class="btn btn-outline-secondary btn-sm" data-action="view-geoip"><i class="bi bi-box-arrow-up-right me-1"></i>Open GeoIP details</button>'),null!=e.latitude&&null!=e.longitude){const i=encodeURIComponent(e.latitude),s=encodeURIComponent(e.longitude);t.push(`<a class="btn btn-outline-secondary btn-sm" target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=${i}&mlon=${s}#map=10/${i}/${s}"><i class="bi bi-geo-alt me-1"></i>View on map</a>`)}return t.join("")}async onInit(){if(this.sourceIP){if(this.ipInfo)this.geoData=this.ipInfo,this.geoModel=new n.GeoLocatedIP(this.ipInfo);else try{this.geoModel=await n.GeoLocatedIP.lookup(this.sourceIP),this.geoModel&&(this.geoData=this.geoModel.attributes)}catch(e){}this.geoData&&(this.threatLevel=(this.geoData.threat_level||"unknown").toUpperCase(),this.threatBadgeClass=this._threatBadgeClass(this.geoData.threat_level),this.isBlocked=!!this.geoData.is_blocked,this.isWhitelisted=!!this.geoData.is_whitelisted,this.blockedReason=this.geoData.blocked_reason||"Blocked")}}async onAfterRender(){if(await super.onAfterRender(),!this.geoData)return;const e=this.geoData,t={get:t=>e[t],attributes:e,on(){},off(){}};this._detailsBuilt||(this.networkView=new d.default({containerId:"src-network",model:t,columns:2,showEmptyValues:!1,title:"Network",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),await this.networkView.render(),this.threatView=new d.default({containerId:"src-threat",model:t,columns:2,showEmptyValues:!1,title:"Threat assessment",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),await this.threatView.render(),this.flagsView=new d.default({containerId:"src-flags",model:t,columns:2,showEmptyValues:!1,title:"Threat flags",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),await this.flagsView.render(),this.blockView=new d.default({containerId:"src-block",model:t,columns:2,showEmptyValues:!1,title:"Block status",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),await this.blockView.render(),this._detailsBuilt=!0)}_threatBadgeClass(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 a.Modal.form({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:0}]});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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to block IP"),!0}async onActionUnblockIp(){const e=await a.Modal.form({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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to unblock IP"),!0}async onActionWhitelistIp(){const e=await a.Modal.form({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,this._detailsBuilt=!1,await this.render()):this.getApp()?.toast?.error("Failed to whitelist IP"),!0}}class IncidentRequestSection extends t.View{constructor(e={}){const{metadata:t={},...i}=e;super({className:"incident-request-section",template:'\n <div class="detail-section-eyebrow">Request</div>\n {{#hasAnyField|bool}}\n {{#hasMethod|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Method</div>\n <div class="detail-flat-row-value"><span class="badge bg-info text-dark">{{httpMethod}}</span></div>\n </div>\n {{/hasMethod|bool}}\n {{#hasStatus|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Status</div>\n <div class="detail-flat-row-value"><code>{{httpStatus}}</code></div>\n </div>\n {{/hasStatus|bool}}\n {{#hasHost|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Host</div>\n <div class="detail-flat-row-value"><code>{{httpHost}}</code></div>\n </div>\n {{/hasHost|bool}}\n {{#hasPath|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Path</div>\n <div class="detail-flat-row-value"><code>{{httpPath}}</code></div>\n </div>\n {{/hasPath|bool}}\n {{#hasUrl|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">URL</div>\n <div class="detail-flat-row-value"><code>{{httpUrl}}</code></div>\n </div>\n {{/hasUrl|bool}}\n {{#hasProtocol|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Protocol</div>\n <div class="detail-flat-row-value"><code>{{httpProtocol}}</code></div>\n </div>\n {{/hasProtocol|bool}}\n {{#hasQueryString|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Query string</div>\n <div class="detail-flat-row-value"><code>{{httpQueryString}}</code></div>\n </div>\n {{/hasQueryString|bool}}\n {{#hasUserAgent|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">User agent</div>\n <div class="detail-flat-row-value"><code>{{httpUserAgent}}</code></div>\n </div>\n {{/hasUserAgent|bool}}\n {{/hasAnyField|bool}}\n {{^hasAnyField|bool}}\n <div class="text-secondary small">No HTTP request fields recorded.</div>\n {{/hasAnyField|bool}}\n\n {{#hasHeaders|bool}}\n <div class="detail-section-eyebrow">Headers</div>\n <pre class="detail-payload-block"><code>{{headersText}}</code></pre>\n {{/hasHeaders|bool}}\n\n {{#hasBody|bool}}\n <div class="detail-section-eyebrow">Body</div>\n <pre class="detail-payload-block"><code>{{bodyText}}</code></pre>\n {{/hasBody|bool}}\n ',...i}),this.metadata=t}get httpMethod(){return this.metadata.http_method||""}get httpStatus(){return null!=this.metadata.http_status?String(this.metadata.http_status):""}get httpHost(){return this.metadata.http_host||""}get httpPath(){return this.metadata.http_path||""}get httpUrl(){return this.metadata.http_url||""}get httpProtocol(){return this.metadata.http_protocol||""}get httpQueryString(){return this.metadata.http_query_string||""}get httpUserAgent(){return this.metadata.http_user_agent||""}get hasMethod(){return!!this.httpMethod}get hasStatus(){return null!=this.metadata.http_status}get hasHost(){return!!this.httpHost}get hasPath(){return!!this.httpPath}get hasUrl(){return!!this.httpUrl}get hasProtocol(){return!!this.httpProtocol}get hasQueryString(){return!!this.httpQueryString}get hasUserAgent(){return!!this.httpUserAgent}get hasAnyField(){return this.hasMethod||this.hasStatus||this.hasHost||this.hasPath||this.hasUrl||this.hasProtocol||this.hasQueryString||this.hasUserAgent}get hasHeaders(){return!!this.metadata.http_headers}get headersText(){const e=this.metadata.http_headers;return"string"==typeof e?e:JSON.stringify(e,null,2)}get hasBody(){return!!this.metadata.http_body}get bodyText(){const e=this.metadata.http_body;return"string"==typeof e?e:JSON.stringify(e,null,2)}}class IncidentStackTraceSection extends t.View{constructor(e={}){const{stackTrace:t="",...i}=e;super({className:"incident-stack-trace-section",template:'\n <div class="detail-section-eyebrow">Stack Trace</div>\n <div data-container="stack-trace-body"></div>\n ',...i}),this.stackTrace=t}async onInit(){try{this.body=new StackTraceView({containerId:"stack-trace-body",stackTrace:this.stackTrace}),this.addChild(this.body)}catch(e){this.body=new t.View({containerId:"stack-trace-body",template:'<pre class="detail-error-block">{{stackTrace}}</pre>'}),this.body.stackTrace=this.stackTrace,this.addChild(this.body)}}}class RuleEngineSection extends t.View{constructor(e={}){const{incident:t,...i}=e;super({className:"rule-engine-section",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.</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="detail-section-eyebrow">\n Linked RuleSet\n <span class="ms-auto 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\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>Open\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>New rule\n </button>\n </span>\n </div>\n <div data-container="ruleset-data"></div>\n <div class="detail-section-eyebrow">Rule Conditions</div>\n <div data-container="ruleset-rules"></div>\n {{/hasRuleset|bool}}\n {{^hasRuleset|bool}}\n <div class="text-center py-5">\n <div class="text-secondary mb-3"><i class="bi bi-gear fs-1"></i></div>\n <h6 class="text-secondary">No RuleSet Linked</h6>\n <p class="text-secondary small mb-3">\n This incident was not created by a rule engine match.<br>\n Create a new rule 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 ',...i}),this.incident=t;const s=this.incident.get("rule_set");this.rulesetId=s&&"object"==typeof s?s.id:s,this.rulesetModel=null,this.hasRuleset=!1,this.autoDeleteEnabled=!1,this.incidentProtected=!!this.incident.get("metadata")?.do_not_delete}async onInit(){if(!this.rulesetId)return;try{this.rulesetModel=new c.RuleSet({id:this.rulesetId}),await this.rulesetModel.fetch(),this.hasRuleset=!0,this.autoDeleteEnabled=!!this.rulesetModel.get("metadata")?.delete_on_resolution}catch(n){return}const e=this.rulesetModel.get("match_by"),t=c.MatchByOptions.find(t=>t.value===e),i=this.rulesetModel.get("bundle_by"),s=c.BundleByOptions.find(e=>e.value===i);this.rulesetDataView=new d.default({containerId:"ruleset-data",model:this.rulesetModel,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:s?s.label:String(i),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 a=new c.RuleList({params:{parent:this.rulesetId}});this.rulesTable=new l.TableView({containerId:"ruleset-rules",collection:a,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 a.Modal.modelForm({title:`Edit RuleSet — ${this.rulesetModel.get("name")}`,model:this.rulesetModel,formConfig:c.RuleSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("RuleSet updated"))}async onActionViewLinkedRuleset(){this.rulesetModel&&await a.Modal.detail(new RuleSetView({model:this.rulesetModel}))}async onActionCreateRuleFromIncident(){const e=this.incident,t=e.get("category")||"",i=e.get("scope")||"",s=e.get("metadata")||{},n=await a.Modal.form({title:"Create RuleSet from Incident",icon:"bi-gear-wide-connected",formConfig:c.RuleSetForms.create,size:"lg",data:{name:`Rule: ${t||"custom"} (from incident #${e.get("id")})`,category:i||t,priority:10,is_active:!1,bundle_by:s.source_ip?4:0,bundle_minutes:30,match_by:0}});if(!n)return;const o=new c.RuleSet,l=await o.save({...n,bundle_by:parseInt(n.bundle_by),bundle_minutes:parseInt(n.bundle_minutes)||30,match_by:parseInt(n.match_by)||0});if(!l.success&&200!==l.status)return void this.getApp()?.toast?.error("Failed to create RuleSet");await this.incident.save({rule_set:o.id}),this.rulesetId=o.id,this.rulesetModel=o,this.hasRuleset=!0;const r=await this._showMetadataRulePicker(o,s);this.getApp()?.toast?.success(r?`RuleSet created with ${r} rule condition(s)`:"RuleSet created — add rule conditions to activate"),await a.Modal.detail(new RuleSetView({model:o}))}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"]),s=Object.entries(t).filter(([e,t])=>!i.has(e)&&null!==t&&""!==t&&"object"!=typeof t).map(([e,t])=>({key:e,value:t,type:ee(t)}));if(!s.length)return 0;const n=[{type:"html",columns:12,html:'<div class="text-secondary small mb-3">\n Select metadata fields to create as rule conditions.\n Each selected field becomes an <code>==</code> match rule.\n </div>'},...s.map(e=>({name:`rule__${e.key}`,type:"switch",label:`${e.key} = ${te(String(e.value),30)}`,tooltip:`${e.type}: ${String(e.value)}`,value:!1,columns:6}))],o=await a.Modal.form({title:"Create Rules from Metadata",icon:"bi-list-check",size:"lg",fields:n,submitText:"Create Rules",cancelText:"Skip"});if(!o)return 0;const l=s.filter(e=>o[`rule__${e.key}`]);if(!l.length)return 0;const r=this.getApp();try{await Promise.all(l.map((t,i)=>(new c.Rule).save({parent:e.id,name:`Match ${t.key}`,field_name:t.key,comparator:"==",value:String(t.value),value_type:t.type,index:i})))}catch(d){r?.toast?.warning("Some rule conditions failed to save")}return l.length}}class IncidentTicketsSection extends t.View{constructor(e={}){const{incident:t,collection:i,...s}=e;super({className:"incident-tickets-section",template:'\n <div class="detail-section-eyebrow">\n Related Tickets\n <span class="ms-auto">\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 </span>\n </div>\n <div data-container="tickets-table"></div>\n ',...s}),this.incident=t,this.collection=i||new c.TicketList({params:{incident:this.incident.get("id"),sort:"-created"}})}async onInit(){this.ticketsTable=new r.ListView({containerId:"tickets-table",collection:this.collection,itemClass:TicketListItem,clickAction:"view",rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},paginated:!0,pageSize:10,emptyMessage:"No tickets linked to this incident."}),this.addChild(this.ticketsTable)}async onActionCreateTicket(){this.emit("action:create-ticket")}}class RelatedIncidentsSection extends t.View{constructor(e={}){const{collection:t,...i}=e;super({className:"related-incidents-section",template:'\n <div class="detail-section-eyebrow">Related Incidents</div>\n <div class="text-secondary small mb-2">Incidents sharing the same source IP, rule, host, or category.</div>\n <div data-container="related-table"></div>\n ',...i}),this.collection=t}async onInit(){this.relatedTable=new r.ListView({containerId:"related-table",collection:this.collection,itemClass:IncidentListItem,clickAction:"view",rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},paginated:!0,pageSize:10,emptyMessage:"No related incidents found."}),this.addChild(this.relatedTable)}}class IncidentHistorySection extends t.View{constructor(e={}){const{incidentId:t,...i}=e;super({className:"incident-history-section",template:'\n <div class="detail-section-eyebrow">History</div>\n <div data-container="history-chat"></div>\n ',...i}),this.incidentId=t}async onInit(){this.adapter=new IncidentHistoryAdapter(this.incidentId),this.chatView=new n.ChatView({containerId:"history-chat",adapter:this.adapter}),this.addChild(this.chatView)}}class IncidentMetadataSection extends t.View{constructor(e={}){super({className:"incident-metadata-section",template:'\n <div class="detail-section-eyebrow">\n Metadata\n <button class="detail-section-action" data-action="edit-metadata" data-bs-toggle="tooltip" title="{{#hasMetadata|bool}}Edit JSON{{/hasMetadata|bool}}{{^hasMetadata|bool}}Add metadata{{/hasMetadata|bool}}">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n {{^hasMetadata|bool}}\n <div class="text-secondary small">No metadata is set on this incident.</div>\n {{/hasMetadata|bool}}\n {{#hasMetadata|bool}}\n <div data-container="metadata-card"></div>\n {{/hasMetadata|bool}}\n ',...e})}get hasMetadata(){const e=this.model?.get?.("metadata")||{};return Object.keys(e).length>0}async onInit(){this.metadataCard=new n.KnownFieldsCard({containerId:"metadata-card",model:this.model,data:e=>e.get("metadata")||{},knownKeys:[{key:"source_ip",label:"Source IP",formatter:e=>`<code>${X(String(e))}</code>`},{key:"hostname",label:"Hostname",formatter:e=>`<code>${X(String(e))}</code>`},{key:"user_agent",label:"User agent"},{key:"http_url",label:"URL",formatter:e=>`<code>${X(String(e))}</code>`},{key:"http_method",label:"HTTP method",formatter:e=>`<span class="badge bg-info text-dark">${X(String(e))}</span>`},{key:"http_status",label:"HTTP status",formatter:e=>`<code>${X(String(e))}</code>`},{key:"country_code",label:"Country"},{key:"region",label:"Region"},{key:"city",label:"City"},{key:"request_path",label:"Request path",formatter:e=>`<code>${X(String(e))}</code>`},{key:"user",label:"User"},{key:"component",label:"Component"},{key:"component_id",label:"Component ID"},{key:"error_class",label:"Error class",formatter:e=>`<code>${X(String(e))}</code>`},{key:"error_message",label:"Error message"},{key:"rule_id",label:"Rule ID"},{key:"risk_score",label:"Risk score"},{key:"action",label:"Action"},{key:"trigger",label:"Trigger"},{key:"do_not_delete",label:"Protected",formatter:"yesnoicon",hideEmpty:!0}],rawLabel:"Raw metadata"}),this.addChild(this.metadataCard)}async onActionEditMetadata(){this.emit("action:edit-metadata")}}class IncidentView extends n.DetailView{constructor(e={}){const t=e.model||new c.Incident(e.data||{}),i=t.get("id"),s=ae(t),a=t.get("metadata")||{},n=new c.IncidentEventList({params:{incident:i}}),o=new c.TicketList({params:{incident:i,sort:"-created"}}),l=new c.RelatedIncidentsList({sourceIp:s||void 0,ruleSet:(t.get("rule_set")&&"object"==typeof t.get("rule_set")?t.get("rule_set").id:t.get("rule_set"))||void 0,group:t.get("group")||void 0,hostname:t.get("hostname")||void 0,category:s?void 0:t.get("category")||void 0,params:{id__not:i,size:10}}),d=new IncidentOverviewSection({model:t}),h=new r.ListView({collection:n,itemClass:EventListItem,clickAction:"view",rowStripe:e=>{const t=Number(e.get("level"));return t>=5?"danger":t>=4?"warning":null},paginated:!0,pageSize:10,emptyMessage:"No events on this incident."}),u=s?new IncidentSourceSection({sourceIP:s,ipInfo:t.get("ip_info")}):null,m=a.http_method||a.http_path||a.http_url?new IncidentRequestSection({metadata:a}):null,p=a.stack_trace||a.traceback||"",b=p?new IncidentStackTraceSection({stackTrace:p}):null,g=new RuleEngineSection({incident:t}),v=new IncidentTicketsSection({incident:t,collection:o}),y=new IncidentHistorySection({incidentId:i}),w=new RelatedIncidentsSection({collection:l}),f=new IncidentMetadataSection({model:t}),_=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:d},{key:"Events",label:"Events",icon:"bi-list-ul",view:h}];(u||m||b)&&(_.push({type:"divider",label:"Investigation"}),u&&_.push({key:"Source",label:"Source",icon:"bi-globe2",view:u}),m&&_.push({key:"Request",label:"Request",icon:"bi-funnel",view:m}),b&&_.push({key:"StackTrace",label:"Stack Trace",icon:"bi-code-square",view:b})),_.push({type:"divider",label:"Response"}),_.push({key:"RuleEngine",label:"Rule Engine",icon:"bi-tools",view:g}),_.push({key:"Tickets",label:"Tickets",icon:"bi-ticket-detailed",view:v}),_.push({key:"History",label:"History",icon:"bi-chat-left-text",view:y}),_.push({type:"divider",label:"Related"}),_.push({key:"Related",label:"Related",icon:"bi-diagram-2",view:w}),_.push({key:"Metadata",label:"Metadata",icon:"bi-braces",view:f});const k=[{icon:"bi-flag-fill",text:e=>{const t=parseInt(e.get("priority"),10);return t?`P${t} · ${re(t).label}`:null},variant:e=>re(parseInt(e.get("priority"),10)||5).variant},{icon:"bi-circle-fill",text:e=>le(e.get("status")).label,variant:e=>{const t=le(e.get("status")).tone;return"success"===t?"success":"danger"===t?"danger":"warning"===t?"warning":"light"}},{icon:"bi-tag-fill",textPath:"category",variant:"light",when:e=>!!e.get("category")},{textPath:"scope",variant:"light",when:e=>!!e.get("scope")&&e.get("scope")!==e.get("category")},{icon:"bi-hdd-network",textPath:"hostname",variant:"light",when:e=>!!e.get("hostname")},{icon:"bi-list-ul",text:e=>{const t=e.get("event_count");return null!=t?`${t} ${1===t?"event":"events"}`:null},variant:"light"},{icon:"bi-shield-fill-check",text:"Protected",variant:"warning",when:e=>!!e.get("metadata")?.do_not_delete},{icon:"bi-shield-lock",text:"TOR",variant:"danger",tooltip:"Source IP is a Tor exit node",when:e=>!!e.get("_sourceGeo")?.is_tor},{icon:"bi-shield-shaded",text:"VPN",variant:"warning",tooltip:"Source IP is a VPN exit node",when:e=>!!e.get("_sourceGeo")?.is_vpn},{icon:"bi-diagram-3",text:"Proxy",variant:"warning",tooltip:"Source IP is an open proxy",when:e=>!!e.get("_sourceGeo")?.is_proxy},{icon:"bi-hdd-stack",text:"Datacenter",variant:"warning",tooltip:"Source IP belongs to a datacenter range",when:e=>!!e.get("_sourceGeo")?.is_datacenter},{icon:"bi-cloud-fill",text:"Cloud",variant:"info",tooltip:"Source IP belongs to a cloud-provider range",when:e=>!!e.get("_sourceGeo")?.is_cloud},{icon:"bi-phone",text:"Mobile",variant:"light",tooltip:"Source IP is a mobile carrier range",when:e=>!!e.get("_sourceGeo")?.is_mobile},{icon:"bi-bug-fill",text:"Known attacker",variant:"danger",tooltip:"Source IP appears in attacker feeds",when:e=>!!e.get("_sourceGeo")?.is_known_attacker},{icon:"bi-slash-circle",text:"Blocked",variant:"danger",tooltip:"Source IP is currently blocked",when:e=>!!e.get("_sourceGeo")?.is_blocked},{icon:"bi-shield-check",text:"Whitelisted",variant:"success",tooltip:"Source IP is on the whitelist",when:e=>!!e.get("_sourceGeo")?.is_whitelisted}],x=[];x.push({label:"Re-run handler chain",action:"rerun-handler",icon:"bi-arrow-clockwise"}),s&&x.push({label:`View source IP (${s})`,action:"view-source-geoip",icon:"bi-globe"});const S=a.user||a.email||a.username;S&&x.push({label:`View user (${te(String(S),30)})`,action:"view-user",icon:"bi-person"}),t.get("rule_set")&&x.push({label:"View ruleset",action:"view-ruleset",icon:"bi-gear-wide-connected"}),x.push({type:"divider"}),x.push({label:"Mark as duplicate",action:"mark-duplicate",icon:"bi-files"}),x.push({label:"Snooze",action:"snooze",icon:"bi-moon"}),x.push({label:"Edit Incident",action:"edit-incident",icon:"bi-pencil"}),x.push({label:"Change Priority",action:"change-priority",icon:"bi-arrow-up-circle"}),a.do_not_delete?x.push({label:"Remove Protection",action:"remove-protection",icon:"bi-shield"}):x.push({label:"Protect from Deletion",action:"protect-incident",icon:"bi-shield-fill-check"}),x.push({label:"Create Ticket",action:"create-ticket",icon:"bi-ticket-perforated"}),x.push({label:"Merge Incidents",action:"merge-incidents",icon:"bi-union"}),x.push({type:"divider"}),x.push({label:"Ask AI",action:"ask-ai",icon:"bi-chat-dots"}),x.push({label:"LLM Analyze",action:"analyze-llm",icon:"bi-robot"}),x.push({type:"divider"}),x.push({label:a.do_not_delete?"Delete Incident (protected)":"Delete Incident",action:"delete-incident",icon:"bi-trash",danger:!0,disabled:!!a.do_not_delete}),super({className:"incident-view",...e,model:t,header:{icon:ce(t.get("status")).icon,iconToneFn:e=>ce(e.get("status")).tone,titleFn:e=>e.get("title")||e.get("category")||`Incident #${e.get("id")||""}`.trim(),subtitleFn:e=>function(e){const t=[],i=e.get("rule_set"),s=i&&"object"==typeof i?i.name:null,a=e.get("category");return s?t.push(`Triggered by rule ${s}`):i&&t.push(`Rule #${"object"==typeof i?i.id:i}`),a&&t.push(`scope ${a}`),e.get("id")&&t.push(`#${e.get("id")}`),t.join(" · ")}(e),chips:IncidentView._adaptChips(k),auxFn:e=>function(e){const t=(e.get("status")||"").toLowerCase(),i=le(t),s=ce(t).tone||"secondary",a=Number(e.get("created"))||0,n=Number(e.get("modified"))||0;let o=i.label,l="";if(oe.has(t)){const e=a?se(Math.max(0,Math.floor(Date.now()/1e3)-a)):"";l=e?`in flight ${e}`:""}else"resolved"===t||"closed"===t?l=n?`${ie(n)}`:"":n&&(l=`updated ${ie(n)}`);return o?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${"default"!==s?` dh-aux-dot-${s}`:""}"></span>\n <span>${X(o)}</span>\n </span>\n ${l?`<span class="dh-aux-meta">${X(l)}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:x}},sections:_,activeSection:"Overview",navWidth:200,minWidth:600}),this.eventsCollection=n,this.ticketsCollection=o,this.relatedCollection=l,this.overviewSection=d,this.eventsSection=h,this.sourceSection=u,this.requestSection=m,this.stackTraceSection=b,this.ruleEngineSection=g,this.ticketsSection=v,this.historySection=y,this.relatedSection=w,this.metadataSection=f,this._sourceIP=s}static _adaptChips(e){return e.map(e=>{if("function"!=typeof e.variant)return e;const t={...e},i=e.variant;let s;const a=e.text;return t.text=e=>(s=e,"function"==typeof a?a(e):a),Object.defineProperty(t,"variant",{get:()=>s?i(s):"light",enumerable:!0}),t})}async onAfterBuild(){this.overviewSection.on("action:resolve",()=>this.onActionResolve()),this.overviewSection.on("action:assign",()=>this.onActionAssign()),this.overviewSection.on("action:reopen",()=>this.onActionReopen()),this.overviewSection.on("action:view-ruleset",()=>this.onActionViewRuleset()),this.overviewSection.on("action:view-source-ip",()=>this.onActionViewSourceGeoip()),this.overviewSection.on("action:view-user",e=>this._openUser(e)),this.overviewSection.on("action:view-ticket",e=>this._openTicket(e)),this.overviewSection.on("action:analyze-llm",()=>this.onActionAnalyzeLlm()),this.ticketsSection.on("action:create-ticket",()=>this._handleCreateTicket()),this.metadataSection.on("action:edit-metadata",()=>this.onActionEditMetadata()),this._updateBadges(),this.eventsCollection.on("fetch:success",()=>this._updateBadges(),this),this.ticketsCollection.on("fetch:success",()=>this._updateBadges(),this),this.relatedCollection.on("fetch:success",()=>this._updateBadges(),this);try{this.getApp()?.showLoading?.(),await this.model.fetch({params:{graph:"detailed"}})}catch(e){}finally{this.getApp()?.hideLoading?.()}if(this.eventsCollection.fetch().catch(()=>{}),this.ticketsCollection.fetch().catch(()=>{}),this.relatedCollection.fetch().catch(()=>{}),this._sourceIP)try{const e=await n.GeoLocatedIP.lookup(this._sourceIP);e?.attributes&&(this.model.attributes._sourceGeo=e.attributes)}catch(e){}this.headerView?.isMounted()&&await this.headerView.render()}_updateBadges(){const e=this.eventsCollection.totalCount??this.eventsCollection.models?.length??0,t=this.ticketsCollection.totalCount??this.ticketsCollection.models?.length??0,i=this.relatedCollection.totalCount??this.relatedCollection.models?.length??0;e>0&&this.setBadge("Events",{text:String(e),variant:e>10?"danger":"muted"}),t>0&&this.setBadge("Tickets",{text:String(t),variant:"warning"}),i>0&&this.setBadge("Related",{text:String(i),variant:"muted"})}async _refreshFromModel(){this.headerView.icon=ce(this.model.get("status")).icon,this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render()}async onActionResolve(){await this._setStatus("resolved")}async onActionAssign(){const e=await a.Modal.form({title:`Assign Incident #${this.model.get("id")}`,icon:"bi-person",size:"sm",fields:[{name:"assignee",type:"text",label:"Assignee (username or email)",required:!0}]});e&&(await this.model.save({"metadata.assignee":e.assignee,status:"investigating"}),this.getApp()?.toast?.success(`Assigned to ${e.assignee}`),await this._refreshFromModel(),this.emit("detail:updated"))}async onActionReopen(){await this._setStatus("open")}async _setStatus(e){await this.model.save({status:e}),this.getApp()?.toast?.success(`Status changed to ${e}`),await this._refreshFromModel(),this.emit("detail:updated")}async onActionRerunHandler(){if(await a.Modal.confirm("Re-run the handler chain for this incident? The configured handlers will fire again as if the incident just triggered.","Re-run handler chain"))try{const e=await this.model.save({rerun_handler:1});e.success||200===e.status?(this.getApp()?.toast?.success("Handler chain re-run requested"),await this.model.fetch({params:{graph:"detailed"}}),await this._refreshFromModel()):this.getApp()?.toast?.error("Failed to re-run handler chain")}catch(e){this.getApp()?.toast?.error(e.message||"Failed to re-run handler chain")}}async onActionViewSourceGeoip(){this._sourceIP&&await GeoIPView.show(this._sourceIP)}async onActionViewUser(){const e=this.model.get("metadata")||{},t=e.user||e.email||e.username;await this._openUser(t)}async _openUser(e){if(e)try{const{default:i}=await Promise.resolve().then(()=>E),{User:s}=await Promise.resolve().then(()=>require("./chunks/User-B1rsVKZn.js")).then(e=>e.User$1),n=new s({email:e});try{await n.fetch({params:{email:e}})}catch(t){}await a.Modal.detail(new i({model:n}))}catch(i){this.getApp()?.toast?.info(`User: ${e}`)}else this.getApp()?.toast?.warning("No user attached to this incident")}async _openTicket(e){if(e)try{const t=new c.Ticket({id:e});await t.fetch();const{default:i}=await Promise.resolve().then(()=>me);await a.Modal.show(new i({model:t}),{size:"lg"})}catch(t){this.getApp()?.toast?.error("Could not load ticket")}}async onActionViewRuleset(){const e=this.model.get("rule_set"),t=e&&"object"==typeof e?e.id:e;if(t)try{const e=new c.RuleSet({id:t});await e.fetch(),await a.Modal.detail(new RuleSetView({model:e}))}catch(i){this.getApp()?.toast?.error("Could not load RuleSet")}}async onActionMarkDuplicate(){const e=await a.Modal.form({title:"Mark as duplicate",icon:"bi-files",size:"sm",fields:[{name:"parent_id",type:"number",label:"Parent incident ID",required:!0,help:"Events from this incident will be merged into the parent."}]});if(!e)return;const t=parseInt(e.parent_id,10);if(t&&t!==this.model.id)try{const e=new c.Incident({id:t}),i=await e.save({merge:[this.model.id]});i.success||200===i.status?(this.getApp()?.toast?.success(`Marked as duplicate of #${t}`),this.emit("incident:deleted",{model:this.model}),this._closeModal()):this.getApp()?.toast?.error("Failed to mark as duplicate")}catch(i){this.getApp()?.toast?.error(i.message||"Failed to mark as duplicate")}}async onActionSnooze(){const e=await a.Modal.form({title:"Snooze incident",icon:"bi-moon",size:"sm",fields:[{name:"until",type:"select",label:"Snooze for",options:[{value:3600,label:"1 hour"},{value:14400,label:"4 hours"},{value:86400,label:"24 hours"},{value:604800,label:"7 days"}],value:86400}]});if(!e)return;const t=Math.floor(Date.now()/1e3)+parseInt(e.until,10);await this.model.save({status:"paused","metadata.snooze_until":t}),this.getApp()?.toast?.success("Incident snoozed"),await this._refreshFromModel()}async onActionEditIncident(){await a.Modal.modelForm({title:`Edit Incident #${this.model.id}`,model:this.model,formConfig:c.IncidentForms.edit})&&(await this._refreshFromModel(),this.emit("detail:updated"))}async onActionChangePriority(){const e=await a.Modal.form({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}`),await this._refreshFromModel())}async onActionProtectIncident(){await this.model.save({"metadata.do_not_delete":!0}),this.getApp()?.toast?.success("Incident protected from deletion"),await this._refreshFromModel()}async onActionRemoveProtection(){await this.model.save({"metadata.do_not_delete":!1}),this.getApp()?.toast?.success("Deletion protection removed"),await this._refreshFromModel()}async onActionCreateTicket(){await this._handleCreateTicket()}async _handleCreateTicket(){const e=this.model,t=`Incident #${e.get("id")}: ${e.get("category")||e.get("title")||"Investigation"}`,i=await a.Modal.form({...c.TicketForms.create,fields:c.TicketForms.create.fields.map(i=>"title"===i.name?{...i,value:t}:"category"===i.name?{...i,value:"incident"}:"priority"===i.name?{...i,value:e.get("priority")||5}:"incident"===i.name?{...i,value:e.get("id"),type:"hidden"}:i)});if(!i)return;const s=new c.Ticket,n=await s.save({...i,incident:e.get("id")});n.success||200===n.status?(this.getApp()?.toast?.success("Ticket created"),this.ticketsCollection.fetch()):this.getApp()?.toast?.error("Failed to create ticket")}async onActionMergeIncidents(){const e=await a.Modal.form({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."}]});if(!e)return;const t=e.merge_ids.split(",").map(e=>parseInt(e.trim())).filter(e=>e&&e!==this.model.id);if(!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)`),await this._refreshFromModel()):this.getApp()?.toast?.error("Merge failed")}async onActionAskAi(){await Z(this,"incident.Incident")}async onActionAnalyzeLlm(){if(!(await a.Modal.confirm('Run LLM analysis on this incident? The AI agent will review all events, attempt to merge related incidents, and propose a new rule. 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)return void e?.toast?.error(t.data?.error||t.error||"Failed to start analysis");e?.toast?.success("LLM analysis started — polling for results…"),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._refreshFromModel();t()}).catch(()=>t())},5e3)};t()}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. Backend auto-merges keys.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }'}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{const e=await this.model.save({metadata:s});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._refreshFromModel(),this.metadataSection?.isMounted()&&await this.metadataSection.render()}catch(n){this.getApp()?.toast?.error(`Failed to save metadata: ${n.message}`)}}async onActionDeleteIncident(){this.model.get("metadata")?.do_not_delete?this.getApp()?.toast?.warning("Remove protection before deleting"):await a.Modal.confirm(`Are you sure you want to delete incident #${this.model.id}? This cannot be undone.`,"Confirm Deletion",{confirmClass:"btn-danger",confirmText:"Delete"})&&(await this.model.destroy()).success&&(this.emit("incident:deleted",{model:this.model}),this._closeModal())}_closeModal(){const e=this.element?.closest(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance(e);t&&t.hide()}}}c.Incident.VIEW_CLASS=IncidentView,c.Incident.MODEL_REF="incident.Incident";class PriorityQueueView extends t.View{constructor(e={}){super({...e,className:`sd-priority-queue ${e.className||""}`.trim()}),this.allowActions=!1!==e.allowActions,this.size=e.size||8,this.collection=new c.IncidentList({params:{priority__gte:8,status__in:"new",sort:"-created",size:this.size}}),this.items=[],this.isLoading=!0,this.hasError=!1,this.error="",this.isEmpty=!1,this.showActions=this.allowActions}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Needs Attention</h3>\n <span class="card-subtitle text-muted small">Top critical &amp; high-priority incidents</span>\n </div>\n <a class="text-muted small" href="?page=system/incidents">All incidents <i class="bi bi-arrow-right-short"></i></a>\n </div>\n <div class="card-body p-0" data-region="list">\n {{#isLoading|bool}}<div class="p-4 text-center text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading…</div>{{/isLoading|bool}}\n {{#hasError|bool}}<div class="p-3"><div class="alert alert-warning small mb-0">{{error}}</div></div>{{/hasError|bool}}\n {{#isEmpty|bool}}<div class="p-4 text-center text-success small"><i class="bi bi-check-circle me-1"></i>Nothing critical right now.</div>{{/isEmpty|bool}}\n <ol class="sd-priority-list list-unstyled mb-0">\n {{#items}}\n <li class="sd-pri-row" data-action="open-incident" data-id="{{id}}">\n <span class="sd-pri sd-pri-{{severityClass}}">{{severityLabel}}&nbsp;{{priority}}</span>\n <div class="sd-pri-body">\n <span class="sd-pri-title">{{title}}</span>\n <span class="sd-pri-meta">{{ageLabel}} · <span class="text-body sd-mono">{{sourceIp}}</span> · {{eventCount}} events</span>\n </div>\n {{#showActions|bool}}\n <span class="sd-pri-actions">\n <button type="button" class="btn btn-sm btn-link text-success p-1" data-action="resolve-incident" data-id="{{id}}" title="Resolve"><i class="bi bi-check2"></i></button>\n <button type="button" class="btn btn-sm btn-link text-secondary p-1" data-action="pause-incident" data-id="{{id}}" title="Pause"><i class="bi bi-pause"></i></button>\n </span>\n {{/showActions|bool}}\n </li>\n {{/items}}\n </ol>\n </div>\n </div>\n '}async onInit(){await this._fetchSafely()}async _fetchSafely(){try{await this.collection.fetch();const e=this.collection.models||[];this.items=e.map(e=>this._rowFor(e)),this.isLoading=!1,this.hasError=!1,this.error="",this.isEmpty=0===this.items.length}catch(e){console.warn("[PriorityQueueView] fetch failed:",e),this.items=[],this.isLoading=!1,this.hasError=!0,this.error="Could not load incidents.",this.isEmpty=!1}}_rowFor(e){const t=parseInt(e.get("priority"),10)||0,i=t>=12?"critical":t>=8?"high":t>=4?"warn":"info",s="critical"===i?"CRIT":i.toUpperCase();return{id:e.id,title:e.get("title")||e.get("details")||`Incident #${e.id}`,priority:t,severityClass:i,severityLabel:s,ageLabel:this._relativeTime(e.get("created")),sourceIp:e.get("source_ip")||e.get("metadata")?.source_ip||"—",eventCount:e.get("event_count")??e.get("metadata")?.event_count??"—"}}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async refresh(){await this._fetchSafely(),this.isMounted()&&await this.render()}async onActionOpenIncident(e,t){if(e.target.closest('[data-action="resolve-incident"], [data-action="pause-incident"]'))return;const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});if(await s.fetch(),!s.id)return;const n=new IncidentView({model:s});await a.Modal.detail(n)}async onActionResolveIncident(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;if(!(await a.Modal.confirm("Mark this incident as resolved?")))return;const s=new c.Incident({id:i});await s.save({status:"resolved"}),await this.refresh()}async onActionPauseIncident(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});await s.save({status:"paused"}),await this.refresh()}}const de=["new","open","in_progress","pending","resolved","qa","closed","ignored"],he={new:"new",open:"open",in_progress:"prog",pending:"prog",resolved:"resolved",qa:"open",closed:"closed",ignored:"closed"},ue=[{value:10,label:"P10 — Critical"},{value:9,label:"P9 — Severe"},{value:8,label:"P8 — High"},{value:7,label:"P7 — Elevated"},{value:5,label:"P5 — Normal"},{value:3,label:"P3 — Low"},{value:1,label:"P1 — Info"}];class TicketView extends t.View{constructor(e={}){super({className:"ticket-view",...e}),this.model=e.model||new c.Ticket(e.data||{})}async onBeforeRender(){const e=this.model.get("status")||"new";this.statusPillClass=he[e]||"closed",this.statusLabel=e.replace(/_/g," ");const i=this.model.get("priority")||5;this.priorityLabel=`P${i}`,this.priorityClass=i>=8?"text-danger":i>=7?"text-warning":"text-secondary";const s=this.model.get("assignee");this.assigneeName=s?.display_name||("string"==typeof s?s:null)||"Unassigned",this.categoryLabel=this.model.get("category")||"ticket",this.groupName=this.model.get("group.name")||this.model.get("group")||"None",this.hasDescription=!!this.model.get("description"),this.descriptionHtml=await async function(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}(this.model.get("description")||""),this.noteCount=this.model.get("note_count")||0,this.hasPanelSupport=!!this.getApp()?.openTicketPanel;const a=this.model.get("incident");a&&"object"==typeof a&&a.id?(this.hasLinkedIncident=!0,this.linkedIncident={id:a.id,title:a.title||"Untitled",status:a.status||"unknown",priority:a.priority}):this.hasLinkedIncident=!1,this.template='\n <div class="tv-header">\n <div class="tv-title-row">\n <div class="tv-title-block">\n <div class="tv-id-row">\n <span class="tv-id">#{{model.id}}</span>\n <span class="tv-pill tv-pill-{{statusPillClass}}" data-action="change-status" title="Change status">\n {{statusLabel}} <i class="bi bi-chevron-down"></i>\n </span>\n {{#model.created}}\n <span class="tv-time"><i class="bi bi-clock"></i>{{model.created|relative}}</span>\n {{/model.created}}\n <span class="tv-time"><i class="bi bi-chat-left-text"></i>{{noteCount}} note{{#noteCount}}{{/noteCount}}s</span>\n </div>\n <div class="tv-title">{{model.title}}</div>\n <div class="tv-fields">\n <span class="tv-field" data-action="change-priority" title="Change priority">\n <i class="bi bi-flag-fill tv-field-icon {{priorityClass}}"></i>{{priorityLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-assignee" title="Assign user">\n <i class="bi bi-person tv-field-icon"></i>{{assigneeName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-category" title="Change category">\n <i class="bi bi-tag tv-field-icon"></i>{{categoryLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tv-sep">&middot;</span>\n <span class="tv-field" data-action="change-group" title="Change group">\n <i class="bi bi-people tv-field-icon"></i>{{groupName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n </div>\n </div>\n <div class="tv-btns">\n <button class="tv-btn" data-action="ask-ai" title="Ask AI">\n <i class="bi bi-robot"></i>\n </button>\n {{#hasPanelSupport|bool}}\n <button class="tv-btn" data-action="open-panel" title="Open in side panel">\n <i class="bi bi-layout-sidebar-reverse"></i>\n </button>\n {{/hasPanelSupport|bool}}\n <div data-container="ticket-context-menu"></div>\n </div>\n </div>\n </div>\n\n <div class="tv-body">\n {{#hasLinkedIncident|bool}}\n <div class="tv-linked" data-action="open-incident" title="Open linked incident">\n <i class="bi bi-exclamation-triangle-fill tv-linked-icon"></i>\n <span class="ltitle">Incident #{{linkedIncident.id}} &middot; {{linkedIncident.title}}</span>\n <span class="lpill">{{linkedIncident.status}}</span>\n {{#linkedIncident.priority}}\n <span class="ltrail">P{{linkedIncident.priority}}</span>\n {{/linkedIncident.priority}}\n <i class="bi bi-box-arrow-up-right ltrail"></i>\n </div>\n {{/hasLinkedIncident|bool}}\n\n <div class="tv-desc">\n <button class="tv-desc-edit" data-action="edit-description" title="Edit description">\n <i class="bi bi-pencil me-1"></i>Edit\n </button>\n {{#hasDescription|bool}}\n <div class="tv-desc-body">{{{descriptionHtml}}}</div>\n {{/hasDescription|bool}}\n {{^hasDescription|bool}}\n <div class="tv-desc-empty tv-desc-add" data-action="edit-description">\n <i class="bi bi-plus-circle me-1"></i>Add description\n </div>\n {{/hasDescription|bool}}\n </div>\n </div>\n '}async onInit(){const t=new e.ContextMenu({containerId:"ticket-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",btnClass:"tv-btn",items:[{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{label:"Edit Description",action:"edit-description",icon:"bi-file-text"},{type:"divider"},{label:"Close Ticket",action:"close-ticket",icon:"bi-x-circle",class:"text-danger"}]}});this.addChild(t)}async _saveAndSync(e){await this.model.save(e);try{await this.model.fetch()}catch(t){}this.render()}async _showInlineSelect(t,i){return new Promise(s=>{let a=!1;const n=new e.ContextMenu({config:{items:t.map((e,t)=>({label:e.label,action:`pick-${t}`,class:e.active?"fw-bold":"",handler:()=>{a=!0,this.removeChild(n),s(e.value)}}))}}),o=n.closeDropdown.bind(n);n.closeDropdown=()=>{o(),a||(this.removeChild(n),s(null))},this.addChild(n),n.openAt(i.clientX,i.clientY)})}async onActionChangeStatus(e){const t=de.map(e=>({label:e.replace(/_/g," "),value:e,active:e===this.model.get("status")})),i=await this._showInlineSelect(t,e);i&&await this._saveAndSync({status:i})}async onActionChangePriority(e){const t=ue.map(e=>({label:e.label,value:e.value,active:e.value===this.model.get("priority")})),i=await this._showInlineSelect(t,e);null!=i&&await this._saveAndSync({priority:parseInt(i)})}async onActionChangeCategory(e){const t=Object.entries(c.TicketCategories).map(([e,t])=>({label:t,value:e,active:e===this.model.get("category")})),i=await this._showInlineSelect(t,e);i&&await this._saveAndSync({category:i})}async onActionChangeAssignee(){const e=await a.Modal.form({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:t.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this._saveAndSync({assignee:e.assignee}),this.getApp()?.toast?.success("Ticket assigned"))}async onActionChangeGroup(){const e=await a.Modal.form({title:"Change Group",icon:"bi-people",size:"sm",fields:[{name:"group",type:"collection",label:"Group",Collection:t.GroupList,labelField:"name",valueField:"id",required:!1,cols:12,value:this.model.get("group")}]});e&&await this._saveAndSync({group:e.group})}async onActionEditDescription(){const e=`\n <textarea data-ref="desc-textarea" rows="14" placeholder="Description (markdown supported)..."\n style="width:100%; font-family: var(--bs-font-monospace); font-size: 0.85rem; padding: 10px 12px; border: 1px solid var(--bs-border-color); border-radius: 8px; background: var(--bs-body-bg); color: var(--bs-body-color); resize: vertical; outline: none;">${(this.model.get("description")||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</textarea>\n <div class="text-muted small mt-1">Markdown supported</div>\n `,t=await a.Modal.dialog({title:`Ticket #${this.model.get("id")} — Edit Description`,body:e,size:"lg",buttons:[{text:"Cancel",class:"btn-secondary",value:null},{text:"Save",class:"btn-primary",handler:()=>{const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');return e?e.value:null}}]});null!=t&&await this._saveAndSync({description:t})}async onActionOpenPanel(){const e=this.getApp();if(!e?.openTicketPanel)return;const t=this.element?.closest(".modal");if(t){const e=window.bootstrap?.Modal?.getInstance(t);e&&e.hide()}e.openTicketPanel(this.model)}async onActionOpenIncident(){if(this.linkedIncident?.id)try{const e=new c.Incident({id:this.linkedIncident.id});await e.fetch({params:{graph:"detailed"}});const t=new IncidentView({model:e});await a.Modal.detail(t)}catch(e){this.getApp()?.toast?.error("Failed to load incident")}}async onActionAskAi(){await Z(this,"incident.Ticket")}async onActionEditTicket(){await a.Modal.modelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:c.TicketForms.edit.fields})&&this.render()}async onActionCloseTicket(){await a.Modal.confirm(`Close ticket #${this.model.get("id")}?`,"Close Ticket",{confirmText:"Close",confirmClass:"btn-warning"})&&(await this._saveAndSync({status:"closed"}),this.getApp()?.toast?.success("Ticket closed"))}}c.Ticket.VIEW_CLASS=TicketView,c.Ticket.MODEL_REF="incident.Ticket";const me=/* @__PURE__ */Object.freeze(/* @__PURE__ */Object.defineProperty({__proto__:null,default:TicketView},Symbol.toStringTag,{value:"Module"}));class TicketsQueueView extends t.View{constructor(e={}){super({...e,className:`sd-tickets-queue ${e.className||""}`.trim()}),this.allowActions=!1!==e.allowActions,this.size=e.size||8,this.collection=new c.TicketList({params:{status__in:"new,open",sort:"-priority,-created",size:this.size}}),this.items=[],this.isLoading=!0,this.hasError=!1,this.error="",this.isEmpty=!1,this.showActions=this.allowActions}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Open Tickets</h3>\n <span class="card-subtitle text-muted small">Human-actionable items</span>\n </div>\n <a class="text-muted small" href="?page=system/tickets">All tickets <i class="bi bi-arrow-right-short"></i></a>\n </div>\n <div class="card-body p-0" data-region="list">\n {{#isLoading|bool}}<div class="p-4 text-center text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading…</div>{{/isLoading|bool}}\n {{#hasError|bool}}<div class="p-3"><div class="alert alert-warning small mb-0">{{error}}</div></div>{{/hasError|bool}}\n {{#isEmpty|bool}}<div class="p-4 text-center text-success small"><i class="bi bi-check-circle me-1"></i>No open tickets.</div>{{/isEmpty|bool}}\n <ol class="sd-priority-list list-unstyled mb-0">\n {{#items}}\n <li class="sd-pri-row" data-action="open-ticket" data-id="{{id}}">\n <span class="sd-pri sd-pri-{{severityClass}}">{{severityLabel}}&nbsp;{{priority}}</span>\n <div class="sd-pri-body">\n <span class="sd-pri-title">{{title}}</span>\n <span class="sd-pri-meta">{{ageLabel}} · {{assigneeLabel}}</span>\n </div>\n {{#showActions|bool}}\n <span class="sd-pri-actions">\n <button type="button" class="btn btn-sm btn-link text-success p-1" data-action="resolve-ticket" data-id="{{id}}" title="Resolve"><i class="bi bi-check2"></i></button>\n <button type="button" class="btn btn-sm btn-link text-secondary p-1" data-action="pause-ticket" data-id="{{id}}" title="Pause"><i class="bi bi-pause"></i></button>\n </span>\n {{/showActions|bool}}\n </li>\n {{/items}}\n </ol>\n </div>\n </div>\n '}async onInit(){await this._fetchSafely()}async _fetchSafely(){try{await this.collection.fetch();const e=this.collection.models||[];this.items=e.map(e=>this._rowFor(e)),this.isLoading=!1,this.hasError=!1,this.error="",this.isEmpty=0===this.items.length}catch(e){console.warn("[TicketsQueueView] fetch failed:",e),this.items=[],this.isLoading=!1,this.hasError=!0,this.error="Could not load tickets.",this.isEmpty=!1}}_rowFor(e){const t=parseInt(e.get("priority"),10)||0,i=t>=12?"critical":t>=8?"high":t>=4?"warn":"info",s="critical"===i?"CRIT":i.toUpperCase(),a=e.get("assignee"),n=a?.display_name||a?.username;return{id:e.id,title:e.get("title")||`Ticket #${e.id}`,priority:t,severityClass:i,severityLabel:s,ageLabel:this._relativeTime(e.get("created")),assigneeLabel:n?`assigned to ${n}`:"unassigned"}}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async refresh(){await this._fetchSafely(),this.isMounted()&&await this.render()}async onActionOpenTicket(e,t){if(e.target.closest('[data-action="resolve-ticket"], [data-action="pause-ticket"]'))return;const i=t.dataset.id;if(!i)return;const s=new c.Ticket({id:i});if(await s.fetch(),!s.id)return;const n=new TicketView({model:s});await a.Modal.show(n,{size:"xl",header:!1})}async onActionResolveTicket(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;if(!(await a.Modal.confirm("Mark this ticket as resolved?")))return;const s=new c.Ticket({id:i});await s.save({status:"resolved"}),await this.refresh()}async onActionPauseTicket(e,t){e.stopPropagation();const i=t.dataset.id;if(!i)return;const s=new c.Ticket({id:i});await s.save({status:"paused"}),await this.refresh()}}const pe=["incident_events","firewall:blocks","bouncer:blocks","auth:failures"];class ThreatCompositionChart extends t.View{constructor(e={}){super({...e,className:`sd-composition ${e.className||""}`.trim()}),this.range=e.range||"30d",this._reflectRange()}_reflectRange(){this.is7d="7d"===this.range,this.is30d="30d"===this.range,this.is90d="90d"===this.range}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Threat Composition</h3>\n <span class="card-subtitle text-muted small">Daily, stacked · click any day to drill in</span>\n </div>\n <div class="btn-group btn-group-sm" role="group" aria-label="Time range">\n <button type="button" class="btn btn-outline-secondary {{#is7d|bool}}active{{/is7d|bool}}" data-action="set-range" data-range="7d">7D</button>\n <button type="button" class="btn btn-outline-secondary {{#is30d|bool}}active{{/is30d|bool}}" data-action="set-range" data-range="30d">30D</button>\n <button type="button" class="btn btn-outline-secondary {{#is90d|bool}}active{{/is90d|bool}}" data-action="set-range" data-range="90d">90D</button>\n </div>\n </div>\n <div class="card-body" data-container="chart-host"></div>\n </div>\n '}async onInit(){this.chart=new i.MetricsChart({containerId:"chart-host",slugs:pe,account:"incident",granularity:"days",chartType:"bar",defaultDateRange:this.range,compactHeader:!0,showGranularity:!1,showTypeSwitch:!1,showDateRange:!1,legendPosition:"bottom",height:280,yAxis:{label:"Count",beginAtZero:!0},tooltip:{y:"number:0"},colors:["rgba(13, 202, 240, 0.85)","rgba(220, 53, 69, 0.85)","rgba(253, 126, 20, 0.85)","rgba(179, 136, 255, 0.85)"],title:""}),this.addChild(this.chart),this.chart.on?.("chart:click",e=>{this.emit?.("composition:bar-click",e),this._openDayDrawer(e)})}async onActionSetRange(e,t){const i=t.dataset.range;i&&i!==this.range&&(this.range=i,this._reflectRange(),this.chart?.setQuickRange?.(i),await(this.chart?.fetchData?.()),await this.render())}async refresh(){return this.chart?.fetchData?.()}_openDayDrawer({x:e,datasets:t}={}){const i=e?`Day Detail · ${e}`:"Day Detail",s=(t||[]).map(e=>`<div class="d-flex justify-content-between border-bottom py-1">\n <span class="text-muted small">${this._esc(e.label||"")}</span>\n <span class="sd-mono">${Number(e.value||0).toLocaleString()}</span>\n </div>`).join("");a.Modal.drawer({eyebrow:"Composition",title:i,meta:[{icon:"bi bi-bar-chart-line",text:pe.length+" series"}],body:`\n <p class="small text-muted mb-2">Per-series totals for this bucket. To drill into the underlying events, open the Events table.</p>\n ${s||'<div class="text-muted small">No breakdown available.</div>'}\n <div class="mt-3">\n <a href="?page=system/events" class="btn btn-sm btn-outline-primary"><i class="bi bi-list-ul me-1"></i>Open Events</a>\n </div>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}const be=[{key:"events",label:"Events",category:"incident_events_by_country",account:"incident"},{key:"incidents",label:"Incidents",category:"incidents_by_country",account:"incident"},{key:"firewall",label:"Firewall",category:"firewall",account:"incident"},{key:"bouncer",label:"Bouncer",category:"bouncer",account:"incident"},{key:"logins",label:"Logins",category:null,account:null}];class GeographyPanel extends t.View{constructor(e={}){super({...e,className:`sd-geography ${e.className||""}`.trim()}),this.activeFamily=e.family||"events",this.inlineMap=!0===e.inlineMap,this._reflectFamilies(),this.leaderboard=[],this.leaderboardEmpty=!0}_reflectFamilies(){this.families=be.map(e=>({...e,active:e.key===this.activeFamily}))}async getTemplate(){const e=this.inlineMap?`\n <div class="sd-geo-grid">\n <div class="sd-geo-map-cell" data-container="inline-map"></div>\n ${this._leaderboardHtml()}\n </div>`:`\n ${this._leaderboardHtml()}`;return`\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0 d-flex justify-content-between align-items-start">\n <div>\n <h3 class="card-title sd-card-title mb-0">Geography</h3>\n <span class="card-subtitle text-muted small">Activity by country, last 7 days</span>\n </div>\n <div class="d-flex align-items-center gap-2">\n <div class="btn-group btn-group-sm" role="group" aria-label="Slug family">\n {{#families}}\n <button type="button"\n class="btn btn-outline-secondary {{#active|bool}}active{{/active|bool}}"\n data-action="set-family"\n data-family="{{key}}">{{label}}</button>\n {{/families}}\n </div>\n ${this.inlineMap?"":'\n <button type="button" class="btn btn-sm btn-outline-secondary" data-action="show-map" title="Show map">\n <i class="bi bi-globe-americas"></i>\n </button>'}\n </div>\n </div>\n <div class="card-body p-0">\n ${e}\n </div>\n </div>\n `}_leaderboardHtml(){return'\n <ol class="sd-geo-leader sd-geo-leader-full list-unstyled mb-0">\n {{#leaderboardEmpty|bool}}\n <li class="px-3 py-3 text-muted small">No country activity in window.</li>\n {{/leaderboardEmpty|bool}}\n {{#leaderboard}}\n <li class="sd-geo-leader-row" data-action="open-country" data-cc="{{cc}}" data-name="{{name}}" data-total="{{total}}">\n <span class="sd-geo-cc sd-mono">{{cc}}</span>\n <span class="sd-geo-name">{{name}}</span>\n <span class="sd-geo-num sd-mono">{{total}}</span>\n </li>\n {{/leaderboard}}\n </ol>\n '}async onInit(){if(this.inlineMap){if("logins"===this.activeFamily){const e=/* @__PURE__ */(new Date).toISOString().slice(0,10),t=new Date(Date.now()-6048e5).toISOString().slice(0,10);this.map=new LoginLocationMapView({containerId:"inline-map",height:360,mapStyle:"dark",drStart:t,drEnd:e})}else this.map=new s.MetricsCountryMapView({containerId:"inline-map",category:this._currentCategory(),account:this._currentAccount(),granularity:"days",maxCountries:20,metricLabel:this._currentLabel(),height:360,mapStyle:"dark",mapOptions:{interactive:!1}});this.addChild(this.map)}await this._fetchLeaderboard()}async onActionShowMap(){let e;if("logins"===this.activeFamily){const t=/* @__PURE__ */(new Date).toISOString().slice(0,10),i=new Date(Date.now()-6048e5).toISOString().slice(0,10);e=new LoginLocationMapView({height:560,mapStyle:"dark",drStart:i,drEnd:t})}else e=new s.MetricsCountryMapView({category:this._currentCategory(),account:this._currentAccount(),granularity:"days",maxCountries:30,metricLabel:this._currentLabel(),height:560,mapStyle:"dark",mapOptions:{interactive:!0}});await a.Modal.drawer({eyebrow:"Geography",title:this._currentLabel()+" by Country",meta:[{icon:"bi bi-calendar3",text:"Last 7 days"},{icon:"bi bi-cursor",text:"Drag, zoom, click markers"}],view:e,size:"xl"})}async _fetchLeaderboard(){const e=this.getApp()?.rest;if(!e)return this.leaderboard=[],void(this.leaderboardEmpty=!0);try{"logins"===this.activeFamily?await this._fetchLoginsLeaderboard(e):await this._fetchMetricsLeaderboard(e)}catch(t){console.warn("[GeographyPanel] leaderboard fetch failed:",t),this.leaderboard=[]}this.leaderboardEmpty=0===this.leaderboard.length}async _fetchMetricsLeaderboard(e){const t=be.find(e=>e.key===this.activeFamily),i=Math.floor((Date.now()-6048e5)/1e3),s=await e.GET("/api/metrics/fetch",{category:t.category,account:t.account,granularity:"days",with_labels:!0,dr_start:i,_:Date.now()}),a=s?.data?.data?.data||{};this.leaderboard=this._buildLeaderboard(a)}async _fetchLoginsLeaderboard(e){const t=new Date(Date.now()-6048e5).toISOString().slice(0,10),i=await e.GET("/api/account/logins/summary",{dr_start:t});if(!i?.success||!i.data?.status)return void(this.leaderboard=[]);const s=i.data.data||[];this.leaderboard=s.filter(e=>e.country_code&&e.count>0).sort((e,t)=>t.count-e.count).slice(0,10).map(e=>({cc:e.country_code,name:e.country_code,total:e.count}))}_buildLeaderboard(e){const t=[];for(const[i,s]of Object.entries(e)){const e=String(i).split(":").pop()?.toUpperCase();if(!e||2!==e.length)continue;const a=(Array.isArray(s)?s:[]).reduce((e,t)=>e+(Number(t)||0),0);a<=0||t.push({cc:e,name:e,total:a})}return t.sort((e,t)=>t.total-e.total),t.slice(0,10)}async onActionSetFamily(e,t){const i=t.dataset.family;i&&i!==this.activeFamily&&(this.activeFamily=i,this._reflectFamilies(),this.map&&(this.map.category=this._currentCategory(),this.map.metricLabel=this._currentLabel()),await this._fetchLeaderboard(),await this.render(),this.map&&await this.map.refresh())}async refresh(){if(await this._fetchLeaderboard(),this.isMounted()&&await this.render(),this.map)return this.map.refresh()}async onActionOpenCountry(e,t){const i=t.dataset.cc,s=t.dataset.name,a=t.dataset.total;i&&this.openCountryDrawer({cc:i,name:s,total:a})}_currentCategory(){return be.find(e=>e.key===this.activeFamily)?.category||be[0].category}_currentAccount(){return be.find(e=>e.key===this.activeFamily)?.account||be[0].account}_currentLabel(){return be.find(e=>e.key===this.activeFamily)?.label||be[0].label}openCountryDrawer({cc:e,name:t,total:i}){const s=this._esc(e);a.Modal.drawer({eyebrow:"Country Detail",title:`${e} · ${t||e}`,meta:[{icon:"bi bi-graph-up",text:`${i??"—"} ${this._currentLabel().toLowerCase()} / 7d`}],body:`\n <p class="small text-muted">\n For a fuller breakdown of events from this country, open the Events\n table filtered by country code.\n </p>\n <div class="mt-2">\n <a href="?page=system/events&country_code=${encodeURIComponent(e)}"\n class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events from ${s}\n </a>\n </div>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}const ge={new:"rgba(13, 202, 240, 0.85)",open:"rgba(13, 110, 253, 0.85)",investigating:"rgba(255, 193, 7, 0.85)",paused:"rgba(108, 117, 125, 0.85)",resolved:"rgba(25, 135, 84, 0.85)",closed:"rgba(73, 80, 87, 0.85)",ignored:"rgba(108, 117, 125, 0.6)",pending:"rgba(173, 181, 189, 0.85)"},ve=[{key:"critical",label:"Critical",range:"12+",color:"rgba(220, 53, 69, 0.85)",match:e=>e>=12},{key:"high",label:"High",range:"8–11",color:"rgba(253, 126, 20, 0.85)",match:e=>e>=8&&e<12},{key:"warn",label:"Warn",range:"4–7",color:"rgba(255, 193, 7, 0.85)",match:e=>e>=4&&e<8},{key:"info",label:"Info",range:"0–3",color:"rgba(13, 202, 240, 0.85)",match:e=>e<4}];class DistributionStrip extends t.View{constructor(e={}){super({...e,className:`sd-distributions ${e.className||""}`.trim()}),this._statusData=[],this.priorityRows=[],this.priorityEmpty=!0,this.funnelRows=[],this.funnelEmpty=!0}async getTemplate(){return'\n <div class="row g-3">\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Incidents by Status</h3>\n </div>\n <div class="card-body" data-container="status-donut"></div>\n </div>\n </div>\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Priority Buckets</h3>\n </div>\n <div class="card-body">\n {{#priorityEmpty|bool}}\n <div class="text-muted small">No incidents in window.</div>\n {{/priorityEmpty|bool}}\n {{^priorityEmpty|bool}}\n <ul class="list-unstyled mb-0 sd-bucket-list">\n {{#priorityRows}}\n <li class="sd-bucket-row" data-action="open-priority" data-bucket="{{key}}">\n <span class="sd-bucket-label" style="color:{{color}};">\n {{label}}<span class="sd-bucket-range">{{range}}</span>\n </span>\n <span class="sd-bucket-bar"><span style="width:{{percent}}%; background:{{color}};"></span></span>\n <span class="sd-bucket-num sd-mono">{{value}}</span>\n </li>\n {{/priorityRows}}\n </ul>\n {{/priorityEmpty|bool}}\n </div>\n </div>\n </div>\n <div class="col-lg-4">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Bouncer Funnel</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <div class="card-body">\n <div class="sd-funnel">\n {{#funnelRows}}\n <div class="sd-funnel-row">\n <div class="sd-funnel-bar">\n <span class="sd-funnel-fill" style="width:{{percent}}%; background:{{color}};">{{label}}</span>\n </div>\n <span class="sd-funnel-num sd-mono">{{value}}</span>\n </div>\n {{/funnelRows}}\n </div>\n {{#funnelEmpty|bool}}\n <div class="text-muted small">No bouncer activity in window.</div>\n {{/funnelEmpty|bool}}\n </div>\n </div>\n </div>\n </div>\n '}async onInit(){this.statusDonut=new i.PieChart({containerId:"status-donut",data:[],cutout:.6,width:200,height:200,legendPosition:"bottom",centerLabel:({total:e})=>e,centerSubLabel:"TOTAL"}),this.statusDonut.on?.("chart:click",({slice:e})=>this._openStatusDrawer(e)),this.addChild(this.statusDonut),await this._fetch()}async _fetch(){const e=this.getApp()?.rest;if(!e)return;const[t,i]=await Promise.all([this._fetchTopByField("status"),this._fetchTopByField("priority")]),s=t.reduce((e,t)=>e+t.value,0);this._statusData=this._buildStatusSlices(t),this.priorityRows=this._bucketByPriority(i),this.priorityEmpty=0===s;try{const t=Math.floor((Date.now()-6048e5)/1e3),i=await e.GET("/api/metrics/fetch",{slugs:"bouncer:assessments,bouncer:monitors,bouncer:blocks",account:"incident",granularity:"days",with_labels:!0,dr_start:t,_:Date.now()}),s=i?.data?.data?.data||{},a={};for(const[e,n]of Object.entries(s))a[e]=(Array.isArray(n)?n:[]).reduce((e,t)=>e+(Number(t)||0),0);this.funnelRows=this._buildFunnel(a)}catch(a){console.warn("[DistributionStrip] bouncer fetch failed:",a),this.funnelRows=[]}this.funnelEmpty=0===this.funnelRows.length||this.funnelRows.every(e=>0===e.value),this.statusDonut?.setData(this._statusData)}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetchTopByField(e){const t=this.getApp()?.rest;if(!t)return[];try{const i=await t.GET("/api/incident/incident",{_mode:"top",_field:e,_size:50,_:Date.now()}),s=i?.data?.data;return Array.isArray(s)?s.map(e=>({key:String(e.key??""),value:Number(e.value)||0})):[]}catch(i){return console.warn(`[DistributionStrip] _mode=top fetch failed for ${e}:`,i),[]}}_buildStatusSlices(e){return e.filter(e=>e.key).map(e=>{const t=e.key.toLowerCase();return{label:t.charAt(0).toUpperCase()+t.slice(1),value:e.value,color:ge[t]||"rgba(108, 117, 125, 0.6)"}}).sort((e,t)=>t.value-e.value)}_bucketByPriority(e){const t=ve.map(e=>({...e,value:0}));for(const s of e){const e=parseInt(s.key,10);if(!Number.isFinite(e))continue;const i=t.find(t=>t.match(e));i&&(i.value+=s.value)}const i=Math.max(1,...t.map(e=>e.value));return t.map(e=>({key:e.key,label:e.label,range:e.range,color:e.color,value:e.value,percent:Math.round(e.value/i*100)}))}_buildFunnel(e){const t=[{key:"bouncer:assessments",label:"Assessments",color:"rgba(76, 201, 240, 0.95)"},{key:"bouncer:monitors",label:"Monitors",color:"rgba(245, 165, 36, 0.95)"},{key:"bouncer:blocks",label:"Blocks",color:"rgba(255, 90, 90, 0.95)"}].map(t=>({...t,value:Number(e[t.key]??0)}));if(t.every(e=>0===e.value))return[];const i=Math.max(1,...t.map(e=>e.value));return t.map(e=>({...e,value:e.value.toLocaleString(),percent:Math.max(12,Math.round(e.value/i*100))}))}_openStatusDrawer(e){if(!e)return;const t=String(e.label).toLowerCase(),i=this._esc(e.label);a.Modal.drawer({eyebrow:"Status Filter",title:`Incidents · ${e.label}`,meta:[{icon:"bi bi-pie-chart",text:`${e.value} (${e.pct.toFixed(1)}%)`}],body:`\n <p class="small text-muted">View the full incident list filtered by this status.</p>\n <a href="?page=system/incidents&status=${encodeURIComponent(t)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Incidents (${i})\n </a>\n `,size:"md"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}async onActionOpenPriority(e,t){const i=t.dataset.bucket;if(!i)return;const s=ve.find(e=>e.key===i);if(!s)return;const[n,o]="12+"===s.range?[12,null]:s.range.split("–").map(Number),l=`priority__gte=${n}`+(null!=o?`&priority__lte=${o}`:"");a.Modal.drawer({eyebrow:"Priority Bucket",title:`Incidents · ${s.label} (${s.range})`,view:null,body:`\n <p class="small text-muted">View the full incident list filtered by priority.</p>\n <a href="?page=system/incidents&${l}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Incidents\n </a>\n `,size:"sm"})}}const ye=e=>{const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML},we=["ossec"];class TopSourcesPanel extends t.View{constructor(e={}){super({...e,className:`sd-top-sources ${e.className||""}`.trim()}),this.allowBlock=!1!==e.allowBlock,this.excludeCategories=Array.isArray(e.excludeCategories)?e.excludeCategories:we,this.ips=[],this.cats=[],this.ipsEmpty=!0,this.catsEmpty=!0}async getTemplate(){return'\n <div class="row g-3">\n <div class="col-lg-6">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Top Source IPs</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <ul class="list-unstyled mb-0 sd-rank-list">\n {{#ipsEmpty|bool}}<li class="px-3 py-4 text-muted small">No source IPs in window.</li>{{/ipsEmpty|bool}}\n {{#ips}}\n <li class="d-flex align-items-center gap-2 px-3 py-2 border-top sd-rank-row" data-action="open-ip" data-ip="{{name}}">\n <span class="text-muted small sd-mono" style="width:1.5rem; text-align:right;">{{rank}}</span>\n <span class="flex-grow-1 sd-mono">{{name}}</span>\n <div class="progress sd-progress" style="height:6px; width:96px;">\n <div class="progress-bar bg-danger" style="width:{{percent}}%"></div>\n </div>\n <span class="sd-mono small">{{value}}</span>\n {{#allowBlock|bool}}\n <button type="button" class="btn btn-sm btn-link text-danger p-1" data-action="block-ip" data-ip="{{name}}" title="Block IP">\n <i class="bi bi-shield-fill-x"></i>\n </button>\n {{/allowBlock|bool}}\n </li>\n {{/ips}}\n </ul>\n </div>\n </div>\n <div class="col-lg-6">\n <div class="card sd-card h-100">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Top Categories</h3>\n <span class="card-subtitle text-muted small">Last 7 days</span>\n </div>\n <ul class="list-unstyled mb-0 sd-rank-list">\n {{#catsEmpty|bool}}<li class="px-3 py-4 text-muted small">No category activity in window.</li>{{/catsEmpty|bool}}\n {{#cats}}\n <li class="d-flex align-items-center gap-2 px-3 py-2 border-top sd-rank-row" data-action="open-category" data-cat="{{name}}">\n <span class="text-muted small sd-mono" style="width:1.5rem; text-align:right;">{{rank}}</span>\n <span class="flex-grow-1 sd-mono">{{name}}</span>\n <div class="progress sd-progress" style="height:6px; width:96px;">\n <div class="progress-bar" style="width:{{percent}}%; background-color: rgba(179, 136, 255, 0.85);"></div>\n </div>\n <span class="sd-mono small">{{value}}</span>\n </li>\n {{/cats}}\n </ul>\n </div>\n </div>\n </div>\n '}_withRank(e){const t=Math.max(1,...e.map(e=>e.value));return e.map((e,i)=>({...e,rank:i+1,percent:Math.round(e.value/t*100)}))}async onInit(){await this._fetch()}async _fetch(){const e=Math.floor((Date.now()-6048e5)/1e3),t=this.excludeCategories.length?{category__not_in:this.excludeCategories.join(",")}:{},[i,s]=await Promise.all([this._fetchTop("source_ip",e),this._fetchTop("category",e,t)]);this.ips=this._withRank(i),this.cats=this._withRank(s),this.ipsEmpty=0===this.ips.length,this.catsEmpty=0===this.cats.length}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetchTop(e,t,i={}){const s=this.getApp()?.rest;if(!s)return[];try{const a=await s.GET("/api/incident/event",{_mode:"top",_field:e,_size:10,dr_start:t,...i,_:Date.now()}),n=a?.data?.data;return Array.isArray(n)?n.filter(e=>e.key&&"—"!==e.key).map(e=>({name:String(e.key),value:Number(e.value)||0})):[]}catch(a){return console.warn(`[TopSourcesPanel] _mode=top fetch failed for ${e}:`,a),[]}}async onActionOpenIp(e,t){if(e.target.closest('[data-action="block-ip"]'))return;const i=t.dataset.ip;if(!i)return;const s=ye(i);a.Modal.drawer({eyebrow:"Source IP",title:i,meta:[{icon:"bi bi-clock",text:"Last 7 days"}],body:`\n <p class="small text-muted">Open the events table filtered by this source IP.</p>\n <a href="?page=system/events&source_ip=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events from ${s}\n </a>\n `,size:"sm"})}async onActionOpenCategory(e,t){const i=t.dataset.cat;if(!i)return;const s=ye(i);a.Modal.drawer({eyebrow:"Category",title:i,meta:[{icon:"bi bi-clock",text:"Last 7 days"}],body:`\n <p class="small text-muted">Open the events table filtered by this category.</p>\n <a href="?page=system/events&category=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events · ${s}\n </a>\n `,size:"sm"})}async onActionBlockIp(e,t){e.stopPropagation();const i=t.dataset.ip;if(!i)return;if(!(await a.Modal.confirm(`Add ${ye(i)} to the firewall block list?`)))return;const s=this.getApp()?.rest;if(s)try{await s.POST("/api/system/geoip",{ip:i,is_blocked:!0}),this.getApp()?.toast?.success?.(`Blocked ${i}`)}catch(n){this.getApp()?.toast?.error?.(n?.message||"Failed to block IP")}}}const fe=[{key:"password_reset",label:"Pwd Resets"},{key:"totp:login_failed",label:"TOTP Fails"},{key:"sessions:revoked",label:"Revoked"},{key:"account:deactivated",label:"Deactivated"}];class AuthFailuresPanel extends t.View{constructor(e={}){super({...e,className:`sd-auth-failures ${e.className||""}`.trim()}),this.tiles=fe.map(e=>({...e,value:null,display:"—"}))}async getTemplate(){return'\n <div class="card sd-card">\n <div class="card-header bg-transparent border-0">\n <h3 class="card-title sd-card-title mb-0">Auth Failures</h3>\n <span class="card-subtitle text-muted small">Aggregate slug <code>auth:failures</code> · last 30 days</span>\n </div>\n <div class="card-body">\n <div data-container="chart-host" class="mb-3"></div>\n <div class="row g-2">\n {{#tiles}}\n <div class="col-6 col-lg-3">\n <button type="button"\n class="card w-100 text-start border sd-auth-tile"\n data-action="open-sub-tile"\n data-category="{{key}}">\n <div class="card-body py-2 px-3">\n <div class="sd-auth-tile-label">{{label}} <span class="sd-auth-tile-suffix">24H</span></div>\n <div class="sd-mono sd-auth-tile-value">{{display}}</div>\n </div>\n </button>\n </div>\n {{/tiles}}\n </div>\n </div>\n </div>\n '}async onInit(){this.chart=new i.MetricsChart({containerId:"chart-host",slugs:["auth:failures"],account:"incident",granularity:"days",defaultDateRange:"30d",chartType:"bar",compactHeader:!0,showDateRange:!1,showGranularity:!1,showTypeSwitch:!1,showLegend:!1,height:200,colors:["rgba(179, 136, 255, 0.85)"],yAxis:{label:"Failures",beginAtZero:!0},tooltip:{y:"number:0"},title:""}),this.addChild(this.chart),await this._fetchSubTiles()}async refresh(){await Promise.allSettled([this.chart?.fetchData?.(),this._fetchSubTiles()]),this.isMounted()&&await this.render()}async _fetchSubTiles(){const e=this.getApp()?.rest;if(!e)return;const t=Math.floor((Date.now()-864e5)/1e3),i=await Promise.all(this.tiles.map(async i=>{try{const s=await e.GET("/api/incident/event",{category:i.key,dr_start:t,size:0,_:Date.now()}),a=s?.data?.count??s?.data?.data?.count??null,n="number"==typeof a?a:0;return{...i,value:n,display:String(n)}}catch(s){return{...i,value:null,display:"—"}}}));this.tiles=i}async onActionOpenSubTile(e,t){const i=t.dataset.category;if(!i)return;const s=this._esc(i);a.Modal.drawer({eyebrow:"Auth Failure Category",title:i,meta:[{icon:"bi bi-clock",text:"Last 24 hours"}],body:`\n <p class="small text-muted">Open the events table filtered by this category.</p>\n <a href="?page=system/events&category=${encodeURIComponent(i)}" class="btn btn-sm btn-outline-primary">\n <i class="bi bi-list-ul me-1"></i>Open Events · ${s}\n </a>\n `,size:"sm"})}_esc(e){const t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}}class HealthStrip extends t.View{constructor(e={}){super({...e,tagName:"div",className:`sd-health ${e.className||""}`.trim()}),this.rows=[],this.dots=[{dot:"good"}],this.summary="Loading…",this.empty=!1,this._fetchedOnce=!1}async getTemplate(){return'\n <details class="card sd-card sd-health-card" open>\n <summary class="card-header bg-transparent border-0 d-flex justify-content-between align-items-center sd-health-summary">\n <div class="d-flex align-items-center gap-3">\n <span class="sd-eyebrow">System Health</span>\n <span class="text-muted small d-inline-flex align-items-center gap-2">\n {{#dots}}<span class="sd-dot sd-dot-{{dot}}"></span>{{/dots}}\n <span class="ms-1">{{summary}}</span>\n </span>\n </div>\n <i class="bi bi-chevron-up sd-health-toggle"></i>\n </summary>\n <ul class="list-unstyled mb-0 sd-health-list">\n {{#empty|bool}}\n <li class="px-3 py-3 text-success small"><i class="bi bi-check-circle me-1"></i>All systems healthy.</li>\n {{/empty|bool}}\n {{#rows}}\n <li class="px-3 py-2 border-top d-flex align-items-center gap-3 sd-health-row {{#hasIncident|bool}}sd-health-row-link{{/hasIncident|bool}}"\n {{#hasIncident|bool}}data-action="open-incident" data-id="{{incidentId}}"{{/hasIncident|bool}}>\n <span class="sd-dot sd-dot-{{dot}}"></span>\n <div class="flex-grow-1 min-w-0">\n <div class="sd-mono small">{{category}}</div>\n <div class="text-muted small text-truncate">{{details}}</div>\n </div>\n <span class="text-muted small">{{when}}</span>\n <span class="badge text-bg-light sd-mono">level {{level}}</span>\n </li>\n {{/rows}}\n </ul>\n </details>\n '}async onInit(){await this._fetch()}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetch(){const e=this.getApp()?.rest;if(!e)return this.rows=[],this._fetchedOnce=!0,void this._reflectState();try{const t=await e.GET("/api/incident/health/summary",{_:Date.now()}),i=t?.data?.data||[];this.rows=i.map(e=>this._normalize(e))}catch(t){console.warn("[HealthStrip] fetch failed:",t),this.rows=[]}finally{this._fetchedOnce=!0,this._reflectState()}}_reflectState(){this.empty=0===this.rows.length&&this._fetchedOnce,this.dots=this.rows.length?this.rows.map(e=>({dot:e.dot})):[{dot:"good"}],this.summary=this._buildSummary()}_normalize(e){const t=parseInt(e.level,10)||0,i=t>=10?"crit":t>=6?"warn":"good";return{category:e.category||"—",details:e.details||e.title||"",level:t,dot:i,when:this._relativeTime(e.last_seen),incidentId:e.incident_id||null,hasIncident:!!e.incident_id}}_buildSummary(){if(!this.rows.length)return this._fetchedOnce?"All systems healthy":"Loading…";const e=this.rows.filter(e=>"crit"===e.dot).length,t=this.rows.filter(e=>"warn"===e.dot).length,i=this.rows.filter(e=>"good"===e.dot).length,s=[];return e&&s.push(`${e} critical`),t&&s.push(`${t} warning${t>1?"s":""}`),i&&s.push(`${i} healthy`),s.join(" · ")}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}async onActionOpenIncident(e,t){const i=t.dataset.id;if(!i)return;const s=new c.Incident({id:i});if(await s.fetch(),!s.id)return;const n=new IncidentView({model:s});await a.Modal.detail(n)}}class SecurityDashboardPage extends e.Page{constructor(e={}){super({...e,title:"Security Dashboard",className:"security-dashboard-page"})}async getTemplate(){return'\n <div class="security-dashboard">\n <header class="sd-page-head">\n <div>\n <span class="sd-eyebrow">Security</span>\n <h1 class="sd-page-title">Security Dashboard</h1>\n </div>\n <div class="sd-page-controls">\n <span class="sd-updated text-muted small me-2">\n <i class="bi bi-circle-fill text-success me-1" style="font-size:0.5rem;"></i>\n Live\n </span>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all panels">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n </header>\n\n <section class="sd-section">\n <div data-container="status-strip"></div>\n </section>\n\n <section class="sd-section sd-grid sd-grid-2">\n <div data-container="priority-queue"></div>\n <div data-container="tickets-queue"></div>\n </section>\n\n <section class="sd-section sd-grid sd-grid-2-3">\n <div data-container="geography"></div>\n <div data-container="composition"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="distributions"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="top-sources"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="auth-failures"></div>\n </section>\n\n <section class="sd-section">\n <div data-container="health-strip"></div>\n </section>\n </div>\n '}async onInit(){const e=this.getApp(),t=!!e?.activeUser?.hasPermission?.("manage_security");this.statusStrip=new StatusStripPanel({containerId:"status-strip"}),this.addChild(this.statusStrip),this.priorityQueue=new PriorityQueueView({containerId:"priority-queue",allowActions:t}),this.addChild(this.priorityQueue),this.ticketsQueue=new TicketsQueueView({containerId:"tickets-queue",allowActions:t}),this.addChild(this.ticketsQueue),this.composition=new ThreatCompositionChart({containerId:"composition"}),this.addChild(this.composition),this.geography=new GeographyPanel({containerId:"geography"}),this.addChild(this.geography),this.distributions=new DistributionStrip({containerId:"distributions"}),this.addChild(this.distributions),this.topSources=new TopSourcesPanel({containerId:"top-sources",allowBlock:t}),this.addChild(this.topSources),this.authFailures=new AuthFailuresPanel({containerId:"auth-failures"}),this.addChild(this.authFailures),this.healthStrip=new HealthStrip({containerId:"health-strip"}),this.addChild(this.healthStrip)}async onEnter(){const e=this._wasExited;await super.onEnter();const t={tier:"fast",immediate:e},i={tier:"slow",immediate:e};this.scheduleRefresh(()=>this.statusStrip?.refresh(),6e4,t),this.scheduleRefresh(()=>this.priorityQueue?.refresh(),6e4,t),this.scheduleRefresh(()=>this.ticketsQueue?.refresh(),6e4,t),this.scheduleRefresh(()=>this.composition?.refresh(),3e5,i),this.scheduleRefresh(()=>this._refreshLazyMounted(),3e5,i)}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}async _refreshLazyMounted(){const e=[this.geography,this.distributions,this.topSources,this.authFailures,this.healthStrip];await Promise.allSettled(e.filter(e=>e?._lazyTriggered&&"function"==typeof e.refresh).map(e=>e.refresh()))}}c.Incident.VIEW_CLASS=IncidentView;class IncidentTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_incidents",pageName:"Manage Incidents",router:"admin/incidents",Collection:c.IncidentList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-id",status:"new"},dayRangeFilter:!0,searchPlaceholder:"Search title, message, or ID",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,visibility:"lg",filter:{type:"text"}},{key:"category",label:"Category",sortable:!0,visibility:"lg",filter:{type:"text"}},{key:"priority",label:"Priority",visibility:"xl",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"}}],rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},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}})}onActionBatchResolve(){return this.batchAction({field:"status",value:"resolved",label:"Resolve"})}onActionBatchOpen(){return this.batchAction({field:"status",value:"open",label:"Open"})}onActionBatchPause(){return this.batchAction({field:"status",value:"paused",label:"Pause"})}onActionBatchIgnore(){return this.batchAction({field:"status",value:"ignored",label:"Ignore"})}onActionBatchProtect(){return this.batchAction({label:"Protect",message:`Protect ${this.tableView.getSelectedItems().length} incident(s) from deletion?`,handler:e=>e.save({metadata:{do_not_delete:!0}})})}async onActionBatchMerge(e,t){const i=this.tableView.getSelectedItems();if(!i.length)return;const s=this.getApp(),a=await s.showForm({title:`Merge ${i.length} incidents`,fields:[{name:"merge",type:"select",label:"Select Parent Incident",options:i.map(e=>({value:e.model.id,label:e.model.id})),required:!0}]});if(!a)return;const n=i.find(e=>e.model.id==a.merge)?.model;if(!n)return;const o=i.map(e=>e.model.id).filter(e=>e!=a.merge);await n.save({merge:o}),this.tableView.clearSelection(),await this.tableView.refresh()}}const _e={user:t.User,userdevice:t.UserDevice,userdevicelocation:t.UserDeviceLocation,geolocatedip:n.GeoLocatedIP,member:l.Member,incident:c.Incident,incidentevent:c.IncidentEvent,ticket:c.Ticket,job:c.Job,log:l.Log,apikey:ApiKey};class EventView extends t.View{constructor(e={}){super({className:"event-view",template:'\n <div class="event-view-container">\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-secondary small">\n Category: {{model.category|capitalize|default(\'—\')}}\n </div>\n <div class="text-secondary small mt-1">\n {{{model.created|epoch|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 <div data-container="event-tabs"></div>\n </div>\n ',...e}),this.model=e.model||new c.IncidentEvent(e.data||{}),this.eventIcon=function(e){const t=Number(e)||0;return t>=40?{icon:"bi-exclamation-octagon-fill",color:"text-danger"}:t>=30?{icon:"bi-exclamation-triangle-fill",color:"text-warning"}:t>=20?{icon:"bi-info-circle-fill",color:"text-info"}:{icon:"bi-bell-fill",color:"text-secondary"}}(this.model.get("level"))}async onInit(){this.overviewView=new d.default({model:this.model,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:"created",label:"Created",formatter:"epoch|datetime"},{name:"details",label:"Details",columns:12}]});const t={Overview:this.overviewView},i=this.model.get("metadata")||{};i.stack_trace&&(this.stackTraceView=new StackTraceView({stackTrace:i.stack_trace}),t["Stack Trace"]=this.stackTraceView),Object.keys(i).length>0&&(this.metadataView=new n.KnownFieldsCard({model:this.model,data:e=>e.get("metadata")||{},knownKeys:[{key:"source_ip",label:"Source IP"},{key:"request_ip",label:"Request IP"},{key:"http_method",label:"Method"},{key:"http_host",label:"Host"},{key:"http_path",label:"Path"},{key:"http_protocol",label:"Protocol"},{key:"http_query_string",label:"Query string"},{key:"http_status",label:"HTTP status"},{key:"http_user_agent",label:"User agent"},{key:"server",label:"Server"},{key:"city",label:"City"},{key:"region",label:"Region"},{key:"country_name",label:"Country"},{key:"country_code",label:"Country code"},{key:"timezone",label:"Timezone"},{key:"latitude",label:"Latitude"},{key:"longitude",label:"Longitude"},{key:"scope",label:"Scope"},{key:"category",label:"Category"},{key:"title",label:"Title"},{key:"details",label:"Details"},{key:"level",label:"Level"},{key:"model_name",label:"Model"},{key:"model_id",label:"Model ID"},{key:"error_class",label:"Error class"},{key:"error_message",label:"Error message"},{key:"hostname",label:"Hostname"},{key:"user_agent",label:"User agent (legacy)"},{key:"http_url",label:"URL (legacy)"},{key:"request_path",label:"Request path (legacy)"}],rawLabel:"Raw metadata"}),t.Metadata=this.metadataView),this.tabView=new o.TabView({containerId:"event-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const s=[{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}],a=new e.ContextMenu({containerId:"event-context-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots-vertical",items:s}});this.addChild(a)}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 c.Incident({id:e}),i=new IncidentView({model:t});await a.Modal.detail(i)}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 i=e.toLowerCase().replace(/[^a-z]/g,""),s=_e[i];return s?s.VIEW_CLASS?void(await a.Modal.showModelById(s,t)):(this.getApp()?.toast?.warning(`No detail view available for ${e}`),!0):(this.getApp()?.toast?.warning(`Unknown model type: ${e}`),!0)}async onActionDeleteEvent(){await a.Modal.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})}}c.IncidentEvent.VIEW_CLASS=EventView,c.IncidentEvent.MODEL_REF="incident.Event",c.IncidentEvent.VIEW_CLASS=EventView;class EventTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_events",pageName:"System Events",router:"admin/events",Collection:c.IncidentEventList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, message, or ID",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",visibility:"lg",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",visibility:"lg",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,visibility:"lg",filter:{type:"text"}},{key:"metadata.server",label:"Server",sortable:!0,visibility:"xl",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"}}],rowStripe:e=>{const t=Number(e.get("level"));return t>=5?"danger":t>=4?"warning":null},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}})}}c.Ticket.VIEW_CLASS=TicketView;const ke={new:"tt-pill-new",open:"tt-pill-open",in_progress:"tt-pill-prog",pending:"tt-pill-prog",resolved:"tt-pill-resolved",qa:"tt-pill-open",closed:"tt-pill-closed",ignored:"tt-pill-closed"},xe={security:"tt-dot-security",incident:"tt-dot-security",bug:"tt-dot-amber",qa:"tt-dot-amber",feature:"tt-dot-accent",ticket:"tt-dot-accent",fulfillment:"tt-dot-green",new_user:"tt-dot-muted",new_group:"tt-dot-muted"};function Se(e){const t=(e||"new").toString();return`<span class="tt-pill ${ke[t]||"tt-pill-closed"}">${t.replace(/_/g," ")}</span>`}function Ce(e){const t=(e||"ticket").toString();return`<span class="tt-cat"><span class="tt-cat-dot ${xe[t]||"tt-dot-muted"}"></span>${t.replace(/_/g," ")}</span>`}function Ae(e){return`<span class="tt-id">${e??""}</span>`}function Te(e){const t=parseInt(e);return Number.isFinite(t)?`<span class="tt-pri ${t>=8?"tt-pri-hi":t>=5?"tt-pri-md":"tt-pri-lo"}">${t}</span>`:""}class TicketTablePage extends n.TablePage{constructor(e={}){super({name:"admin_tickets",pageName:"Tickets",router:"admin/tickets",Collection:c.TicketList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-priority",status__in:"new,open"},columns:[{key:"id",label:"ID",width:"60px",sortable:!0,formatter:Ae},{key:"title",label:"Title",sortable:!0},{key:"status",label:"Status",sortable:!0,width:"100px",formatter:Se,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:"Pri",sortable:!0,width:"50px",formatter:Te},{key:"category",label:"Category",sortable:!0,width:"110px",formatter:Ce,editable:!0,editableOptions:{type:"select",options:[...Object.keys(c.TicketCategories)]},filter:{type:"multiselect",placeHolder:"Select Category",options:[...Object.keys(c.TicketCategories)]}},{key:"created",label:"Created",sortable:!0,width:"80px",formatter:"relative",class:"tt-time"},{key:"modified",label:"Activity",sortable:!0,width:"80px",formatter:"relative",class:"tt-time"}],rowStripe:e=>{const t=parseInt(e.get("priority"),10);return Number.isFinite(t)?t>=8?"danger":t>=5?"warning":null:null},selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No tickets found.",tableOptions:{striped:!1,bordered:!1,hover:!0,responsive:!1},...e})}buildTemplate(){return'\n <style>\n .ticket-table-page table { font-size: 0.82rem; }\n .ticket-table-page table thead th { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; color: var(--bs-secondary-color); padding: 8px 10px; border-bottom: 1px solid var(--bs-border-color); border-top: none; background: transparent; }\n .ticket-table-page table tbody td { padding: 10px 10px; border-bottom: 1px solid var(--bs-border-color-translucent); border-top: none; vertical-align: middle; color: var(--bs-body-color); }\n .ticket-table-page table tbody tr { cursor: pointer; transition: background 0.1s; }\n .ticket-table-page table tbody tr:hover { background: var(--bs-tertiary-bg); }\n .ticket-table-page table tbody tr.selected { background: rgba(var(--bs-primary-rgb), 0.08); }\n .ticket-table-page table { border-collapse: collapse; }\n\n .tt-id { color: var(--bs-secondary-color); font-family: var(--bs-font-monospace); font-size: 0.78rem; }\n .tt-time { color: var(--bs-secondary-color); font-size: 0.78rem; white-space: nowrap; }\n\n .tt-pill { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.68rem; font-weight: 500; letter-spacing: 0.01em; text-transform: lowercase; }\n .tt-pill-new { background: rgba(var(--bs-info-rgb), 0.1); color: var(--bs-info); }\n .tt-pill-open { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tt-pill-prog { background: rgba(var(--bs-warning-rgb), 0.12); color: var(--bs-warning); }\n .tt-pill-resolved { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tt-pill-closed { background: var(--bs-secondary-bg); color: var(--bs-secondary-color); }\n\n .tt-pri { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; border-radius: 4px; font-size: 0.74rem; font-weight: 500; padding: 0 6px; }\n .tt-pri-hi { background: rgba(var(--bs-danger-rgb), 0.1); color: var(--bs-danger); }\n .tt-pri-md { background: rgba(var(--bs-warning-rgb), 0.12); color: var(--bs-warning); }\n .tt-pri-lo { color: var(--bs-secondary-color); }\n\n .tt-cat { display: inline-flex; align-items: center; gap: 6px; font-size: 0.78rem; color: var(--bs-body-color); text-transform: capitalize; }\n .tt-cat-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }\n .tt-dot-security { background: var(--bs-danger); }\n .tt-dot-accent { background: var(--bs-primary); }\n .tt-dot-amber { background: var(--bs-warning); }\n .tt-dot-green { background: var(--bs-success); }\n .tt-dot-muted { background: var(--bs-secondary-color); }\n\n .ticket-table-page .table-toolbar,\n .ticket-table-page .toolbar { padding: 6px 0 10px; border-bottom: none; }\n .ticket-table-page .pagination-container { padding-top: 8px; }\n </style>\n <div class="ticket-table-page">\n <div class="table-container" data-container="table"></div>\n </div>\n '}}function Me(e){return String(e??"").replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[e]))}c.RuleSet.VIEW_CLASS=RuleSetView;class RuleSetTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_rulesets",pageName:"Rule Engine",router:"admin/rulesets",Collection:c.RuleSetList,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"priority",size:50},...n.groupByField("priority",{format:e=>`Priority ${e}`}),columns:[{key:"name",label:"Rule",sortable:!0,formatter:(e,t)=>function(e){const t=e.get("id"),i=e.get("name")||"(unnamed)",s=!!e.get("is_active"),a=s?"bi-check-circle-fill":"bi-circle",n=s?"Active":"Inactive",o=s?"is-active":"is-inactive";return`\n <div class="rs-row-identity">\n ${function(e){const t=parseInt(e,10);if(Number.isNaN(t))return'<span class="text-body-tertiary">—</span>';let i;return i=t<=3?"text-bg-primary":t<=7?"text-bg-secondary":"bg-body-tertiary text-body-tertiary border",`<span class="badge ${i} font-monospace" title="Lower priority runs first">${t}</span>`}(e.get("priority"))}\n <div class="rs-row-identity-text">\n <div class="rs-row-identity-name">${Me(i)}</div>\n <div class="rs-row-identity-meta">\n <span class="rs-row-identity-active ${o}"><i class="bi ${a}"></i>${n}</span>\n <span class="rs-row-identity-id">#${Me(String(t??""))}</span>\n </div>\n </div>\n </div>\n `}(t.model)},{key:"category",label:"Category",sortable:!0,formatter:"badge",filter:{type:"combobox",options:c.CommonCategoryOptions}},{key:"handler",label:"Behavior",visibility:"lg",formatter:(e,t)=>function(e){const t=W(e.get("handler")),i=0===t.length?'<span class="rs-row-chip rs-row-chip-empty"><i class="bi bi-dash-circle"></i>Record only</span>':t.map(e=>`<span class="rs-row-chip tone-${e.tone}" title="${Me(e.label)}${e.detail?" — "+Me(e.detail):""}"><i class="bi ${e.icon}"></i>${Me(e.label)}</span>`).join(""),s=e.get("trigger_count"),a=e.get("trigger_window");let n;n=s?a?`<i class="bi bi-stopwatch"></i>${s} events / ${a} min`:`<i class="bi bi-stopwatch"></i>${s} events`:'<i class="bi bi-lightning-charge"></i>Fires immediately';const o=function(e){if(!e)return null;const t=c.BundleByOptions.find(t=>t.value===e);return t?t.label.replace(/^By\s+/i,""):String(e)}(e.get("bundle_by"));return`\n <div class="rs-row-behavior">\n <div class="rs-row-chips">${i}</div>\n <div class="rs-row-meta">${n}<span class="rs-row-sep">·</span>${o?`<i class="bi bi-collection"></i>${Me(o)}`:'<i class="bi bi-collection"></i>No bundling'}</div>\n </div>\n `}(t.model)}],filters:[{key:"is_active",label:"Active",filter:{type:"boolean",trueLabel:"Active",falseLabel:"Inactive"}}],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}})}onActionBatchEnable(){return this.batchAction({field:"is_active",value:!0,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"is_active",value:!1,label:"Disable"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}class EmailDomainTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_domains",pageName:"Email Domains",router:"admin/email/domains",Collection:c.EmailDomainList,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('—')",visibility:"lg"},{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",visibility:"lg"},{key:"created",label:"Created",formatter:"epoch|datetime",visibility:"xl"}],searchPlaceholder:"Search domain or region",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 a.Modal.modelForm({model:i,formConfig:EmailDomainForms.credentials}),!0}async onActionOnboard(e,t){const i=this.collection.get(t.dataset.id),s=new c.EmailDomain({id:i.id}),n=await a.Modal.form(EmailDomainForms.onboard);if(n)try{const e=await s.onboard(n);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(o){console.error("Onboard error:",o),this.showError(o.message||"Failed to onboard domain")}}async onActionAudit(e,i){const s=this.collection.get(i.dataset.id),n=new c.EmailDomain({id:s.id});try{const e=await n.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 a.Modal.dialog({title:`Audit Report - ${s.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(o){console.error("Audit error:",o),this.showError(o.message||"Failed to audit domain")}}async onActionReconcile(e,t){const i=this.collection.get(t.dataset.id),s=new c.EmailDomain({id:i.id});try{const e=await s.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(a){console.error("Reconcile error:",a),this.showError(a.message||"Failed to reconcile domain")}}}class EmailMailboxTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_mailboxes",pageName:"Mailboxes",router:"admin/email/mailboxes",Collection:c.MailboxList,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('—')",visibility:"lg"},{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",visibility:"xl"},{key:"is_domain_default",label:"Domain Default",formatter:"boolean|badge",visibility:"xl"}],searchPlaceholder:"Search email or domain",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),s=await a.Modal.form({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}]});s.from_email=i.get("email");const n=await c.Mailbox.sendEmail(s);if(n.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";n.data.details?e=n.data.details:n.data.error&&(e=n.data.error),this.getApp().toast.error(e)}}async onActionSendTemplateEmail(e,t){const i=this.collection.get(t.dataset.id),s=await a.Modal.form({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}]});s.from_email=i.get("email");const n=await c.Mailbox.sendEmail(s);if(n.success)this.getApp().toast.success("Email sent successfully");else{let e="Failed to send email";n.data.details?e=n.data.details:n.data.error&&(e=n.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")||"",i=e.contentDocument||e.contentWindow.document;i.open(),i.write(t),i.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 c.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 o.TabView({containerId:"template-tabs",tabs:e,activeTab:Object.keys(e)[0]||""}),this.addChild(this.tabView)}}c.EmailTemplate.VIEW_CLASS=EmailTemplateView;class EmailTemplateTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_templates",pageName:"Email Templates",router:"admin/email/templates",Collection:c.EmailTemplateList,clickAction:"edit",dayRangeFilter:!0,searchPlaceholder:"Search template name",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 c.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 o.TabView({containerId:"email-tabs",tabs:e,activeTab:this.hasHtml?"HTML":this.hasText?"Text":"Context"}),this.addChild(this.tabView)}}c.SentMessage.VIEW_CLASS=EmailView,c.SentMessage.VIEW_CLASS=EmailView;class SentMessageTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_email_sent",pageName:"Sent Messages",router:"admin/email/sent",Collection:c.SentMessageList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search recipient, subject, or status",defaultQuery:{sort:"-created"},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,visibility:"xl"},{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('—')",visibility:"xl"},{key:"created",label:"Created",formatter:"datetime"}],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}})}}const Ie={open:"bg-warning text-dark",closed:"bg-secondary"};function Pe(e){return String(e||"").replace(/[_-]+/g," ").replace(/\s+/g," ").trim().replace(/\b\w/g,e=>e.toUpperCase())}class PublicMessageView extends t.View{constructor(e={}){super({className:"public-message-view",...e}),this.model=e.model||new c.PublicMessage(e.data||{}),this.template='\n <div class="public-message-view-container">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n <span class="badge bg-primary">{{kindLabel}}</span>\n <span class="badge {{statusBadgeClass}}">{{statusLabel}}</span>\n {{#model.created}}\n <span class="text-muted small"><i class="bi bi-clock me-1"></i>{{model.created|relative}}</span>\n {{/model.created}}\n </div>\n <h5 class="mb-1">{{#model.subject}}{{model.subject}}{{/model.subject}}{{^model.subject}}<span class="text-muted fst-italic">No subject</span>{{/model.subject}}</h5>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-person-fill me-1"></i>{{model.name}}</span>\n <span><i class="bi bi-envelope me-1"></i><a href="mailto:{{safeMailtoEmail}}">{{model.email}}</a></span>\n {{#model.group.name}}\n <span><i class="bi bi-diagram-3 me-1"></i>{{model.group.name}}</span>\n {{/model.group.name}}\n </div>\n </div>\n </div>\n\n \x3c!-- Submitter --\x3e\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-person-lines-fill me-1"></i>Submitter</h6></div>\n <div data-container="submitter"></div>\n </div>\n\n \x3c!-- Details (metadata) --\x3e\n {{#hasMetadata|bool}}\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-tags me-1"></i>Details</h6></div>\n <div data-container="details"></div>\n </div>\n {{/hasMetadata|bool}}\n\n \x3c!-- Message body --\x3e\n <div class="card mb-3">\n <div class="card-header py-2"><h6 class="mb-0"><i class="bi bi-chat-left-text me-1"></i>Message</h6></div>\n <div class="card-body">\n <pre class="mb-0" style="white-space: pre-wrap; word-wrap: break-word; font-family: inherit;">{{model.message}}</pre>\n </div>\n </div>\n\n \x3c!-- Actions --\x3e\n <div class="d-flex align-items-center gap-2">\n <button class="btn {{toggleBtnClass}} btn-sm" data-action="toggle-status">\n <i class="bi {{toggleBtnIcon}} me-1"></i>{{toggleBtnLabel}}\n </button>\n <a class="btn btn-outline-secondary btn-sm" href="mailto:{{safeMailtoEmail}}?subject={{replySubject}}">\n <i class="bi bi-reply me-1"></i>Reply via Email\n </a>\n </div>\n </div>\n '}async onBeforeRender(){const e=this.model.get("kind")||"",t=c.PublicMessageKindOptions.find(t=>t.value===e);this.kindLabel=t?t.label:e;const i=this.model.get("status")||"open";this.statusLabel=i.charAt(0).toUpperCase()+i.slice(1),this.statusBadgeClass=Ie[i]||"bg-secondary";const s=this.model.get("metadata")||{};this.hasMetadata=Object.keys(s).length>0,"open"===i?(this.toggleBtnClass="btn-success",this.toggleBtnIcon="bi-check-circle",this.toggleBtnLabel="Mark Closed"):(this.toggleBtnClass="btn-outline-warning",this.toggleBtnIcon="bi-arrow-counterclockwise",this.toggleBtnLabel="Mark Open");const a=this.model.get("subject")||"";this.replySubject=encodeURIComponent(a?`Re: ${a}`:"Re: your message"),this.safeMailtoEmail=encodeURIComponent(this.model.get("email")||"")}async onInit(){this.submitterView=new d.default({containerId:"submitter",model:this.model,className:"p-3",columns:2,showEmptyValues:!1,fields:[{name:"name",label:"Name",colSize:6},{name:"email",label:"Email",type:"email",colSize:6},{name:"ip_address",label:"IP",colSize:6},{name:"user_agent",label:"User Agent",colSize:12},{name:"group.name",label:"Group",colSize:6}]}),this.addChild(this.submitterView);const e=this.model.get("metadata")||{},t=Object.keys(e);if(t.length){const i=t.map(e=>({name:e,label:c.PublicMessageMetadataLabels[e]||Pe(e),colSize:6}));this.detailsView=new d.default({containerId:"details",data:e,className:"p-3",columns:2,showEmptyValues:!1,fields:i}),this.addChild(this.detailsView)}}async onActionToggleStatus(){const e="open"===this.model.get("status")?"closed":"open";try{await this.model.save({status:e}),this.getApp()?.toast?.success(`Message marked ${e}`),await this.render()}catch(t){this.getApp()?.toast?.error("Failed to update status")}}}c.PublicMessage.VIEW_CLASS=PublicMessageView,c.PublicMessage.VIEW_CLASS=PublicMessageView;class PublicMessageTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_public_messages",pageName:"Contact Messages",router:"admin/messaging/public-messages",Collection:c.PublicMessageList,viewDialogOptions:{header:!1,size:"lg",scrollable:!0},defaultQuery:{sort:"-created"},dayRangeFilter:!0,searchPlaceholder:"Search name, email, or subject",columns:[{key:"status",label:"Status",sortable:!0,formatter:"badge",filter:{type:"multiselect",placeHolder:"Select Status",options:c.PublicMessageStatusOptions.map(e=>e.value)}},{key:"kind",label:"Kind",sortable:!0,formatter:"badge",filter:{type:"multiselect",placeHolder:"Select Kind",options:c.PublicMessageKindOptions.map(e=>e.value)}},{key:"name",label:"Name",sortable:!0,formatter:"truncate(30)"},{key:"email",label:"Email",sortable:!0},{key:"subject",label:"Subject",sortable:!0,formatter:"truncate(60)|default('—')"},{key:"group.name",label:"Group",sortable:!0,formatter:"default('—')"},{key:"created",label:"Created",sortable:!0,formatter:"relative"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No contact or support messages yet. Visitors submit these through the bouncer-gated /contact page.",batchBarLocation:"top",batchActions:[{label:"Mark Closed",icon:"bi bi-check-circle",action:"mark-closed"}],tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1}})}onActionBatchMarkClosed(){return this.batchAction({field:"status",value:"closed",label:"Mark Closed"})}}class PhoneNumberView extends t.View{constructor(e={}){super({className:"phone-number-view",...e}),this.model=e.model||new c.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 d.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 d.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 d.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 d.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 o.TabView({containerId:"phone-tabs",tabs:t,activeTab:"Overview"}),this.addChild(this.tabView);const i=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(i)}async onActionRefreshLookup(){const e=this.model.get("phone_number");if(e)try{this.getApp()?.toast?.info?.("Refreshing lookup...");const t=await c.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 a.Modal.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 c.PhoneNumber.lookup(e);if(t?.model){const e=new PhoneNumberView({model:t.model});return void(await a.Modal.show(e,{size:"lg",header:!1}))}a.Modal.alert({message:`Could not find phone data for number: ${e}`,type:"warning"})}}PhoneNumberView.MODEL_CLASS=c.PhoneNumber,c.PhoneNumber.VIEW_CLASS=PhoneNumberView;class PhoneNumberTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_numbers",pageName:"Phone Numbers",router:"admin/phonehub/numbers",Collection:c.PhoneNumberList,viewDialogOptions:{header:!1},columns:[{key:"phone_number",label:"Phone Number",sortable:!0},{key:"carrier",label:"Carrier",sortable:!0,formatter:"default('—')",visibility:"lg"},{key:"line_type",label:"Line Type",sortable:!0,formatter:"capitalize"},{key:"is_mobile",label:"Mobile",formatter:"yesnoicon",visibility:"lg"},{key:"is_voip",label:"VOIP",formatter:"yesnoicon",visibility:"xl"},{key:"is_valid",label:"Valid",formatter:"yesnoicon"},{key:"registered_owner",label:"Owner",sortable:!0,formatter:"default('—')",visibility:"xl"},{key:"owner_type",label:"Owner Type",formatter:"capitalize",visibility:"xl"},{key:"last_lookup_at|relative",label:"Last Lookup",sortable:!0}],searchPlaceholder:"Search number or owner",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 c.PhoneNumber.lookup(e.number);t.model&&await this.showItemDialog(t.model)}}}class SMSView extends t.View{constructor(e={}){super({className:"sms-view",...e}),this.model=e.model||new c.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 d.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 d.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 d.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 o.TabView({containerId:"sms-tabs",tabs:t,activeTab:"Message"}),this.addChild(this.tabView);const i=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(i)}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 a.Modal.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=c.SMS,c.SMS.VIEW_CLASS=SMSView;class SMSTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_phonehub_sms",pageName:"SMS Messages",router:"admin/phonehub/sms",Collection:c.SMSList,dayRangeFilter:!0,searchPlaceholder:"Search number, body, or provider",defaultQuery:{sort:"-created"},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('—')",visibility:"lg"},{key:"body",label:"Message",formatter:"default('—')"},{key:"sent_at",label:"Sent At",sortable:!0,formatter:"datetime",visibility:"xl"},{key:"delivered_at",label:"Delivered At",sortable:!0,formatter:"datetime",visibility:"xl"},{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 l.TableView({containerId:"recent-deliveries",title:"Recent Deliveries",Collection:new c.PushDeliveryList({params:{_sort:"-created",_limit:5}}),columns:[{key:"title",label:"Title"},{key:"status",label:"Status",formatter:"badge"}]}),this.addChild(this.recentDeliveries),this.failedDeliveries=new l.TableView({containerId:"failed-deliveries",title:"Failed Deliveries",Collection:new c.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 n.TablePage{constructor(e={}){super({...e,name:"admin_push_configs",pageName:"Push Configurations",router:"admin/push/configs",Collection:c.PushConfigList,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",formatter:"boolean"}],actions:["edit","delete"],emptyMessage:"No push configurations found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0})}}class PushTemplateTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_templates",pageName:"Push Templates",router:"admin/push/templates",Collection:c.PushTemplateList,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",formatter:"boolean"}],actions:["edit","delete"],emptyMessage:"No push templates found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0})}}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 '}}c.PushDelivery.VIEW_CLASS=PushDeliveryView;class PushDeliveryTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_deliveries",pageName:"Push Deliveries",router:"admin/push/deliveries",Collection:c.PushDeliveryList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, user, or device",defaultQuery:{sort:"-created"},viewDialogOptions:{header:!1,size:"md"},columns:[{key:"id",label:"ID",width:"70px"},{key:"created",label:"Timestamp",formatter:"datetime"},{key:"user.display_name",label:"User",visibility:"lg"},{key:"device.device_name",label:"Device",visibility:"lg"},{key:"title",label:"Title"},{key:"category",label:"Category",visibility:"xl"},{key:"status",label:"Status",formatter:"badge"}],actions:["view"],emptyMessage:"No deliveries found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0})}}class PushDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_push_devices",pageName:"Registered Devices",router:"admin/push/devices",Collection:c.PushDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search user or device name",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",visibility:"lg"},{key:"app_version",label:"App Version",visibility:"xl"},{key:"push_enabled",label:"Push Enabled",formatter:"boolean",visibility:"lg"},{key:"last_seen",label:"Last Seen",formatter:"datetime"}],actions:["view","delete"],emptyMessage:"No devices found.",searchable:!0,sortable:!0,paginated:!0,showRefresh:!0})}}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 JobsRunnersStrip extends t.View{constructor(e={}){super({...e,tagName:"div",className:`jobs-runners-strip ${e.className||""}`.trim()}),this.rows=[],this.summary="Loading runners…",this.summaryClass="text-muted",this.empty=!1,this._fetchedOnce=!1}async getTemplate(){return'\n <details class="card shadow-sm" open>\n <summary class="card-header bg-transparent d-flex justify-content-between align-items-center">\n <div class="d-flex align-items-center gap-2">\n <i class="bi bi-cpu me-1"></i>\n <strong>Runners</strong>\n <span class="ms-2 small {{summaryClass}}">{{summary}}</span>\n </div>\n <i class="bi bi-chevron-down"></i>\n </summary>\n <ul class="list-group list-group-flush mb-0">\n {{#empty|bool}}\n <li class="list-group-item text-danger small"><i class="bi bi-exclamation-triangle me-2"></i>No runners registered — jobs will not be processed.</li>\n {{/empty|bool}}\n {{#rows}}\n <li class="list-group-item d-flex align-items-center gap-3">\n <i class="bi bi-circle-fill {{dotClass}}" style="font-size:0.55rem;"></i>\n <div class="flex-grow-1 min-w-0">\n <code class="small">{{runner_id}}</code>\n <div class="text-muted small text-truncate">{{channelsLabel}} · {{processedLabel}}</div>\n </div>\n <span class="text-muted small">{{when}}</span>\n <span class="badge {{badgeClass}}">{{statusLabel}}</span>\n </li>\n {{/rows}}\n </ul>\n </details>\n '}async onInit(){await this._fetch()}async refresh(){await this._fetch(),this.isMounted()&&await this.render()}async _fetch(){const e=this.getApp()?.rest;if(!e)return this.rows=[],this._fetchedOnce=!0,void this._reflectState();try{const t=await e.GET("/api/jobs/runners",{_:Date.now()}),i=t?.data?.data||[];this.rows=i.map(e=>this._normalize(e))}catch(t){console.warn("[JobsRunnersStrip] fetch failed:",t),this.rows=[]}finally{this._fetchedOnce=!0,this._reflectState()}}_reflectState(){this.empty=0===this.rows.length&&this._fetchedOnce,this.summary=this._buildSummary();const e=this.rows.some(e=>!e.alive);this.empty||0===this.rows.length&&this._fetchedOnce?this.summaryClass="text-danger":this.summaryClass=e?"text-warning":"text-success"}_normalize(e){const t=!!e.alive,i=Array.isArray(e.channels)?e.channels:[],s=Number(e.jobs_processed)||0,a=Number(e.jobs_failed)||0;return{runner_id:e.runner_id||e.id||"—",alive:t,dotClass:t?"text-success":"text-danger",badgeClass:t?"text-bg-success":"text-bg-danger",statusLabel:t?"alive":"down",channels:i,channelsLabel:i.length?i.join(", "):"no channels",processed:s,failed:a,processedLabel:a>0?`${s.toLocaleString()} done · ${a.toLocaleString()} failed`:`${s.toLocaleString()} done`,when:this._relativeTime(e.last_heartbeat)}}_buildSummary(){if(!this._fetchedOnce)return"Loading runners…";if(!this.rows.length)return"No runners registered";const e=this.rows.filter(e=>e.alive).length,t=this.rows.length;return`${e} of ${t} runner${1===t?"":"s"} alive · ${this.rows.reduce((e,t)=>e+t.processed,0).toLocaleString()} jobs processed`}_relativeTime(e){if(!e)return"—";const t="number"==typeof e?1e3*e:new Date(e).getTime();if(!t)return"—";const i=Math.floor((Date.now()-t)/1e3);return i<60?`${i}s ago`:i<3600?`${Math.floor(i/60)}m ago`:i<86400?`${Math.floor(i/3600)}h ago`:`${Math.floor(i/86400)}d ago`}}let Le=class extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div class="row 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 ',...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)}};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 a.Modal.confirm({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,()=>c.Job.test(),"Test job started successfully")}async onActionRunTestJobs(e,t){await a.Modal.confirm({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,()=>c.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}))],s=await a.Modal.form({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"}]}});s&&await this.executeJobAction(t,()=>c.Job.clearStuck(s.channel||null),e=>{const t=e.data.count||0;return`Cleared ${t} stuck job${1!==t?"s":""}${s.channel?` from channel "${s.channel}"`:""}`})}async onActionClearChannel(e,t){const i=(this.options.getChannels?.()||[]).map(e=>({value:e.channel,label:e.channel})),s=await a.Modal.form({title:"Clear Channel",formConfig:{fields:[{name:"channel",type:"select",label:"Channel",options:i,required:!0,help:"Select the channel to clear."}]}});s&&await this.executeJobAction(t,()=>c.Job.clearChannel(s.channel),`Channel "${s.channel}" cleared successfully.`)}async onActionPurgeJobs(e,t){const i=await a.Modal.form({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,()=>c.Job.purgeJobs(i.days_old),e=>`Purged ${e.data.count||0} old job(s).`)}async onActionCleanupConsumers(e,t){await a.Modal.confirm({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,()=>c.Job.cleanConsumers(),e=>`Cleaned up ${e.data.count||0} consumer(s).`)}async onActionRunnerBroadcast(){const e=await a.Modal.form({title:"Broadcast Command to All Runners",formConfig:c.JobRunnerForms.broadcast});if(e)try{const t=await c.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,i){try{e.disabled=!0;const s=e.querySelector("i");s?.classList.add("spinning");const a=await t();if(a.success&&a.data?.status){const e="function"==typeof i?i(a):i;this.getApp().toast.success(e)}else this.getApp().toast.error(a.data?.error||"Operation failed")}catch(s){console.error("Job action failed:",s),this.getApp().toast.error("Error: "+s.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"}async getTemplate(){return'\n <div class="job-dashboard-container container-fluid">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h1 class="h3 mb-1">Job Engine</h1>\n <p class="text-muted mb-0">{{pageSubtitle}}</p>\n </div>\n <button type="button"\n class="btn btn-outline-secondary btn-sm"\n data-action="refresh-all"\n title="Refresh all panels">\n <i class="bi bi-arrow-clockwise"></i> Refresh\n </button>\n </div>\n\n <div class="mb-3" data-container="job-runners"></div>\n\n <div data-container="job-stats"></div>\n\n <div data-container="job-overview"></div>\n\n <div class="mt-4" data-container="job-operations"></div>\n </div>\n '}async onInit(){this.jobStats=new c.JobsEngineStats,this.runnersStrip=new JobsRunnersStrip({containerId:"job-runners"}),this.addChild(this.runnersStrip),this.jobStatsView=new JobStatsView({containerId:"job-stats",model:this.jobStats}),this.addChild(this.jobStatsView),this.overviewSection=new Le({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,{lazyMount:!0}),this.jobStats.fetch().catch(e=>{console.warn("[JobDashboardPage] initial stats fetch failed:",e)})}async onEnter(){await super.onEnter(),this.scheduleRefresh(()=>this.runnersStrip?.refresh?.(),6e4,{tier:"fast"}),this.scheduleRefresh(()=>this.jobStats?.fetch(),6e4,{tier:"fast"})}async onActionRefreshAll(e,t){const i=t||e?.currentTarget||null,s=i?.querySelector?.("i");s?.classList.add("bi-spin"),i&&(i.disabled=!0);try{await this.runScheduledRefreshes()}finally{s?.classList.remove("bi-spin"),i&&(i.disabled=!1)}}}const Ee=t.MOJOUtils.escapeHtml;function De(e){return null==e?"—":e>=1e9?(e/1e9).toFixed(2)+" GB":e>=1e6?(e/1e6).toFixed(2)+" MB":e>=1e3?(e/1e3).toFixed(2)+" KB":e+" B"}function Ve(e){return e?(Date.now()-new Date(e).getTime())/1e3:null}function $e(e){return null==e?"never":e<60?`${Math.round(e)}s ago`:e<3600?`${Math.round(e/60)}m ago`:`${Math.round(e/3600)}h ago`}function Re(e){const t=e?.get?.("alive");if(!t)return{key:"down",label:"Down",tone:"danger"};const i=Ve(e.get("last_heartbeat"));return null==i?{key:"stale",label:"No heartbeat",tone:"warning"}:i>=120?{key:"stale",label:"Stale heartbeat",tone:"warning"}:i>=30?{key:"stale",label:"Slow heartbeat",tone:"warning"}:{key:"healthy",label:"Healthy",tone:"success"}}class RunnerOverviewSection extends t.View{constructor(e={}){super({className:"runner-overview-section",template:'\n <div data-container="runner-status"></div>\n <div class="detail-kpi-grid">\n <div data-container="runner-kpi-uptime"></div>\n <div data-container="runner-kpi-processed"></div>\n <div data-container="runner-kpi-failure"></div>\n <div data-container="runner-kpi-active"></div>\n </div>\n ',...e}),this._activeJobs=e.activeJobs||(()=>[])}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"runner-status",model:e,tone:e=>Re(e).tone,state:e=>Re(e).label,headline:e=>this._headline(e),meta:e=>this._meta(e),actions:e=>this._actions(e)}),this.addChild(this.statusPanel),this.kpiUptime=this._kpi("runner-kpi-uptime",()=>"Uptime",e=>this._uptimeText(e)),this.kpiProcessed=this._kpi("runner-kpi-processed",()=>"Jobs processed",e=>(e.get("jobs_processed")||0).toLocaleString(),e=>(e.get("jobs_processed")||0)>0?"success":null),this.kpiFailure=this._kpi("runner-kpi-failure",()=>"Failure rate",e=>this._failureText(e),e=>this._failureTone(e)),this.kpiActive=this._kpi("runner-kpi-active",()=>"Active jobs",()=>String((this._activeJobs()||[]).length),()=>(this._activeJobs()||[]).length>0?"info":null),[this.kpiUptime,this.kpiProcessed,this.kpiFailure,this.kpiActive].forEach(e=>this.addChild(e))}_headline(e){const t=e||this.model,i=Re(t),s=this._uptimeText(t),a=$e(Ve(t.get("last_heartbeat")));return"down"===i.key?`Down · last heartbeat ${a}`:"stale"===i.key?`${i.label} · last heartbeat ${a}`:`Up ${s} · heartbeat ${a}`}_meta(e){const t=e||this.model,i=t.get("channels")||[],s=t.get("jobs_processed")||0,a=t.get("jobs_failed")||0;return`Channels: ${i.length?i.map(e=>`<code>${Ee(String(e))}</code>`).join(", "):'<span class="text-secondary">no channels</span>'} · ${s.toLocaleString()} processed${a>0?` · ${a.toLocaleString()} failed`:""}`}_actions(e){return"down"===Re(e||this.model).key?[]:[{label:"Ping",action:"ping",icon:"bi-broadcast-pin",variant:"outline-secondary"},{label:"Drain",action:"drain",icon:"bi-pause-circle",variant:"outline-warning"},{label:"Shutdown",action:"shutdown",icon:"bi-power",variant:"outline-danger"}]}_uptimeText(e){const t=(e||this.model).get("started");if(!t)return"unknown";const i=(Date.now()-new Date(t).getTime())/1e3;return i>=0?function(e){const t=Math.floor(e/86400),i=Math.floor(e%86400/3600),s=Math.floor(e%3600/60);return t>0?`${t}d ${i}h ${s}m`:i>0?`${i}h ${s}m`:`${s}m`}(i):"unknown"}_failureText(e){const t=e||this.model,i=t.get("jobs_processed")||0,s=t.get("jobs_failed")||0;return i<=0?"—":`${(s/i*100).toFixed(2)}%`}_failureTone(e){const t=e||this.model,i=t.get("jobs_processed")||0,s=t.get("jobs_failed")||0;if(i<=0)return null;const a=s/i*100;return a>=5?"danger":a>=1?"warning":"success"}setActiveJobs(e){this._activeJobsCache=e,this._activeJobs=()=>this._activeJobsCache||[],this.isMounted()&&this.render().catch(()=>{})}async refreshFromModel(){this.isMounted()&&await this.render()}_kpi(e,i,s,a=null){const n=this.model,o=a?a(n):null,l=new t.View({containerId:e,model:n,className:"metric-card"+(o?` metric-card-tone-${o}`:""),template:'\n <div class="metric-card-label">{{kpiLabel}}</div>\n <div class="metric-card-value">{{kpiValue}}</div>\n '});return l.kpiLabel=i(n),l.kpiValue=s(n),l}async onActionPing(){this.emit("action:ping")}async onActionShutdown(){this.emit("action:shutdown")}async onActionDrain(){this.emit("action:drain")}}class RunnerSystemSection extends t.View{constructor(e={}){const{sysinfo:t,sysinfoError:i,loading:s,...a}=e;super({className:"runner-system-section",template:'\n <div class="detail-section-eyebrow">\n <span>{{eyebrowText}}</span>\n <button class="detail-section-action" data-action="refresh-sysinfo" type="button" data-bs-toggle="tooltip" title="Refresh sysinfo">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n\n {{#hasError|bool}}\n <div class="alert alert-warning small mb-3">\n <i class="bi bi-exclamation-triangle me-1"></i>{{errorText}}\n </div>\n {{/hasError|bool}}\n\n {{#isLoading|bool}}\n <div class="text-secondary small text-center py-4">\n <span class="spinner-border spinner-border-sm me-1"></span>Loading system info…\n </div>\n {{/isLoading|bool}}\n\n {{#hasSysinfo|bool}}\n <div class="detail-section-eyebrow">Operating system</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Hostname</div><div class="detail-flat-row-value"><code>{{osHostname}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">System</div><div class="detail-flat-row-value">{{osSystem}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Release</div><div class="detail-flat-row-value"><code>{{osRelease}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Machine</div><div class="detail-flat-row-value"><code>{{osMachine}}</code></div></div>\n {{#hasBootTime|bool}}<div class="detail-flat-row"><div class="detail-flat-row-label">Boot time</div><div class="detail-flat-row-value">{{bootTime}}</div></div>{{/hasBootTime|bool}}\n\n <div class="detail-section-eyebrow">CPU</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Load</div><div class="detail-flat-row-value">{{{cpuMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Cores</div><div class="detail-flat-row-value">{{cpuCount}}</div></div>\n {{#hasCpuFreq|bool}}<div class="detail-flat-row"><div class="detail-flat-row-label">Frequency</div><div class="detail-flat-row-value">{{cpuFreqText}}</div></div>{{/hasCpuFreq|bool}}\n\n {{#hasMemory|bool}}\n <div class="detail-section-eyebrow">Memory</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Usage</div><div class="detail-flat-row-value">{{{memMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Total</div><div class="detail-flat-row-value"><code>{{memTotal}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Used</div><div class="detail-flat-row-value"><code>{{memUsed}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Available</div><div class="detail-flat-row-value"><code class="text-success">{{memAvailable}}</code></div></div>\n {{/hasMemory|bool}}\n\n {{#hasDisk|bool}}\n <div class="detail-section-eyebrow">Disk (root)</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Usage</div><div class="detail-flat-row-value">{{{diskMeterHtml}}}</div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Total</div><div class="detail-flat-row-value"><code>{{diskTotal}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Used</div><div class="detail-flat-row-value"><code>{{diskUsed}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Free</div><div class="detail-flat-row-value"><code class="text-success">{{diskFree}}</code></div></div>\n {{/hasDisk|bool}}\n\n {{#hasNetwork|bool}}\n <div class="detail-section-eyebrow">Network</div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Bytes recv</div><div class="detail-flat-row-value"><code>{{netBytesRecv}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Bytes sent</div><div class="detail-flat-row-value"><code>{{netBytesSent}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Packets in/out</div><div class="detail-flat-row-value"><code>{{netPacketsIn}}</code> / <code>{{netPacketsOut}}</code></div></div>\n <div class="detail-flat-row"><div class="detail-flat-row-label">Errors in/out</div><div class="detail-flat-row-value"><code class="{{netErrClass}}">{{netErrIn}}</code> / <code class="{{netErrClass}}">{{netErrOut}}</code></div></div>\n {{/hasNetwork|bool}}\n\n <div class="detail-section-eyebrow">Raw sysinfo</div>\n <div data-container="runner-sysinfo-raw"></div>\n {{/hasSysinfo|bool}}\n\n {{^hasSysinfo|bool}}{{^isLoading|bool}}{{^hasError|bool}}\n <div class="text-secondary small">No system info collected yet.</div>\n {{/hasError|bool}}{{/isLoading|bool}}{{/hasSysinfo|bool}}\n ',...a}),this.sysinfoFn=t||(()=>null),this.errorFn=i||(()=>null),this.loadingFn=s||(()=>!1)}async onInit(){this.rawCard=new n.KnownFieldsCard({containerId:"runner-sysinfo-raw",model:this.model,data:()=>this.sysinfoFn()||{},knownKeys:[],rawCollapsed:!0,rawLabel:"Raw sysinfo"}),this.addChild(this.rawCard)}get _sysinfo(){return this.sysinfoFn()||null}get _err(){return this.errorFn()||null}get isLoading(){return!0===this.loadingFn()}get hasError(){return!!this._err}get hasSysinfo(){return!!this._sysinfo&&!this._err&&!this.isLoading}get errorText(){return String(this._err||"")}get eyebrowText(){const e=this._sysinfo;return e?.datetime?`Collected ${e.datetime}`:"System info"}get osHostname(){return this._sysinfo?.os?.hostname||"—"}get osSystem(){return this._sysinfo?.os?.system||"—"}get osRelease(){return this._sysinfo?.os?.release||"—"}get osMachine(){return this._sysinfo?.os?.machine||"—"}get hasBootTime(){return!!this._sysinfo?.boot_time}get bootTime(){const e=this._sysinfo?.boot_time;return e?new Date(1e3*e).toLocaleString():""}get cpuPct(){return this._sysinfo?.cpu_load??null}get cpuCount(){return this._sysinfo?.cpu?.count?String(this._sysinfo.cpu.count):"—"}get hasCpuFreq(){return!!this._sysinfo?.cpu?.freq}get cpuFreqText(){const e=this._sysinfo?.cpu?.freq;return e?`${Math.round(e.current).toLocaleString()} MHz current · ${Math.round(e.max).toLocaleString()} MHz max`:""}get cpuMeterHtml(){return Ne(this.cpuPct)}get hasMemory(){return!!this._sysinfo?.memory}get memMeterHtml(){const e=this._sysinfo?.memory;return Ne(e?.percent,e?`${De(e.used)} / ${De(e.total)}`:"")}get memTotal(){return De(this._sysinfo?.memory?.total)}get memUsed(){return De(this._sysinfo?.memory?.used)}get memAvailable(){return De(this._sysinfo?.memory?.available)}get hasDisk(){return!!this._sysinfo?.disk}get diskMeterHtml(){const e=this._sysinfo?.disk;return Ne(e?.percent,e?`${De(e.used)} / ${De(e.total)}`:"")}get diskTotal(){return De(this._sysinfo?.disk?.total)}get diskUsed(){return De(this._sysinfo?.disk?.used)}get diskFree(){return De(this._sysinfo?.disk?.free)}get hasNetwork(){return!!this._sysinfo?.network}get netBytesRecv(){return De(this._sysinfo?.network?.bytes_recv)}get netBytesSent(){return De(this._sysinfo?.network?.bytes_sent)}get netPacketsIn(){return String(this._sysinfo?.network?.packets_recv??0)}get netPacketsOut(){return String(this._sysinfo?.network?.packets_sent??0)}get netErrIn(){return String(this._sysinfo?.network?.errin??0)}get netErrOut(){return String(this._sysinfo?.network?.errout??0)}get netErrClass(){const e=this._sysinfo?.network;return e&&(e.errin>0||e.errout>0)?"text-danger fw-bold":""}async onActionRefreshSysinfo(){this.emit("action:refresh-sysinfo")}}function Ne(e,t=""){if(null==e)return`\n <div style="width: 100%;">\n <div class="d-flex justify-content-between mb-1">\n <span class="text-secondary small">${t?Ee(t):""}</span>\n <span class="text-secondary small">—</span>\n </div>\n <div class="progress" role="progressbar" style="height: 6px;"><div class="progress-bar bg-secondary" style="width: 0%"></div></div>\n </div>\n `;const i=function(e){return null==e?"secondary":e>=80?"danger":e>=60?"warning":"success"}(e);return`\n <div style="width: 100%;">\n <div class="d-flex justify-content-between mb-1">\n <span class="text-secondary small">${t?Ee(t):""}</span>\n <span class="small fw-bold">${e.toFixed(0)}%</span>\n </div>\n <div class="progress" role="progressbar" style="height: 6px;">\n <div class="progress-bar bg-${i}" style="width: ${e.toFixed(0)}%;"></div>\n </div>\n </div>\n `}class RunnerChannelsSection extends t.View{constructor(e={}){const{activeJobs:t,...i}=e;super({className:"runner-channels-section",template:'\n <div class="detail-section-eyebrow">{{channelsEyebrow}}</div>\n {{#hasChannels|bool}}\n {{{channelRowsHtml}}}\n {{/hasChannels|bool}}\n {{^hasChannels|bool}}\n <div class="text-secondary small py-3">\n This runner serves no channels — it will not receive any jobs.\n </div>\n {{/hasChannels|bool}}\n ',...i}),this.activeJobsFn=t||(()=>[])}get _channels(){return this.model.get("channels")||[]}get hasChannels(){return this._channels.length>0}get channelsEyebrow(){const e=this._channels.length;return`${e} channel${1===e?"":"s"}`}get channelRowsHtml(){const e=this.activeJobsFn()||[];return this._channels.map(t=>{const i=e.filter(e=>e.channel===t).length,s=i>0?"info":"secondary";return`\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-broadcast me-1"></i>Channel</div>\n <div class="detail-flat-row-value"><code>${Ee(String(t))}</code></div>\n <div class="detail-flat-row-action"><span class="badge text-bg-${s}">${i} active</span></div>\n </div>\n `}).join("")}}class RunnerActionsSection extends t.View{constructor(e={}){super({className:"runner-actions-section",template:'\n <div class="detail-section-eyebrow">Operates on <code>{{model.runner_id|default:\'unknown\'}}</code></div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-broadcast-pin me-1"></i>Ping</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Verify the runner is responsive — fire-and-forget.</span>\n {{#pingResult|bool}}<div class="small mt-1">{{{pingResult}}}</div>{{/pingResult|bool}}\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-success" data-action="ping" type="button">\n <i class="bi bi-broadcast-pin me-1"></i>Ping now\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-pause-circle me-1"></i>Drain</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Stop accepting new jobs; finish in-flight work.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-warning" data-action="drain" type="button">\n <i class="bi bi-pause-circle me-1"></i>Drain\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-arrow-clockwise me-1"></i>Restart</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Graceful shutdown then restart on the same host.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-primary" data-action="restart" type="button">\n <i class="bi bi-arrow-clockwise me-1"></i>Restart\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-power me-1"></i>Shutdown</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Finish current job then exit. Fire-and-forget.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-danger" data-action="shutdown" type="button">\n <i class="bi bi-power me-1"></i>Shutdown\n </button>\n </div>\n </div>\n\n <div class="detail-flat-row">\n <div class="detail-flat-row-label"><i class="bi bi-download me-1"></i>Export</div>\n <div class="detail-flat-row-value">\n <span class="text-secondary small">Download runner identity data as a JSON file.</span>\n </div>\n <div class="detail-flat-row-action">\n <button class="btn btn-sm btn-outline-secondary" data-action="export" type="button">\n <i class="bi bi-download me-1"></i>Export\n </button>\n </div>\n </div>\n\n <div class="detail-section-eyebrow">Broadcast command</div>\n <p class="text-secondary 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 small text-secondary 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 small text-secondary mb-1">Timeout (s)</label>\n <input type="number" class="form-control form-control-sm" 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" type="button">\n <i class="bi bi-megaphone me-1"></i>Broadcast to all runners\n </button>\n </div>\n </div>\n ',...e}),this.pingResult=""}setPingResult(e){this.pingResult=e||"",this.isMounted()&&this.render().catch(()=>{})}async onActionPing(){this.emit("action:ping")}async onActionShutdown(){this.emit("action:shutdown")}async onActionDrain(){this.emit("action:drain")}async onActionRestart(){this.emit("action:restart")}async onActionExport(){this.emit("action:export")}async onActionBroadcast(){const e=this.element?.querySelector('[data-field="broadcast-command"]'),t=this.element?.querySelector('[data-field="broadcast-timeout"]'),i=e?e.value:"status",s=t&&parseFloat(t.value)||2;this.emit("action:broadcast",{command:i,timeout:s})}}class RunnerDetailsView extends n.DetailView{constructor(e={}){const i=e.model instanceof c.JobRunner?e.model:new c.JobRunner(e.model||e.data||{}),s=i.get("runner_id"),a=new c.ActiveJobsList({runnerId:s,params:{size:25,sort:"-started_at"}}),n=new c.JobList({params:{runner_id:s,status:"completed",size:25,sort:"-created"}}),o=new c.JobLogList({params:{runner_id:s,size:50,sort:"-created"}}),r=new RunnerOverviewSection({model:i,activeJobs:()=>a.models?.map(e=>e.attributes||e)||[]}),d=new RunnerSystemSection({model:i,sysinfo:()=>i.attributes._sysinfo,sysinfoError:()=>i.attributes._sysinfoError,loading:()=>i.attributes._sysinfoLoading}),h=new RunnerChannelsSection({model:i,activeJobs:()=>a.models?.map(e=>e.attributes||e)||[]}),u=new l.TableView({collection:a,title:"Active jobs",eyebrow:"Section · Active jobs",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id","status"],clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.func|default:\'—\'}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge",width:"110px"},{key:"started_at",label:"Started",formatter:"relative",sortable:!0,width:"140px"},{key:"attempt",label:"Attempt",width:"80px"}]}),m=new l.TableView({collection:n,title:"Job history",eyebrow:"Section · Recent completed jobs",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id","status"],clickAction:"view",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.func|default:\'—\'}}</div>\n '},{key:"channel",label:"Channel",formatter:"badge",width:"110px"},{key:"status",label:"Status",width:"110px",formatter:e=>`<span class="badge text-bg-${{completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",expired:"warning"}[e]||"secondary"}">${t.MOJOUtils.escapeHtml(String(e||"unknown").toUpperCase())}</span>`},{key:"created",label:"Finished",formatter:"relative",sortable:!0,width:"140px"},{key:"duration_ms",label:"Duration",formatter:"duration",width:"110px"}]}),p=new l.TableView({collection:o,title:"Logs",eyebrow:"Section · Recent logs from this runner",showFullscreen:!1,searchable:!1,hideActivePillNames:["runner_id"],columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"kind",label:"Kind",formatter:"badge",width:"100px"},{key:"job_id",label:"Job",formatter:e=>e?`<code class="small">${t.MOJOUtils.escapeHtml(String(e).slice(0,12))}</code>`:'<span class="text-secondary">—</span>',width:"130px"},{key:"message",label:"Message"}]}),b=new RunnerActionsSection({model:i}),g=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:r},{key:"System",label:"System",icon:"bi-cpu",view:d},{key:"Channels",label:"Channels",icon:"bi-broadcast",view:h},{key:"Active Jobs",label:"Active Jobs",icon:"bi-hourglass-split",view:u},{type:"divider",label:"History"},{key:"Job History",label:"Job History",icon:"bi-clock-history",view:m},{key:"Logs",label:"Logs",icon:"bi-code-square",view:p},{type:"divider",label:"Control"},{key:"Actions",label:"Actions",icon:"bi-power",view:b}],v=[{icon:"bi-broadcast-pin",text:e=>Re(e).label,variant:e=>{const t=Re(e).tone;return"danger"===t?"danger":"warning"===t?"warning":"success"}},{icon:"bi-tag",text:e=>e.get("version")?`v${e.get("version")}`:null,variant:"light",when:e=>!!e.get("version")},{icon:"bi-broadcast",text:e=>{const t=e.get("channels")||[];return t.length?`channels: ${t.join(" · ")}`:null},variant:"info",when:e=>(e.get("channels")||[]).length>0},{icon:"bi-pc-display",text:e=>{const t=e.attributes._sysinfo;return t?.os&&`${t.os.system||""}${t.os.machine?` · ${t.os.machine}`:""}`.trim()||null},variant:"light"}];"function"==typeof v[0].variant&&(v[0].variant=v[0].variant(i)),super({className:"runner-details-view",...e,model:i,header:{icon:"bi-cpu",iconToneFn:e=>Re(e).tone,titleFn:e=>e.get("runner_id")||"unknown",chips:v,auxFn:e=>function(e){if(!e)return"";const t=Re(e),i=e.get("started"),s=i?(Date.now()-new Date(i).getTime())/1e3:null,a=Ve(e.get("last_heartbeat")),n=e.get("jobs_processed")||0,o=e.get("jobs_failed")||0,l=n>0?`${(o/n*100).toFixed(2)}%`:"0%";let r,c;return"down"===t.key?(r="Down",c=null!=a?`last heartbeat ${$e(a)}`:""):"stale"===t.key?(r=t.label,c=null!=a?$e(a):""):(r=`${null!=s?`Up ${function(e){const t=Math.floor(e/86400),i=Math.floor(e%86400/3600);return t>0?`${t}d`:i>0?`${i}h`:`${Math.floor(e%3600/60)}m`}(s)}`:"Up"} · ${l} failure`,c=null!=a?`heartbeat ${$e(a)}`:""),r?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${t.tone&&"default"!==t.tone?` dh-aux-dot-${t.tone}`:""}"></span>\n <span>${Ee(r)}</span>\n </span>\n ${c?`<span class="dh-aux-meta">${Ee(c)}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:[{label:"Ping runner",action:"ping",icon:"bi-broadcast-pin"},{label:"Broadcast command…",action:"broadcast-prompt",icon:"bi-megaphone"},{label:"Drain mode",action:"drain",icon:"bi-pause-circle"},{label:"Restart",action:"restart",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Shutdown",action:"shutdown",icon:"bi-power",danger:!0},{type:"divider"},{label:"Export snapshot",action:"export",icon:"bi-download"}]}},sections:g,activeSection:"Overview"}),this.activeJobsCollection=a,this.jobHistoryCollection=n,this.logsCollection=o,this.overviewSection=r,this.systemSection=d,this.channelsSection=h,this.activeJobsSection=u,this.jobHistorySection=m,this.logsSection=p,this.actionsSection=b}async onAfterBuild(){this.overviewSection.on("action:ping",()=>this.onActionPing()),this.overviewSection.on("action:shutdown",()=>this.onActionShutdown()),this.overviewSection.on("action:drain",()=>this.onActionDrain()),this.systemSection.on("action:refresh-sysinfo",()=>this._loadSysinfo({force:!0})),this.actionsSection.on("action:ping",()=>this.onActionPing()),this.actionsSection.on("action:shutdown",()=>this.onActionShutdown()),this.actionsSection.on("action:drain",()=>this.onActionDrain()),this.actionsSection.on("action:restart",()=>this.onActionRestart()),this.actionsSection.on("action:export",()=>this.onActionExport()),this.actionsSection.on("action:broadcast",({command:e,timeout:t})=>this.onActionBroadcastWith(e,t)),this._updateChannelsBadge(),this._updateActiveJobsBadge(),this._updateJobHistoryBadge(),this.activeJobsCollection.on?.("fetch:success",()=>{this._updateActiveJobsBadge(),this._refreshOverviewActiveCount(),this.channelsSection?.isMounted()&&this.channelsSection.render().catch(()=>{})},this),this.jobHistoryCollection.on?.("fetch:success",()=>this._updateJobHistoryBadge(),this),this.activeJobsCollection.fetch().catch(()=>{}),this.jobHistoryCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this._loadSysinfo(),this._pollHandle=setInterval(()=>{this._loadSysinfo({silent:!0}),this.activeJobsCollection.fetch().catch(()=>{}),this.overviewSection?.isMounted()&&this.overviewSection.refreshFromModel().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})},15e3)}_updateActiveJobsBadge(){const e=this.activeJobsCollection.totalCount??this.activeJobsCollection.models?.length??0;this.setBadge?.("Active Jobs",e>0?{text:String(e),variant:"muted"}:null)}_updateChannelsBadge(){const e=(this.model.get("channels")||[]).length;this.setBadge?.("Channels",e>0?{text:String(e),variant:"muted"}:null)}_updateJobHistoryBadge(){const e=this.jobHistoryCollection.totalCount??this.jobHistoryCollection.models?.length??0;this.setBadge?.("Job History",e>0?{text:String(e),variant:"muted"}:null)}_refreshOverviewActiveCount(){const e=this.activeJobsCollection.models?.map(e=>e.attributes||e)||[];this.overviewSection.setActiveJobs?.(e)}async _loadSysinfo({force:e=!1,silent:t=!1}={}){const i=this.model;t||(i.attributes._sysinfoLoading=!0,i.attributes._sysinfoError=null,this.systemSection?.isMounted()&&this.systemSection.render().catch(()=>{}));try{const e=await this.getApp().rest.GET(`/api/jobs/runners/sysinfo/${encodeURIComponent(i.get("runner_id"))}`);if(e.success&&e.data){const t=e.data.data||e.data;if(t&&"error"===t.status)i.attributes._sysinfo=null,i.attributes._sysinfoError=t.error||"Runner reported an error collecting sysinfo.";else if(e.data.status||t?.cpu_load||t?.memory){const e=t.result||t;i.attributes._sysinfo=e,i.attributes._sysinfoError=null}else i.attributes._sysinfo=null,i.attributes._sysinfoError=e.data.error||"Could not load system info."}else i.attributes._sysinfo=null,i.attributes._sysinfoError="Could not load system info."}catch(s){i.attributes._sysinfo=null,i.attributes._sysinfoError=s.message||"Request failed."}finally{i.attributes._sysinfoLoading=!1,this.systemSection?.isMounted()&&this.systemSection.render().catch(()=>{}),this.headerView?.isMounted()&&this.headerView.render().catch(()=>{})}e&&this.getApp()?.toast?.info("Sysinfo refreshed")}async onActionPing(){try{const e=await this.getApp().rest.POST("/api/jobs/runners/ping",{runner_id:this.model.get("runner_id"),timeout:2});let t;t=e.success&&e.data?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>':'<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Ping request failed</span>',this.actionsSection.setPingResult(t),this.getApp()?.toast?.info("Ping complete")}catch(e){this.actionsSection.setPingResult(`<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${Ee(e.message||"Ping failed")}</span>`)}}async onActionShutdown(){if(await a.Modal.confirm(`Send a graceful shutdown to <strong class="font-monospace">${Ee(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 onActionDrain(){await a.Modal.confirm(`Place <strong class="font-monospace">${Ee(this.model.get("runner_id")||"")}</strong> in drain mode?<br><br>The runner stops accepting new jobs but finishes its current ones.`,"Drain Mode",{confirmText:"Drain",confirmClass:"btn-warning"})&&this.getApp()?.toast?.info("Drain mode requested (backend integration pending).")}async onActionRestart(){await a.Modal.confirm(`Restart <strong class="font-monospace">${Ee(this.model.get("runner_id")||"")}</strong>?<br><br>The runner will gracefully shut down then relaunch on the same host.`,"Restart Runner",{confirmText:"Restart",confirmClass:"btn-primary"})&&this.getApp()?.toast?.info("Restart requested (backend integration pending).")}async onActionBroadcastPrompt(){const e=await a.Modal.form({title:"Broadcast Command",size:"md",fields:[{name:"command",type:"select",label:"Command",required:!0,options:[{value:"status",label:"Status check"},{value:"pause",label:"Pause processing"},{value:"resume",label:"Resume processing"},{value:"reload",label:"Reload configuration"},{value:"shutdown",label:"Shutdown all runners"}]},{name:"timeout",type:"number",label:"Timeout (s)",value:2,min:.5,step:.5}],submitText:"Broadcast",cancelText:"Cancel"});e&&await this.onActionBroadcastWith(e.command,parseFloat(e.timeout)||2)}async onActionBroadcastWith(e,t){a.Modal.showBusy({message:`Broadcasting "${e}" to all runners…`});try{const i=await this.getApp().rest.POST("/api/jobs/runners/broadcast",{command:e,timeout:t});a.Modal.hideBusy(),i.success&&i.data?await a.Modal.code({code:JSON.stringify(i.data,null,2),language:"json",title:`Broadcast Response — ${e}`,size:"lg"}):this.showError?.(i.data&&i.data.error||"Broadcast failed.")}catch(i){a.Modal.hideBusy(),this.showError?.("Broadcast failed: "+i.message)}}async onActionExport(){try{const e={runner:this.model.toJSON?this.model.toJSON():this.model.attributes,exported_at:/* @__PURE__ */(new Date).toISOString()},t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),s=Object.assign(document.createElement("a"),{href:i,download:`runner-${this.model.get("runner_id")}-${Date.now()}.json`});document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(i),this.showSuccess?.("Runner data exported.")}catch(e){this.showError?.("Export failed: "+e.message)}}async onUnmount(){this._pollHandle&&(clearInterval(this._pollHandle),this._pollHandle=null),super.onUnmount&&await super.onUnmount()}static async show(e,t={}){const i=e instanceof c.JobRunner?e:new c.JobRunner(e),s=new RunnerDetailsView({model:i});return await a.Modal.detail(s,t)}}RunnerDetailsView.VIEW_CLASS=RunnerDetailsView,c.JobRunner.VIEW_CLASS=RunnerDetailsView,c.JobRunner.MODEL_REF="jobs.JobRunner";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 &amp; 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 l.TableView({containerId:"runner-table",Collection:c.JobRunnerList,searchable:!0,filterable:!1,paginated:!0,itemView:RunnerDetailsView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},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),i=/* @__PURE__ */new Date-t,s=Math.floor(i/1e3);return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:`${Math.floor(s/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)}}const Fe=t.MOJOUtils.escapeHtml,Oe={pending:"info",running:"info",completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",expired:"warning"},Be={pending:"bi-hourglass",running:"bi-arrow-repeat",completed:"bi-check-circle",failed:"bi-x-octagon",canceled:"bi-x-circle",cancelled:"bi-x-circle",expired:"bi-clock"},je={enqueued:"info",started:"info",success:"success",completed:"success",failed:"danger",canceled:"secondary",cancelled:"secondary",timeout:"warning",retry:"warning"},He={enqueued:"bi-inbox",started:"bi-play-fill",success:"bi-check-circle",completed:"bi-check-circle",failed:"bi-x-octagon",canceled:"bi-x-circle",cancelled:"bi-x-circle",timeout:"bi-stopwatch",retry:"bi-arrow-repeat"};function Ue(e){if(!e)return"secondary";const t=e.get("status")||"unknown";return!0===e.isScheduled?.()?"warning":Oe[t]||"secondary"}class JobOverviewSection2 extends t.View{constructor(e={}){super({className:"job-overview-section",template:'\n <div data-container="job-status"></div>\n <div class="detail-kpi-grid">\n <div data-container="job-kpi-attempt"></div>\n <div data-container="job-kpi-runtime"></div>\n <div data-container="job-kpi-retries"></div>\n <div data-container="job-kpi-next"></div>\n </div>\n <div class="detail-pair">\n <div data-container="job-overview-execution"></div>\n <div data-container="job-overview-lifecycle"></div>\n </div>\n ',...e})}async onInit(){const e=this.model;this.statusPanel=new n.StatusPanel({containerId:"job-status",model:e,tone:e=>Ue(e),state:e=>this._narrative(e).state,headline:e=>this._narrative(e).headline,meta:e=>this._narrative(e).meta,actions:e=>this._actions(e)}),this.addChild(this.statusPanel),this.kpiAttempt=this._kpi("job-kpi-attempt",()=>"Attempt",e=>`${e.get("attempt")??0} / ${e.get("max_retries")||"∞"}`,e=>(e.get("attempt")??0)>0&&"failed"===e.get("status")?"warning":null),this.kpiRuntime=this._kpi("job-kpi-runtime",()=>"Runtime",e=>e.getFormattedDuration?.()||"—"),this.kpiRetries=this._kpi("job-kpi-retries",()=>"Retries left",e=>String(Math.max(0,(e.get("max_retries")??0)-(e.get("attempt")??0)))),this.kpiNext=this._kpi("job-kpi-next",e=>!0===e.isScheduled?.()?"Scheduled":"Next",e=>this._nextLabel(e),e=>this._nextTone(e)),[this.kpiAttempt,this.kpiRuntime,this.kpiRetries,this.kpiNext].forEach(e=>this.addChild(e)),this.executionCard=new JobExecutionCard({containerId:"job-overview-execution",model:e}),this.addChild(this.executionCard),this.lifecycleCard=new JobLifecycleCard({containerId:"job-overview-lifecycle",model:e}),this.addChild(this.lifecycleCard)}_narrative(e){const t=e||this.model,i=t.get("status")||"unknown",s=!0===t.isScheduled?.(),a=t.get("created"),n=t.get("started_at"),o=t.get("finished_at"),l=t.get("runner_id"),r=t.get("attempt")??0,c=t.get("max_retries")??0,d=t.getFormattedDuration?.(),h=t.get("last_error")||"";if(s)return{state:"Scheduled",headline:`Runs ${this._fmtRelative(t.get("run_at"))}`,meta:`Function <code>${Fe(t.get("func")||"unknown")}</code> on channel <code>${Fe(t.get("channel")||"?")}</code> · queued ${Fe(this._fmtRelative(a))}`};if("running"===i)return{state:"Running",headline:l?`Running on ${l} · ${this._fmtRelative(n)}`:`Running · started ${this._fmtRelative(n)}`,meta:`Attempt <strong>${r}</strong> of <strong>${c||"∞"}</strong>`};if("completed"===i)return{state:"Completed",headline:d&&"N/A"!==d?`Completed in ${d}`:"Completed",meta:`Finished ${Fe(this._fmtRelative(o))}${l?` on ${Fe(l)}`:""}`};if("failed"===i){const e=h.split("\n")[0]||"Failed";return{state:"Failed",headline:d&&"N/A"!==d?`Failed after ${d}`:"Failed",meta:`Attempt <strong>${r}</strong> of <strong>${c||"∞"}</strong>${t.canRetry?.()?" · retry available":""}<br><code class="text-danger">${Fe(e)}</code>`}}return"canceled"===i||"cancelled"===i?{state:"Cancelled",headline:"Cancelled",meta:`Cancelled ${Fe(this._fmtRelative(o||t.get("modified")))}`}:"expired"===i?{state:"Expired",headline:"Expired before completion",meta:`Created ${Fe(this._fmtRelative(a))}`}:{state:"Pending",headline:"Waiting for a runner",meta:`Queued on channel <code>${Fe(t.get("channel")||"?")}</code> · ${Fe(this._fmtRelative(a))}`}}_actions(e){const t=e||this.model,i=[];return t.canRetry?.()&&i.push({label:"Retry now",action:"retry",icon:"bi-arrow-clockwise",variant:"primary"}),t.canCancel?.()&&i.push({label:"Cancel",action:"cancel",icon:"bi-x-circle",variant:"outline-danger"}),i}_nextLabel(e){const t=e||this.model,i=t.get("status")||"unknown";if(!0===t.isScheduled?.()){const e=Ge(t.get("run_at"));return e?qe(e):"Scheduled"}return"failed"===i&&t.canRetry?.()?"Retry available":"running"===i?"In flight":"—"}_nextTone(e){const t=e||this.model,i=t.get("status")||"unknown";return!0===t.isScheduled?.()?"warning":"failed"===i&&t.canRetry?.()||"running"===i?"info":null}_fmtRelative(e){const t=Ge(e);return null==t?"—":qe(t)}async onActionRetry(){this.emit("action:retry")}async onActionCancel(){this.emit("action:cancel")}_kpi(e,i,s,a=null){const n=this.model,o=a?a(n):null,l=new t.View({containerId:e,model:n,className:"metric-card"+(o?` metric-card-tone-${o}`:""),template:'\n <div class="metric-card-label">{{kpiLabel}}</div>\n <div class="metric-card-value">{{kpiValue}}</div>\n '});return l.kpiLabel=i(n),l.kpiValue=s(n),l}}class JobExecutionCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-cpu"></i>Execution</div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Function</div>\n <div class="detail-flat-row-value"><code>{{model.func|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Channel</div>\n <div class="detail-flat-row-value"><code>{{model.channel|default:\'—\'}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Runner</div>\n <div class="detail-flat-row-value">\n {{#hasRunner|bool}}<code>{{model.runner_id}}</code>{{/hasRunner|bool}}\n {{^hasRunner|bool}}<span class="text-secondary">—</span>{{/hasRunner|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Created</div>\n <div class="detail-flat-row-value"><code>{{model.created|datetime}}</code></div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Started</div>\n <div class="detail-flat-row-value">\n {{#hasStarted|bool}}<code>{{model.started_at|datetime}}</code>{{/hasStarted|bool}}\n {{^hasStarted|bool}}<span class="text-secondary">—</span>{{/hasStarted|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Finished</div>\n <div class="detail-flat-row-value">\n {{#hasFinished|bool}}<code>{{model.finished_at|datetime}}</code>{{/hasFinished|bool}}\n {{^hasFinished|bool}}<span class="text-secondary">—</span>{{/hasFinished|bool}}\n </div>\n </div>\n {{#isScheduled|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Scheduled</div>\n <div class="detail-flat-row-value"><code>{{model.run_at|datetime}} · {{runAtRelative}}</code></div>\n </div>\n {{/isScheduled|bool}}\n {{#hasExpires|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Expires</div>\n <div class="detail-flat-row-value"><code>{{model.expires_at|datetime}}</code></div>\n </div>\n {{/hasExpires|bool}}\n {{#hasError|bool}}\n <pre class="detail-error-block">{{model.last_error}}</pre>\n {{/hasError|bool}}\n </div>\n </div>\n ',...e})}get hasRunner(){return!!this.model?.get?.("runner_id")}get hasStarted(){return!!this.model?.get?.("started_at")}get hasFinished(){return!!this.model?.get?.("finished_at")}get hasExpires(){return!!this.model?.get?.("expires_at")}get hasError(){return!!this.model?.get?.("last_error")}get isScheduled(){return!0===this.model?.isScheduled?.()}get runAtRelative(){const e=Ge(this.model?.get?.("run_at"));return null==e?"—":qe(e)}}class JobLifecycleCard extends t.View{constructor(e={}){super({template:'\n <div class="card">\n <div class="card-body">\n <div class="card-title"><i class="bi bi-list-ul"></i>Lifecycle</div>\n <div data-container="job-lifecycle-timeline"></div>\n </div>\n </div>\n ',...e})}async onInit(){this.timeline=new n.Timeline({containerId:"job-lifecycle-timeline",model:this.model,limit:8,emptyText:"No events recorded yet. Lifecycle entries appear here as the runner picks up the job and emits events.",items:e=>(e.getEvents?.()||[]).map(e=>ze(e,!0))}),this.addChild(this.timeline)}}function ze(e,t=!1){if(!e)return null;const i=(e.event||"").toLowerCase(),s=je[i]||null,a=e.label||e.event||"event";let n="";if(e.details){const t="string"==typeof e.details?e.details:JSON.stringify(e.details);n=Fe(t)}else e.runner_id&&(n=`runner <code>${Fe(String(e.runner_id))}</code>`);return{tone:s,headline:a,detail:n,when:e.at?t?Je(e.at):function(e){const t=Ge(e);return null==t?"—":new Date(t).toLocaleString()}(e.at):"",_icon:He[i]||null}}class JobPayloadSection extends t.View{constructor(e={}){super({className:"job-payload-section",template:'\n <div class="detail-section-eyebrow">Payload</div>\n <pre class="detail-payload-block"><code>{{{model.payload|json}}}</code></pre>\n ',...e})}}class RetryHistorySection extends t.View{constructor(e={}){const{collection:t,...i}=e;super({className:"job-retry-history-section",template:'\n <div class="detail-section-eyebrow">Retry History</div>\n <div data-container="retry-timeline"></div>\n ',...i}),this.collection=t||null}async onInit(){this.timeline=new n.Timeline({containerId:"retry-timeline",model:this.model,emptyText:"No retry events yet.",items:()=>this._buildItems()}),this.addChild(this.timeline)}_buildItems(){return this.collection&&this.collection.models&&this.collection.models.length?this.collection.models.map(e=>ze(e.attributes||e,!1)).filter(Boolean):(this.model?.getEvents?.()||[]).filter(e=>"retry"===(e.event||"").toLowerCase()).map(e=>ze(e,!0)).filter(Boolean)}async refresh(){await(this.timeline?.render())}}function qe(e){const t=Math.round((e-Date.now())/1e3);if(t<=0){const e=-t;return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`}return t<60?`in ${t}s`:t<3600?`in ${Math.floor(t/60)}m`:t<86400?`in ${Math.floor(t/3600)}h`:`in ${Math.floor(t/86400)}d`}function Ge(e){if(null==e)return null;if("number"==typeof e)return e<1e11?1e3*e:e;const t=new Date(e).getTime();return Number.isFinite(t)?t:null}function Je(e){const t=Ge(e);return null==t?"—":qe(t)}class JobDetailsView extends n.DetailView{constructor(e={}){const i=e.model||new c.Job(e.data||{}),s=i.get("status")||"unknown",a=!0===i.isScheduled?.(),n=new c.JobEventList({params:{job:i.get("id"),size:25,sort:"-at"}}),o=new c.JobEventList({params:{job:i.get("id"),event:"retry",ordering:"-at",size:25}}),r=new c.JobLogList({params:{job_id:i.get("id"),size:25,sort:"-created"}}),d=new JobOverviewSection2({model:i}),h=new JobPayloadSection({model:i}),u=new RetryHistorySection({model:i,collection:o}),m=new l.TableView({collection:n,title:"Events",eyebrow:"Section · Events",showFullscreen:!1,searchable:!1,hideActivePillNames:["job"],columns:[{key:"at",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"event",label:"Event",formatter:"badge"},{key:"runner_id",label:"Runner",formatter:e=>e?`<span class="font-monospace small">${t.MOJOUtils.escapeHtml(String(e))}</span>`:'<span class="text-secondary">—</span>'},{key:"attempt",label:"Attempt",width:"70px"},{key:"details|json",label:"Details"}]}),p=new l.TableView({collection:r,title:"Logs",eyebrow:"Section · Logs",showFullscreen:!1,searchable:!1,hideActivePillNames:["job_id"],columns:[{key:"created",label:"Timestamp",formatter:"datetime",sortable:!0,width:"180px"},{key:"kind",label:"Kind",formatter:"badge",width:"100px"},{key:"message",label:"Message"}]});let b=null;const g=i.get("func");if(g){const e=new c.SimilarJobsList({func:g,params:{size:15}});b=new l.TableView({collection:e,title:"Similar jobs",eyebrow:"Section · Similar",showFullscreen:!1,searchable:!1,hideActivePillNames:["func"],clickAction:"view",itemView:JobDetailsView,viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"Job",template:'\n <div class="fw-semibold font-monospace small">{{model.id|truncate_middle(16)}}</div>\n <div class="text-secondary small">{{model.channel}}</div>\n '},{key:"status",label:"Status",formatter:(e,i)=>{const s=i.row;return`<span class="badge ${s.getStatusBadgeClass?s.getStatusBadgeClass():"bg-secondary"}"><i class="${s.getStatusIcon?s.getStatusIcon():"bi-question"} me-1"></i>${t.MOJOUtils.escapeHtml((e||"unknown").toUpperCase())}</span>`}},{key:"created",label:"Created",formatter:"relative",sortable:!0},{key:"duration_ms",label:"Duration",formatter:"duration"}]})}const v=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:d},{key:"Payload",label:"Payload",icon:"bi-braces",view:h},{type:"divider",label:"Activity"},{key:"Events",label:"Events",icon:"bi-list-ul",view:m},{key:"Logs",label:"Logs",icon:"bi-code-square",view:p},{key:"RetryHistory",label:"Retry History",icon:"bi-arrow-repeat",view:u}];b&&(v.push({type:"divider",label:"Related"}),v.push({key:"Similar",label:"Similar",icon:"bi-files",view:b}));const y=a?"bi-clock-fill":Be[s]||"bi-question-circle",w=[{icon:"bi-broadcast",textPath:"channel",variant:"info"},{text:e=>e.get("id")?`#${String(e.get("id")).slice(-8)}`:null,variant:"light",when:e=>e.get("id")},{icon:"bi-cpu",text:e=>e.get("runner_id")||null,variant:"light",when:e=>!!e.get("runner_id")},{text:e=>`attempt ${e.get("attempt")??0}/${e.get("max_retries")??"∞"}`,variant:"light",when:e=>(e.get("attempt")??0)>0||(e.get("max_retries")??0)>0},{text:e=>{const t=e.getFormattedDuration?.();return t&&"N/A"!==t?`duration ${t}`:null},variant:"light"},{icon:"bi-exclamation-triangle",text:"cancel requested",variant:"warning",when:e=>!!e.get("cancel_requested")}],f=[{label:"Refresh",action:"refresh-job",icon:"bi-arrow-clockwise"}];i.canRetry?.()&&(f.push({type:"divider"}),f.push({label:"Retry job",action:"retry-job",icon:"bi-arrow-repeat"})),i.canCancel?.()&&(f.push({type:"divider"}),f.push({label:"Cancel job",action:"cancel-job",icon:"bi-x-circle",danger:!0})),super({className:"job-details-view",...e,model:i,header:{icon:y,iconToneFn:e=>Ue(e),titleFn:e=>e.get("func")||"unknown.task",subtitleFn:e=>function(e){const t=e.get("status")||"unknown";if(!0===e.isScheduled?.())return`Scheduled · runs ${Je(e.get("run_at"))}`;if("running"===t)return e.get("runner_id")?`Running on ${e.get("runner_id")} · started ${Je(e.get("started_at"))}`:`Running · started ${Je(e.get("started_at"))}`;if("failed"===t){const t=(e.get("last_error")||"").split("\n")[0],i=e.getFormattedDuration?.();return t?`Failed${i&&"N/A"!==i?` after ${i}`:""} · ${t}`:"Failed"}if("completed"===t){const t=e.getFormattedDuration?.();return t&&"N/A"!==t?`Completed in ${t}`:"Completed"}return"canceled"===t||"cancelled"===t?"Cancelled":"expired"===t?"Expired before completion":"Pending — waiting for a runner"}(e),chips:w,auxFn:e=>function(e){const t=e.get("status")||"unknown",i=!0===e.isScheduled?.(),s=Ue(e);let a,n;if(i){a="Scheduled";const t=Ge(e.get("run_at"));n=t?`runs ${Fe(qe(t))}`:""}else if("running"===t){const t=e.get("runner_id");a=t?`Running on ${Fe(String(t))}`:"Running",n=`started ${Fe(Je(e.get("started_at")))}`}else"failed"===t?(a="Failed",n=`${Fe(Je(e.get("finished_at")||e.get("modified")))}`):"completed"===t?(a="Completed",n=`${Fe(Je(e.get("finished_at")))}`):"canceled"===t||"cancelled"===t?(a="Cancelled",n=`${Fe(Je(e.get("finished_at")||e.get("modified")))}`):"expired"===t?(a="Expired",n=""):(a="Pending",n=`queued ${Fe(Je(e.get("created")))}`);return a?`\n <span class="dh-aux-presence">\n <span class="dh-aux-dot${s&&"default"!==s?` dh-aux-dot-${s}`:""}"></span>\n <span>${Fe(a)}</span>\n </span>\n ${n?`<span class="dh-aux-meta">${n}</span>`:""}\n `:""}(e),actions:[],contextMenu:{items:f}},sections:v,activeSection:"Overview"}),this.eventsCollection=n,this.retryCollection=o,this.logsCollection=r,this.overviewSection=d,this.payloadSection=h,this.retrySection=u,this.eventsSection=m,this.logsSection=p,this.similarSection=b}async onAfterBuild(){this.overviewSection.on("action:retry",()=>this.onActionRetryJob()),this.overviewSection.on("action:cancel",()=>this.onActionCancelJob());try{await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel()}catch(e){console.warn("[JobDetailsView] initial fetch failed:",e)}this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this.retryCollection.fetch().then(()=>{this.retrySection?.isMounted?.()&&this.retrySection.refresh(),this.setBadge?.("RetryHistory",this.retryCollection.models?.length||0)}).catch(()=>{}),this.similarSection?.collection&&this.similarSection.collection.fetch?.().then(()=>{this.setBadge?.("Similar",this.similarSection.collection.models?.length||0)}).catch(()=>{})}async _refreshFromModel(){this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.render()}async onActionRefreshJob(){try{await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel(),this.eventsCollection.fetch().catch(()=>{}),this.logsCollection.fetch().catch(()=>{}),this.retryCollection.fetch().then(()=>this.retrySection?.refresh()).catch(()=>{})}catch(e){this.getApp()?.toast?.error(e.message||"Failed to refresh job")}}async onActionCancelJob(){if(!(await a.Modal.confirm(`Cancel job <code>${Fe(String(this.model.get("id")||""))}</code>?`,"Cancel Job")))return!0;try{const e=await this.model.cancel();e.success?(this.getApp()?.toast?.success("Cancellation requested"),await this.model.fetch({params:{graph:"detail"}}),await this._refreshFromModel(),this.emit("job-cancelled",{job:this.model})):this.getApp()?.toast?.error(e.data?.error||"Failed to cancel job")}catch(e){console.error("[JobDetailsView] cancel failed:",e),this.getApp()?.toast?.error(e.message||"Failed to cancel job")}return!0}async onActionRetryJob(){const e=await a.Modal.form({title:"Retry Job",formConfig:c.JobForms?.retry});if(!e)return!0;try{const t=await this.model.retry(e.delay||0);t.success?(this.getApp()?.toast?.success("Retry scheduled"),this.emit("job-retried",{job:this.model,newJobId:t.newJobId})):this.getApp()?.toast?.error(t.data?.error||"Failed to retry job")}catch(t){console.error("[JobDetailsView] retry failed:",t),this.getApp()?.toast?.error(t.message||"Failed to retry job")}return!0}}JobDetailsView.VIEW_CLASS=JobDetailsView,c.Job.VIEW_CLASS=JobDetailsView,c.Job.MODEL_REF="jobs.Job";const We={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}} &middot; {{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 i=t.row;return`<span class="badge ${i.getStatusBadgeClass?i.getStatusBadgeClass():"bg-secondary"}"><i class="${i.getStatusIcon?i.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}} &middot; {{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}} &middot; {{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 i=t.row;return`<span class="badge ${i.getStatusBadgeClass?i.getStatusBadgeClass():"bg-secondary"}"><i class="${i.getStatusIcon?i.getStatusIcon():"bi-question"} me-1"></i>${e?.toUpperCase()||"UNKNOWN"}</span>`}},{key:"run_at",label:"Scheduled",formatter:e=>{if(!e)return'<span class="text-muted small">—</span>';const t=new Date("number"==typeof e&&e<1e11?1e3*e:e);if(isNaN(t.getTime()))return'<span class="text-muted small">—</span>';const i=t.getTime()>Date.now();return`<span class="${i?"text-warning":"text-muted"} small"><i class="${i?"bi-clock-fill":"bi-clock-history"} me-1"></i>${t.toLocaleString()}</span>`}},{key:"created",label:"Created",formatter:"datetime"},{key:"finished_at",label:"Finished",formatter:"datetime"},{key:"duration_ms",label:"Duration",formatter:"duration"}]},Ke=[{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"}],Ye=[{icon:"bi-x-circle-fill",label:"Cancel Jobs",action:"cancel-jobs"}];class JobTableSection extends t.View{constructor(e={}){const{status:t,sort:i="-created",extraParams:s={},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=i,this.extraParams=s,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?We[this.columnConfig]:this.columnConfig)||We[this.status]||We.all,i=this.selectable,s=this.batchActionConfig||(i?Ye:void 0),a=!this.status,n={containerId:"job-table",Collection:c.JobList,collectionParams:e,columns:t,searchable:!0,filterable:a,paginated:!0,itemView:JobDetailsView,hideActivePills:this.status?["status"]:[],viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},tableOptions:{striped:!1,hover:!0,size:"sm"}};i&&(n.selectable=!0,n.batchBarLocation="top",n.batchActions=s),a&&(n.filters=Ke,n.tableOptions.striped=!0,n.tableOptions.responsive=!0),this.tableView=new l.TableView(n),i&&this.tableView.on("action:batch-cancel-jobs",async(e,t,i)=>{const s=this.tableView.getSelectedItems();await Promise.all(s.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",selectable:!0}),this.addChild(this.jobTableSection)}}n.GeoLocatedIP.VIEW_CLASS=GeoIPView;class BlockedIPsTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_blocked_ips",pageName:"Blocked IPs",router:"admin/security/blocked-ips",Collection:n.GeoLocatedIPList,dayRangeFilter:{field:"blocked_at",value:"7d"},searchPlaceholder:"Search IP, country, or rule",viewDialogOptions:{header:!1,size:"xl",noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-modified",is_blocked:"true"},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}})}onActionBatchUnblock(){return this.batchAction({field:"unblock",value:"Bulk unblock from admin",label:"Unblock"})}onActionBatchWhitelist(){return this.batchAction({field:"whitelist",value:"Bulk whitelist from admin",label:"Whitelist"})}}class LogView extends t.View{constructor(e={}){super({className:"log-view",...e}),this.model=e.model||new l.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 d.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 i=this.model.get("log");let s=i;try{const e=JSON.parse(i);s=JSON.stringify(e,null,2)}catch(n){}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>${s}</code></pre>\n </div>\n `,onActionCopyLog:()=>{navigator.clipboard.writeText(s),this.getApp()?.toast?.success("Log content copied to clipboard.")}}),this.tabView=new o.TabView({containerId:"log-tabs",tabs:{Log:this.logContentView,Details:this.overviewView},activeTab:"Log"}),this.addChild(this.tabView);const a=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(a)}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 a.Modal.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})}}l.Log.VIEW_CLASS=LogView,l.Log.MODEL_REF="logs.Log",l.Log.VIEW_CLASS=LogView;class FirewallLogTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_firewall_log",pageName:"Firewall Log",router:"admin/security/firewall-log",Collection:l.LogList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search IP, action, or rule",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 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 i=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 '}),s=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 '}),a=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 o.TabView({tabs:{Overview:i,"Raw Signals":s,"Server Signals":a},activeTab:"Overview",containerId:"signal-tabs"}),this.addChild(this.tabView);const n=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(n)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}c.BouncerSignal.VIEW_CLASS=BouncerSignalView;class BouncerSignalTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_signals",pageName:"Bouncer Signals",router:"admin/security/bouncer-signals",Collection:c.BouncerSignalList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search IP, country, or rule",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,visibility:"xl",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",visibility:"lg",filter:{type:"text"}},{key:"stage",label:"Stage",visibility:"lg",filter:{type:"select",options:["assess","submit","event"]}},{key:"muid",label:"Device",formatter:"truncate_middle(12)",visibility:"xl"}],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 &middot; {{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 i=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 '}),s=new l.TableView({Collection:c.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"}}),a=new l.TableView({Collection:c.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 o.TabView({tabs:{Overview:i,Signals:s,Incidents:a},activeTab:"Overview",containerId:"device-tabs"}),this.addChild(this.tabView);const n=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(n)}async onActionRefresh(){await this.model.fetch({params:{graph:"detail"}})}}c.BouncerDevice.VIEW_CLASS=BouncerDeviceView;class BouncerDeviceTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_bouncer_devices",pageName:"Bouncer Devices",router:"admin/security/bouncer-devices",Collection:c.BouncerDeviceList,dayRangeFilter:{field:"last_seen",value:"30d"},searchPlaceholder:"Search MUID or IP",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,visibility:"lg",align:"right",footer_total:!0},{key:"block_count",label:"Blocks",sortable:!0,visibility:"lg",align:"right",footer_total:!0},{key:"last_seen_ip",label:"Last IP",visibility:"lg",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 n.TablePage{constructor(e={}){super({...e,name:"admin_bot_signatures",pageName:"Bot Signatures",router:"admin/security/bot-signatures",Collection:c.BouncerSignatureList,viewDialogOptions:{size:"lg"},defaultQuery:{sort:"-modified"},dayRangeFilter:!0,searchPlaceholder:"Search signature value or notes",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:"boolean",trueLabel:"Active",falseLabel:"Inactive"}},{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}})}onActionBatchEnable(){return this.batchAction({field:"is_active",value:!0,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"is_active",value:!1,label:"Disable"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}class IPSetView extends t.View{constructor(e={}){super({className:"ipset-view",...e}),this.model=e.model||new c.IPSet(e.data||{});const t=this.model.get("kind")||"",i=c.IPSetKindBadgeOptions.find(e=>e.value===t);this.kindLabel=i?i.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 i=this.model.get("source")||"",s=c.IPSetSourceOptions.find(e=>e.value===i),a=s?s.label:i,n=this.model.get("last_synced"),l=this.model.get("sync_error"),r=[{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:a,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:n?new Date(n).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 d.default({model:this.model,className:"p-3",columns:2,showEmptyValues:!0,emptyValueText:"—",fields:r}),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 o.TabView({containerId:"ipset-tabs",tabs:{Configuration:this.configView,"CIDR Data":this.cidrView},activeTab:"Configuration"}),this.addChild(this.tabView);const h=this.model.get("is_enabled"),u=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"},h?{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(u)}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 a.Modal.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 a.Modal.modelForm({title:`Edit IP Set — ${this.model.get("name")}`,model:this.model,formConfig:c.IPSetForms.edit})&&(await this.render(),this.getApp()?.toast?.success("IP Set updated"))}async onActionDeleteIpset(){if(await a.Modal.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}`)}}}c.IPSet.VIEW_CLASS=IPSetView,c.IPSet.VIEW_CLASS=IPSetView;class IPSetTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_ipsets",pageName:"IP Sets",router:"admin/security/ipsets",Collection:c.IPSetList,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:"boolean",trueLabel:"Enabled",falseLabel:"Disabled"}},{key:"name",label:"Name",sortable:!0},{key:"kind",label:"Kind",sortable:!0,width:"120px",formatter:e=>{const t=c.IPSetKindBadgeOptions.find(t=>t.value===e);return`<span class="badge bg-primary bg-opacity-75">${t?t.label:e}</span>`},filter:{type:"select",options:c.IPSetKindBadgeOptions}},{key:"description",label:"Description",formatter:"truncate(40)|default('—')",visibility:"xl"},{key:"cidr_count",label:"CIDRs",width:"80px",sortable:!0,align:"right",visibility:"lg"},{key:"source",label:"Source",width:"110px",visibility:"lg",formatter:e=>{const t=c.IPSetSourceOptions.find(t=>t.value===e);return t?t.label:e||"—"}},{key:"last_synced|datetime",label:"Last Synced",width:"160px",sortable:!0,visibility:"xl"},{key:"sync_error",label:"Status",width:"80px",visibility:"xl",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,searchPlaceholder:"Search name, description, or kind",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 a.Modal.form({...c.IPSetForms.create});if(!e)return;if("country"===e.kind&&e.country_code){const t=e.country_code,i=c.CommonBlockCountries.find(e=>e.value===t);e.name=`country_${t}`,e.source="ipdeny",e.description=i?`Country block: ${i.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 c.IPSet,i=await t.save(e);i?.data?.status?(this.getApp()?.toast?.success("IP Set created"),this.tableView?.collection?.fetch()):a.Modal.showError(i?.data?.error||"Failed to create IP Set")}onActionBatchEnable(){return this.batchAction({field:"enable",value:1,label:"Enable"})}onActionBatchDisable(){return this.batchAction({field:"disable",value:1,label:"Disable"})}onActionBatchSync(){return this.batchAction({field:"sync",value:1,label:"Sync"})}onActionBatchRefresh(){return this.batchAction({field:"refresh_source",value:1,label:"Refresh"})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}l.Log.VIEW_CLASS=LogView;class LogTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_logs",pageName:"Manage Logs",router:"admin/logs",Collection:l.LogList,dayRangeFilter:!0,...n.groupByDay("created"),searchPlaceholder:"Search title, message, or ID",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",visibility:"lg",filter:{type:"text"}},{key:"path",label:"Path",visibility:"lg",filter:{type:"text"}},{key:"username",label:"User",visibility:"lg",filter:{type:"text"}},{key:"ip",label:"IP",visibility:"xl",filter:{type:"text"}},{key:"duid",label:"Browser ID",formatter:"truncate_middle(16)",visibility:"xl",filter:{type:"text"}}],defaultQuery:{sort:"-created"},rowStripe:e=>{const t=String(e.get("level")||"").toLowerCase();return"error"===t?"danger":"warning"===t?"warning":null},searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No log entries found.",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 n.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 d.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 a.Modal.modelForm({title:`Edit Permissions for ${this.model.get("account")}`,model:this.model,formConfig:n.MetricsForms.edit});e&&(this.model.set(e.data.data),this.render())}async onActionDelete(){await a.Modal.confirm(`Are you sure you want to delete all permissions for ${this.model.get("account")}?`)&&(await this.model.destroy(),this.emit("deleted",this.model))}}n.MetricsPermission.VIEW_CLASS=MetricsPermissionsView,n.MetricsPermission.EDIT_FORM=n.MetricsForms.edit,n.MetricsPermission.VIEW_CLASS=MetricsPermissionsView;class MetricsPermissionsTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_metrics_permissions",pageName:"Metrics Permissions",router:"admin/metrics/permissions",Collection:n.MetricsPermissionList,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"}],actions:["view","edit","delete"],emptyMessage:"No metrics permissions found.",selectable:!0,searchable:!0,sortable:!0,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!0})}}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 Qe={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:t.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:t.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:Qe.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=Qe.create,Setting.EDIT_FORM=Qe.edit,Setting.VIEW_CLASS=SettingView;class SettingTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_settings",pageName:"Settings",router:"admin/settings",Collection:SettingList,searchPlaceholder:"Search key or group",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 n.TablePage{constructor(e={}){super({...e,name:"admin_file_managers",pageName:"Manage Storage Backends",router:"admin/file-managers",Collection:a.FileManagerList,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,visibility:"xl"},{key:"is_default",label:"Default",formatter:"boolean|badge"},{key:"is_active",label:"Active",formatter:"boolean|badge"},{key:"is_public",label:"Public",formatter:"boolean|badge",visibility:"xl"},{key:"backend_type",label:"Type",formatter:"default('Unknown')"},{key:"created",label:"Created",formatter:"epoch|datetime",visibility:"xl"}],searchPlaceholder:"Search backend name or URL",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),s=await a.Modal.modelForm({title:"Edit Owners",model:i,fields:FileManagerForms.owners.fields});if(!s)return!0;s.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),s=await i.save({check_cors:!0});return s.success&&s.data.status?await a.Modal.data({title:`Audit Report - ${i._.name}`,data:s.data,size:"lg"}):this.getApp().toast.error("Connection test failed"),!0}async onActionTestConnection(e,t){const i=this.collection.get(t.dataset.id),s=await i.save({test_connection:!0});return s.success&&s.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),s=await a.Modal.modelForm({title:"Edit Credentials",model:i,fields:FileManagerForms.credentials.fields});return!s||(s.success&&s.data.status?this.getApp().toast.success("Credentials updated successfully"):this.getApp().toast.error("Failed to update credentials"),!0)}async onActionClone(e,t){if(!(await a.Modal.confirm({title:"Clone File Manager",message:"This will create a clone with the same credentials."})))return!0;const i=this.collection.get(t.dataset.id),s=await i.save({clone:!0});return s.success&&s.data.status?(this.getApp().toast.success("Connection cloned successfully"),this.collection.fetch()):this.getApp().toast.error("Failed to clone connection"),!0}}a.File.VIEW_CLASS=n.FileView;class FileTablePage extends n.TablePage{constructor(e={}){super({name:"admin_files",pageName:"Manage Files",router:"admin/files",Collection:a.FileList,onAdd:async e=>{await this.handleFileUpload(e)},viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},columns:[{key:"id",label:"ID",sortable:!0,class:"text-muted"},{key:"filename",label:"Filename"},{key:"content_type",label:"Type",formatter:"default('Unknown')",visibility:"lg"},{key:"file_size",label:"Size",formatter:"filesize",align:"right"},{key:"group.name",label:"Group",formatter:"default('No Group')",visibility:"xl"},{key:"upload_status",label:"Status",formatter:"badge",visibility:"xl"},{key:"created",label:"Uploaded",formatter:"epoch|datetime"}],searchPlaceholder:"Search filename or content type",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 s=104857600;if(i.size>s)this.showError(`File size (${this._formatFileSize(i.size)}) exceeds maximum (${this._formatFileSize(s)})`);else try{const e=new a.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const s=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 s}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 s=e[0];s.name,s.type,s.size;try{const e=new a.File;let t={};this.options.requiresGroup&&this.getApp().activeGroup&&(t.group=this.getApp().activeGroup.id);const i=e.upload({file:s,name:s.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 n.TablePage{constructor(e={}){super({...e,name:"admin_s3_buckets",pageName:"Manage S3 Buckets",router:"admin/s3-buckets",Collection:c.S3BucketList,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}})}}const Ze=t.MOJOUtils.escapeHtml;function Xe(e,t){const i=e?.get?.("short_link");if(i)return i;const s=e?.get?.("code");if(!s)return"";const a=t?.config?.shortlink_base_url||("undefined"!=typeof window?window.location.origin:"");return`${String(a).replace(/\/+$/,"")}/s/${s}`}function et(e){const t=e?.get?.("expires_at");if(!t)return null;const i=new Date(t).getTime();return Number.isFinite(i)?Math.round((i-Date.now())/864e5):null}function tt(e){if(!e)return"";const t=n.SHORTLINK_SOURCE_OPTIONS.find(t=>t.value===e);return t?t.label:String(e)}function it(e){const t=e?.models||[],i=Date.now(),s=864e5,a=i-30*s,n=i-7*s,o=/* @__PURE__ */new Date;o.setHours(0,0,0,0);const l=o.getTime();let r=0,c=0,d=0,h=0;const u=/* @__PURE__ */new Map;for(const b of t){const e=b.get?.("created");if(null==e)continue;const t="number"==typeof e?e<1e11?1e3*e:e:new Date(e).getTime();if(!Number.isFinite(t))continue;if(t<a)continue;r++,t>=n&&c++,t>=l&&d++,b.get?.("is_bot")&&h++;const i=b.get?.("country")||b.get?.("geo")?.country||b.get?.("ip_info")?.country||null;i&&u.set(i,(u.get(i)||0)+1)}let m=null,p=0;for(const[b,g]of u)g>p&&(m=b,p=g);return{count30:r,count7:c,countToday:d,topCountry:m,botPct:r>0?Math.round(h/r*1e3)/10:null}}class ShortLinkOverviewSection extends t.View{constructor(e={}){super({className:"shortlink-overview-section",template:'\n <div class="detail-kpi-grid">\n <div data-container="sl-kpi-30d"></div>\n <div data-container="sl-kpi-7d"></div>\n <div data-container="sl-kpi-today"></div>\n <div data-container="sl-kpi-country"></div>\n </div>\n\n <div class="detail-section-eyebrow">Slack / iMessage preview</div>\n {{#hasOg|bool}}\n <div class="sl-preview">\n <div class="sl-preview-thumb">\n {{#hasOgImage|bool}}<img src="{{ogImage}}" alt="">{{/hasOgImage|bool}}\n {{^hasOgImage|bool}}<i class="bi bi-link-45deg"></i>{{/hasOgImage|bool}}\n </div>\n <div class="sl-preview-body">\n <div class="sl-preview-domain">{{domain}}</div>\n {{#hasOgTitle|bool}}<div class="sl-preview-title">{{ogTitle}}</div>{{/hasOgTitle|bool}}\n {{#hasOgDescription|bool}}<div class="sl-preview-desc">{{ogDescription}}</div>{{/hasOgDescription|bool}}\n </div>\n </div>\n <div class="text-secondary small mt-2">\n <i class="bi bi-info-circle me-1"></i>\n Pulled from <code>og:title</code>, <code>og:description</code>, <code>og:image</code> on the destination page.\n </div>\n {{/hasOg|bool}}\n {{^hasOg|bool}}\n <div class="alert alert-info mb-0 small d-flex align-items-start gap-2">\n <i class="bi bi-info-circle flex-shrink-0 mt-1"></i>\n <div>\n No OG metadata set on this link. The server auto-scrapes the destination URL in the background &mdash;\n custom values entered in <strong>OG / Social</strong> override scraped values.\n </div>\n </div>\n {{/hasOg|bool}}\n ',...e}),this.clicksCollection=e.clicksCollection||null}get _flat(){return n.flattenShortLinkMetadata(this.model.get("metadata"))}get ogTitle(){return this._flat.og_title||""}get ogDescription(){return this._flat.og_description||""}get ogImage(){return this._flat.og_image||""}get hasOgTitle(){return!!this.ogTitle}get hasOgDescription(){return!!this.ogDescription}get hasOgImage(){return!!this.ogImage}get hasOg(){return this.hasOgTitle||this.hasOgDescription||this.hasOgImage}get domain(){return function(e){if(!e)return"";try{return new URL(e).hostname}catch(t){return e}}(this.model.get("url"))}async onInit(){const e=it(this.clicksCollection);this.kpi30=new n.MetricCard({containerId:"sl-kpi-30d",label:"Hits · 30d",value:e.count30,tone:e.count30>0?"success":"default"}),this.kpi7=new n.MetricCard({containerId:"sl-kpi-7d",label:"Hits · 7d",value:e.count7}),this.kpiToday=new n.MetricCard({containerId:"sl-kpi-today",label:"Today",value:e.countToday}),this.kpiCountry=new n.MetricCard({containerId:"sl-kpi-country",label:"Top country",value:e.topCountry||"—"}),[this.kpi30,this.kpi7,this.kpiToday,this.kpiCountry].forEach(e=>this.addChild(e)),this.clicksCollection&&this.clicksCollection.on("fetch:success",()=>this._refreshKpis(),this)}_refreshKpis(){const e=it(this.clicksCollection);this.kpi30?.setValue(e.count30),this.kpi7?.setValue(e.count7),this.kpiToday?.setValue(e.countToday),this.kpiCountry?.setValue(e.topCountry||"—")}async refreshFromModel(){this.isMounted?.()&&await this.render()}}class ShortLinkConfigurationSection extends t.View{constructor(e={}){super({className:"shortlink-configuration-section",enableTooltips:!0,template:'\n <div class="detail-section-eyebrow">\n Destination\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit shortlink">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Original URL</div>\n <div class="detail-flat-row-value detail-flat-row-value--url">\n {{#hasUrl|bool}}<a href="{{model.url}}" target="_blank" rel="noopener noreferrer" data-bs-toggle="tooltip" title="{{model.url}}">{{model.url}}</a>{{/hasUrl|bool}}\n {{^hasUrl|bool}}<span class="text-secondary fst-italic">—</span>{{/hasUrl|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Code</div>\n <div class="detail-flat-row-value">\n {{#hasCode|bool}}<code>{{model.code}}</code>{{/hasCode|bool}}\n {{^hasCode|bool}}<span class="text-secondary fst-italic">—</span>{{/hasCode|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Source</div>\n <div class="detail-flat-row-value">\n {{#hasSource|bool}}{{sourceLabel}}{{/hasSource|bool}}\n {{^hasSource|bool}}<span class="text-secondary fst-italic">—</span>{{/hasSource|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Tracking\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit tracking">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Track clicks</div>\n <div class="detail-flat-row-value">\n {{#trackClicks|bool}}<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>Enabled</span>{{/trackClicks|bool}}\n {{^trackClicks|bool}}<span class="badge text-bg-secondary">Disabled</span>{{/trackClicks|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Bot passthrough</div>\n <div class="detail-flat-row-value">\n {{#botPassthrough|bool}}<span class="badge text-bg-info"><i class="bi bi-robot me-1"></i>Bypassed</span>{{/botPassthrough|bool}}\n {{^botPassthrough|bool}}<span class="badge text-bg-secondary">Bots see preview</span>{{/botPassthrough|bool}}\n </div>\n </div>\n\n <div class="detail-section-eyebrow">\n Lifecycle\n <button class="detail-section-action" data-action="edit-shortlink" data-bs-toggle="tooltip" title="Edit lifecycle">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Active</div>\n <div class="detail-flat-row-value">\n {{#isActive|bool}}<span class="badge text-bg-success">Active</span>{{/isActive|bool}}\n {{^isActive|bool}}<span class="badge text-bg-secondary">Disabled</span>{{/isActive|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Expires</div>\n <div class="detail-flat-row-value">\n {{#hasExpires|bool}}<code>{{model.expires_at|datetime}}</code>{{/hasExpires|bool}}\n {{^hasExpires|bool}}<span class="text-secondary">Never</span>{{/hasExpires|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">Protected</div>\n <div class="detail-flat-row-value">\n {{#isProtected|bool}}<span class="badge text-bg-warning"><i class="bi bi-shield-lock me-1"></i>Protected</span>{{/isProtected|bool}}\n {{^isProtected|bool}}<span class="badge text-bg-secondary">Unprotected</span>{{/isProtected|bool}}\n </div>\n </div>\n ',...e})}get hasUrl(){return!!this.model.get("url")}get hasCode(){return!!this.model.get("code")}get hasSource(){return!!this.model.get("source")}get hasExpires(){return!!this.model.get("expires_at")}get sourceLabel(){return tt(this.model.get("source"))}get trackClicks(){return!!this.model.get("track_clicks")}get botPassthrough(){return!!this.model.get("bot_passthrough")}get isActive(){return!!this.model.get("is_active")}get isProtected(){return!!this.model.get("is_protected")}}class ShortLinkMetricsSection extends t.View{constructor(e={}){super({className:"shortlink-metrics-section",...e});const t=!!this.model.get("track_clicks"),i=this.model.get("user");this.userId=i?.id||i||null,this.code=this.model.get("code"),this.canShowMetrics=t&&this.userId&&this.code,this.canShowMetrics?this.template='\n <div class="detail-section-eyebrow">Click metrics</div>\n <div data-container="sl-metrics-chart"></div>\n ':(this.template='\n <div class="detail-section-eyebrow">Click metrics</div>\n <div class="alert alert-info mb-0 d-flex align-items-start gap-2">\n <i class="bi bi-info-circle flex-shrink-0 mt-1"></i>\n <div>{{reason}}</div>\n </div>\n ',this.reason=t?"No owning user on this shortlink — per-link metrics are recorded per user account.":'Click tracking is disabled — enable "Track clicks" on this shortlink to collect time-series data.')}async onInit(){this.canShowMetrics&&(this.metricsChart=new i.MetricsChart({containerId:"sl-metrics-chart",title:"Clicks",slugs:[`sl:click:${this.code}`],account:`user-${this.userId}`,granularity:"days",defaultDateRange:"30d",yAxis:{label:"Clicks",beginAtZero:!0},tooltip:{y:"number"},height:320}),this.addChild(this.metricsChart))}}class ShortLinkOgSection extends t.View{constructor(e={}){super({className:"shortlink-og-section",template:'\n <div class="detail-section-eyebrow">\n OG / Social\n <button class="detail-section-action" data-action="edit-og" data-bs-toggle="tooltip" title="{{#hasAny|bool}}Edit OG metadata{{/hasAny|bool}}{{^hasAny|bool}}Add OG metadata{{/hasAny|bool}}">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n {{#hasAny|bool}}\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:title</div>\n <div class="detail-flat-row-value">\n {{#hasOgTitle|bool}}{{ogTitle}}{{/hasOgTitle|bool}}\n {{^hasOgTitle|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgTitle|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:description</div>\n <div class="detail-flat-row-value">\n {{#hasOgDescription|bool}}{{ogDescription}}{{/hasOgDescription|bool}}\n {{^hasOgDescription|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgDescription|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">og:image</div>\n <div class="detail-flat-row-value text-truncate">\n {{#hasOgImage|bool}}<a href="{{ogImage}}" target="_blank" rel="noopener noreferrer">{{ogImage}}</a>{{/hasOgImage|bool}}\n {{^hasOgImage|bool}}<span class="text-secondary fst-italic">—</span>{{/hasOgImage|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:card</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterCard|bool}}<code>{{twitterCard}}</code>{{/hasTwitterCard|bool}}\n {{^hasTwitterCard|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterCard|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:title</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterTitle|bool}}{{twitterTitle}}{{/hasTwitterTitle|bool}}\n {{^hasTwitterTitle|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterTitle|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:description</div>\n <div class="detail-flat-row-value">\n {{#hasTwitterDescription|bool}}{{twitterDescription}}{{/hasTwitterDescription|bool}}\n {{^hasTwitterDescription|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterDescription|bool}}\n </div>\n </div>\n <div class="detail-flat-row">\n <div class="detail-flat-row-label">twitter:image</div>\n <div class="detail-flat-row-value text-truncate">\n {{#hasTwitterImage|bool}}<a href="{{twitterImage}}" target="_blank" rel="noopener noreferrer">{{twitterImage}}</a>{{/hasTwitterImage|bool}}\n {{^hasTwitterImage|bool}}<span class="text-secondary fst-italic">—</span>{{/hasTwitterImage|bool}}\n </div>\n </div>\n {{/hasAny|bool}}\n {{^hasAny|bool}}\n <p class="text-secondary small mb-0">\n No custom OG / Twitter metadata. The server scrapes the destination URL in the\n background &mdash; set values here to override what gets scraped.\n </p>\n {{/hasAny|bool}}\n ',...e})}get _flat(){return n.flattenShortLinkMetadata(this.model.get("metadata"))}get ogTitle(){return this._flat.og_title||""}get ogDescription(){return this._flat.og_description||""}get ogImage(){return this._flat.og_image||""}get twitterCard(){return this._flat.twitter_card||""}get twitterTitle(){return this._flat.twitter_title||""}get twitterDescription(){return this._flat.twitter_description||""}get twitterImage(){return this._flat.twitter_image||""}get hasOgTitle(){return!!this.ogTitle}get hasOgDescription(){return!!this.ogDescription}get hasOgImage(){return!!this.ogImage}get hasTwitterCard(){return!!this.twitterCard}get hasTwitterTitle(){return!!this.twitterTitle}get hasTwitterDescription(){return!!this.twitterDescription}get hasTwitterImage(){return!!this.twitterImage}get hasAny(){return this.hasOgTitle||this.hasOgDescription||this.hasOgImage||this.hasTwitterCard||this.hasTwitterTitle||this.hasTwitterDescription||this.hasTwitterImage}async refresh(){this.isMounted()&&await this.render()}}class ShortLinkMetadataSection extends t.View{constructor(e={}){super({className:"shortlink-metadata-section",template:'\n <div class="detail-section-eyebrow">\n Metadata\n <button class="detail-section-action" data-action="edit-metadata" data-bs-toggle="tooltip" title="Edit metadata JSON">\n <i class="bi bi-pencil"></i>\n </button>\n </div>\n <div data-container="sl-metadata-card"></div>\n ',...e})}async onInit(){this.knownFields=new n.KnownFieldsCard({containerId:"sl-metadata-card",model:this.model,data:e=>e.get("metadata")||{},knownKeys:[],rawLabel:"Raw metadata JSON",rawCollapsed:!1,emptyText:"No metadata is set on this shortlink. Use this for arbitrary configuration the framework doesn’t know about."}),this.addChild(this.knownFields)}async refresh(){this.isMounted?.()&&await this.render()}}class ShortLinkView extends n.DetailView{constructor(e={}){const i=e.model||new n.ShortLink(e.data||{}),s=Math.floor(Date.now()/1e3)-2592e3,a=new n.ShortLinkClickList({params:{shortlink:i.get("id"),created__gte:s,sort:"-created",size:200}}),o=new ShortLinkOverviewSection({model:i,clicksCollection:a}),r=new ShortLinkConfigurationSection({model:i}),c=!!i.get("track_clicks"),d=new l.TableView({collection:a,title:"Click History",eyebrow:"Section · Click History",showFullscreen:!1,searchable:!1,sortable:!0,filterable:!0,paginated:!0,hideActivePillNames:["shortlink","created__gte"],tableOptions:{hover:!0,size:"sm",emptyMessage:c?"No clicks recorded yet.":'Click tracking is disabled for this shortlink. Enable "Track clicks" in Configuration to collect per-click history.',emptyIcon:"bi-cursor",actions:[]},columns:[{key:"created",label:"Time",width:"180px",formatter:"datetime",sortable:!0},{key:"ip",label:"IP",width:"140px",template:"<code>{{model.ip}}</code>"},{key:"is_bot",label:"Bot",width:"80px",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Bots only"},{value:"false",label:"Humans only"}]}},{key:"user_agent",label:"User-Agent",formatter:"truncate(60)"},{key:"referer",label:"Referer",formatter:"truncate(40)|default('—')"}]}),h=new ShortLinkMetricsSection({model:i}),u=new ShortLinkOgSection({model:i}),m=new ShortLinkMetadataSection({model:i}),p=[{key:"Overview",label:"Overview",icon:"bi-grid-1x2",view:o},{key:"Configuration",label:"Configuration",icon:"bi-sliders",view:r},{type:"divider",label:"Activity"},{key:"ClickHistory",label:"Click History",icon:"bi-cursor",view:d},{key:"Metrics",label:"Metrics",icon:"bi-graph-up",view:h},{type:"divider",label:"Detail"},{key:"OgSocial",label:"OG / Social",icon:"bi-share",view:u},{key:"Metadata",label:"Metadata",icon:"bi-braces",view:m}],b=[{icon:"bi-tag-fill",text:e=>tt(e.get("source"))||null,variant:"primary",when:e=>!!e.get("source")},{icon:"bi-cursor",text:e=>`${e.get("hit_count")||0} hits`,variant:"success",when:e=>(e.get("hit_count")||0)>0},{icon:"bi-graph-up",text:"Tracked",variant:"info",when:e=>!!e.get("track_clicks")},{icon:"bi-eye-slash",text:"Untracked",variant:"secondary",when:e=>!e.get("track_clicks")},{icon:"bi-clock",text:e=>{const t=et(e);return null==t?null:t<0?"Expired":`expires in ${t}d`},variant:"warning",when:e=>{const t=et(e);return null!=t&&t<=14}},{icon:"bi-shield-lock",text:"Protected",variant:"warning",when:e=>!!e.get("is_protected")}];super({className:"shortlink-view",...e,model:i,header:{icon:"bi-link-45deg",titleFn:t=>t.get("short_link")||Xe(t,e.app)||t.get("code")||"Short link",subtitleFn:e=>{const t=e.get("url")||"";return t?`→ ${t.length>80?`${t.slice(0,80)}…`:t}`:""},chips:b,titleAffix:()=>'<button type="button" class="dh-name-action" data-action="copy-link" data-bs-toggle="tooltip" title="Copy short URL"><i class="bi bi-clipboard"></i></button>',auxFn:e=>function(e){const i=!!e.get("is_active"),s=et(e),a=null!=s&&s<0,n=e.get("hit_count")||0,o=e.get("modified"),l=o?t.dataFormatter.pipe(o,"relative"):"",r=`\n <label class="dh-active-switch">\n <input type="checkbox" data-change-action="toggle-active" ${i?"checked":""}>\n <span class="dh-track"></span>\n <span class="dh-track-label">${i?a?"Expired":"Active":"Disabled"}</span>\n </label>\n `,c=[];i&&!a&&c.push(`${n.toLocaleString()} ${1===n?"hit":"hits"}`),l&&c.push(`updated ${Ze(l)}`);const d=c.join(" · ");return`\n <div class="dh-aux-top">${r}</div>\n ${d?`<span class="dh-aux-meta">${d}</span>`:""}\n `}(e),actions:[],contextMenu:{items:[{label:"Copy short URL",action:"copy-link",icon:"bi-clipboard"},{label:"Open destination",action:"open-destination",icon:"bi-box-arrow-up-right"},{type:"divider"},{label:"Edit shortlink",action:"edit-shortlink",icon:"bi-pencil"},{label:"Refresh OG metadata",action:"refresh-og",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Delete shortlink",action:"delete-shortlink",icon:"bi-trash",danger:!0}]}},sections:p,activeSection:"Overview"}),this.clicksCollection=a,this.overviewSection=o,this.configurationSection=r,this.clickHistorySection=d,this.metricsSection=h,this.ogSection=u,this.metadataSection=m}async onAfterBuild(){const e=()=>{const e=this.model.get("hit_count")||0;this.setBadge("ClickHistory",e>0?{text:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e),variant:"muted"}:null)};e(),this.clicksCollection.on("fetch:success",e,this),this.clicksCollection.fetch().catch(()=>{})}async _refreshFromModel(){this.headerView?.isMounted()&&await this.headerView.render(),this.overviewSection?.isMounted()&&await this.overviewSection.refreshFromModel(),this.configurationSection?.isMounted()&&await this.configurationSection.render(),this.ogSection?.isMounted()&&await this.ogSection.refresh(),this.metadataSection?.isMounted()&&await this.metadataSection.refresh()}async onActionToggleActive(e,t){const i=!!t.checked;t.disabled=!0;try{this.model.set("is_active",i);const e=await this.model.save({is_active:i});if(e&&e.status&&e.status>=400)throw new Error("Save failed");this.emit("detail:updated")}catch(s){this.model.set("is_active",!i)}finally{t&&t.isConnected&&(t.disabled=!1)}return!0}async onActionCopyLink(){const e=Xe(this.model,this.getApp?.());if(e)try{await navigator.clipboard.writeText(e),this.getApp()?.toast?.success(`Copied: ${e}`)}catch(t){this.getApp()?.toast?.warning("Copy failed — select the URL manually.")}}async onActionOpenDestination(){const e=this.model.get("url");e&&/^https?:\/\//i.test(e)&&window.open(e,"_blank","noopener,noreferrer")}async onActionEditShortlink(){const e={...this.model.toJSON(),...n.flattenShortLinkMetadata(this.model.get("metadata"))},t=await a.Modal.form({...n.ShortLinkForms.edit,data:e});if(!t)return;const i=n.extractShortLinkPayload(t);try{await this.model.save(i),this.getApp()?.toast?.success("Shortlink updated"),await this._refreshFromModel(),this.emit("detail:updated")}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to update")}}async onActionEditOg(){const e=n.flattenShortLinkMetadata(this.model.get("metadata")),t=await a.Modal.form({title:"Edit OG / Social metadata",size:"md",fields:[{name:"og_title",type:"text",label:"og:title",cols:12},{name:"og_description",type:"textarea",label:"og:description",rows:3,cols:12},{name:"og_image",type:"url",label:"og:image",placeholder:"https://…",cols:12},{name:"twitter_card",type:"select",label:"twitter:card",options:n.TWITTER_CARD_OPTIONS,cols:6},{name:"twitter_title",type:"text",label:"twitter:title",cols:6},{name:"twitter_description",type:"textarea",label:"twitter:description",rows:2,cols:12},{name:"twitter_image",type:"url",label:"twitter:image",cols:12}],data:e,submitText:"Save",cancelText:"Cancel"});if(!t)return;const i=n.buildShortLinkMetadata(t);try{await this.model.save({metadata:i}),this.model.set("metadata",i),this.getApp()?.toast?.success("OG metadata saved"),await this._refreshFromModel(),this.emit("detail:updated")}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to save metadata")}}async onActionEditMetadata(){const e=this.model.get("metadata")||{},t=JSON.stringify(e,null,2),i=await a.Modal.form({title:"Edit metadata (JSON)",icon:"bi-braces",size:"lg",fields:[{type:"html",columns:12,html:'<div class="alert alert-info small mb-3">\n <i class="bi bi-info-circle me-1"></i>\n Free-form JSON object. OG / Twitter keys are also editable from the OG / Social section.\n </div>'},{name:"metadata_json",type:"textarea",label:"Metadata",rows:16,columns:12,value:t,placeholder:'{ "key": "value" }',tooltip:"Must be a valid JSON object"}],submitText:"Save",cancelText:"Cancel"});if(!i)return;let s;try{if(s=JSON.parse(i.metadata_json),null===s||"object"!=typeof s||Array.isArray(s))throw new Error("Metadata must be a JSON object (e.g. `{}`), not an array or scalar.")}catch(n){return void this.getApp()?.toast?.error(`Invalid JSON: ${n.message}`)}try{await this.model.save({metadata:s}),this.model.set("metadata",s),this.getApp()?.toast?.success("Metadata updated"),await this._refreshFromModel(),this.emit("detail:updated")}catch(n){a.Modal.showError(n?.data?.error||n?.message||"Failed to save metadata")}}async onActionRefreshOg(){try{await this.model.fetch(),await this._refreshFromModel(),this.getApp()?.toast?.success("OG metadata refreshed")}catch(e){this.getApp()?.toast?.error(e?.message||"Failed to refresh metadata")}}async onActionDeleteShortlink(){if(await a.Modal.confirm(`Delete shortlink "${this.model.get("code")}"? This cannot be undone.`,"Delete Shortlink",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Shortlink deleted"),this.emit("shortlink:deleted",{model:this.model});const e=this.element?.closest?.(".modal");if(e){const t=window.bootstrap?.Modal?.getInstance?.(e);t&&t.hide()}}catch(e){a.Modal.showError(e?.data?.error||e?.message||"Failed to delete")}}}ShortLinkView.VIEW_CLASS=ShortLinkView,n.ShortLink.VIEW_CLASS=ShortLinkView,n.ShortLink.MODEL_REF="shortlink.ShortLink",n.ShortLink.VIEW_CLASS=ShortLinkView;class ShortLinkTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_shortlinks",pageName:"Shortlinks",router:"admin/shortlinks/links",Collection:n.ShortLinkList,onAdd:()=>this._handleAdd(),onItemEdit:e=>this._handleEdit(e),dayRangeFilter:!0,searchPlaceholder:"Search code, URL, or source",viewDialogOptions:{header:!1,noBodyPadding:!0,buttons:[]},defaultQuery:{sort:"-created"},columns:[{key:"is_active",label:"Active",width:"70px",sortable:!0,visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Active"},{value:"false",label:"Disabled"}]}},{key:"code",label:"Code",sortable:!0,template:'\n <div class="d-flex align-items-center gap-2">\n <code>{{model.code}}</code>\n <button class="btn btn-sm btn-link p-0 text-muted"\n data-action="copy-code"\n data-code="{{model.code}}"\n title="Copy short URL">\n <i class="bi bi-clipboard"></i>\n </button>\n </div>\n '},{key:"url",label:"Destination",sortable:!0,formatter:"truncate(60)|default('—')"},{key:"source",label:"Source",width:"110px",sortable:!0,visibility:"lg",filter:{type:"select",options:n.SHORTLINK_SOURCE_OPTIONS}},{key:"hit_count",label:"Hits",width:"80px",sortable:!0,align:"right",footer_total:!0},{key:"track_clicks",label:"Tracked",width:"90px",visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Tracked"},{value:"false",label:"Not tracked"}]}},{key:"expires_at",label:"Expires",width:"160px",sortable:!0,visibility:"xl",formatter:"datetime|default('Never')"},{key:"created",label:"Created",width:"160px",sortable:!0,formatter:"datetime",visibility:"xl"}],selectable:!0,searchable:!0,sortable:!0,filterable:!0,paginated:!0,showRefresh:!0,showAdd:!0,showExport:!0,emptyMessage:"No shortlinks yet — create one to share a link with a rich preview card.",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,actions:["edit","delete"],pageSizes:[25,50,100],defaultPageSize:25,emptyIcon:"bi-link-45deg"}})}async _handleAdd(){const e=await a.Modal.form({...n.ShortLinkForms.create});if(!e)return;const t=n.extractShortLinkPayload(e);try{const e=new n.ShortLink;await e.save(t);const s=Xe(e,this.getApp());try{await(navigator.clipboard?.writeText?.(s)),this.getApp()?.toast?.success(`Shortlink created — ${s} copied to clipboard`)}catch(i){this.getApp()?.toast?.success(`Shortlink created: ${s}`)}this.tableView?.collection?.fetch()}catch(s){a.Modal.showError(s?.data?.error||s?.message||"Failed to create shortlink")}}async _handleEdit(e){if(!e)return;const t={...e.toJSON(),...n.flattenShortLinkMetadata(e.get("metadata"))},i=await a.Modal.form({...n.ShortLinkForms.edit,data:t});if(!i)return;const s=n.extractShortLinkPayload(i);try{await e.save(s),this.getApp()?.toast?.success("Shortlink updated"),this.tableView?.collection?.fetch()}catch(o){a.Modal.showError(o?.data?.error||o?.message||"Failed to update shortlink")}}async onActionCopyCode(e,t){e?.stopPropagation?.();const i=t?.dataset?.code;if(!i)return;const s=Xe({get:e=>"code"===e?i:null},this.getApp());try{await navigator.clipboard.writeText(s),this.getApp()?.toast?.success(`Copied: ${s}`)}catch(a){this.getApp()?.toast?.warning("Copy failed — select the URL manually.")}}async onActionBatchEnable(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await this.getApp().confirm(`Enable ${e.length} shortlink(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!0}))),this.getApp().toast.success(`${e.length} shortlink(s) enabled`),this.tableView.collection.fetch())}async onActionBatchDisable(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await this.getApp().confirm(`Disable ${e.length} shortlink(s)?`)&&(await Promise.all(e.map(e=>e.model.save({is_active:!1}))),this.getApp().toast.success(`${e.length} shortlink(s) disabled`),this.tableView.collection.fetch())}async onActionBatchDelete(){const e=this.tableView?.getSelectedItems?.()||[];e.length&&await a.Modal.confirm(`Delete ${e.length} shortlink(s)? This cannot be undone.`,"Delete Shortlinks",{confirmText:"Delete",confirmClass:"btn-danger"})&&(await Promise.all(e.map(e=>e.model.destroy())),this.getApp().toast.success(`${e.length} shortlink(s) deleted`),this.tableView.collection.fetch())}}class ShortLinkClickTablePage extends n.TablePage{constructor(e={}){super({...e,name:"admin_shortlink_clicks",pageName:"Shortlink Click History",router:"admin/shortlinks/clicks",Collection:n.ShortLinkClickList,dayRangeFilter:!0,...n.groupByDay("created"),defaultQuery:{sort:"-created"},columns:[{key:"created",label:"Time",width:"180px",sortable:!0,formatter:"datetime"},{key:"shortlink.code",label:"Code",width:"130px",template:"<code>{{model.shortlink.code}}</code>"},{key:"shortlink.url",label:"Destination",formatter:"truncate(50)|default('—')",visibility:"lg"},{key:"ip",label:"IP",width:"140px",template:"<code>{{model.ip}}</code>"},{key:"is_bot",label:"Bot",width:"80px",visibility:"lg",formatter:"yesnoicon",filter:{type:"select",options:[{value:"true",label:"Bots only"},{value:"false",label:"Humans only"}]}},{key:"user_agent",label:"User-Agent",formatter:"truncate(50)",visibility:"lg"},{key:"referer",label:"Referer",formatter:"truncate(40)|default('—')",visibility:"xl"}],searchable:!1,sortable:!0,filterable:!0,paginated:!0,selectable:!1,showRefresh:!0,showAdd:!1,showExport:!0,emptyMessage:"No click history recorded. Clicks are only captured on shortlinks created with track_clicks=true.",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 t.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">&middot;</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()||"",i=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":i.includes("iphone")?"bi-phone":i.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 i=this._geo,a=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">${i.city||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Region</div>\n <div class="udl-field-value">${i.region||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">Country</div>\n <div class="udl-field-value">${i.country_name||"—"} ${i.country_code?`<span class="text-muted">(${i.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">${i.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">${i.timezone||"—"}</div>\n </div>\n ${i.latitude?`\n <div class="udl-field-row">\n <div class="udl-field-label">Coordinates</div>\n <div class="udl-field-value">${i.latitude}, ${i.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">${i.isp||"—"}</div>\n </div>\n <div class="udl-field-row">\n <div class="udl-field-label">ASN</div>\n <div class="udl-field-value">${i.asn||"—"} ${i.asn_org?`<span class="text-muted small">(${i.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">${a?.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">${[a?.user_agent?.major,a?.user_agent?.minor,a?.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">${a?.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">${[a?.os?.major,a?.os?.minor,a?.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">${a?.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">${a?.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">${a?.device?.model||"—"}</div>\n </div>\n\n ${a?.string?`\n <div class="udl-section-label">User Agent String</div>\n <div class="udl-ua-string">${a.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;">${i.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!=i.risk_score?i.risk_score:"—"}</div>\n </div>\n\n <div class="udl-section-label">Detection Flags</div>\n ${this._riskRow("VPN","bi-shield",i.is_vpn)}\n ${this._riskRow("Tor Exit Node","bi-shield-lock",i.is_tor)}\n ${this._riskRow("Proxy","bi-diagram-3",i.is_proxy)}\n ${this._riskRow("Cloud Provider","bi-cloud",i.is_cloud)}\n ${this._riskRow("Datacenter","bi-hdd-stack",i.is_datacenter)}\n ${this._riskRow("Mobile","bi-phone",i.is_mobile)}\n\n <div class="udl-section-label">Reputation</div>\n ${this._riskRow("Known Attacker","bi-exclamation-triangle",i.is_known_attacker)}\n ${this._riskRow("Known Abuser","bi-flag",i.is_known_abuser)}\n ${this._riskRow("Threat","bi-shield-exclamation",i.is_threat)}\n ${this._riskRow("Suspicious","bi-question-circle",i.is_suspicious)}\n `})}];if(this.hasCoordinates)try{const e=new s.MapView({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(h){}const r=this.model.get("ip_address");if(r){const e=new l.TableView({collection:new c.IncidentEventList({params:{size:10,source_ip:r}}),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 l.TableView({collection:new l.LogList({params:{size:10,ip:r}}),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 n.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 d=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(d)}_riskRow(e,t,i){return`\n <div class="udl-field-row">\n <div class="udl-field-label"><i class="bi ${t} me-1 ${i?"udl-risk-yes":"udl-risk-no"}"></i>${e}</div>\n <div class="udl-field-value">${i?'<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 a.Modal.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 i=new t.UserDeviceLocation({id:e});return await i.fetch(),i.id?a.Modal.dialog({title:!1,size:"lg",body:new UserDeviceLocationView({model:i}),buttons:[{text:"Close",class:"btn-secondary",dismiss:!0}]}):(a.Modal.alert({message:`Could not find location record: ${e}`,type:"warning"}),null)}}t.UserDeviceLocation.VIEW_CLASS=UserDeviceLocationView;const st={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"}]},at={ec2:"bi-pc-display",rds:"bi-database",redis:"bi-lightning-charge"},nt={ec2:"EC2 Instance",rds:"RDS Database",redis:"ElastiCache Redis"};function ot(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=st[this.resourceType]||[],t=at[this.resourceType]||"bi-cloud",i=nt[this.resourceType]||"Resource";this.resource.state||this.resource.status;const s=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;">${i}</span>\n </div>\n <div class="mt-1">${s}</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=st[this.resourceType]||[];for(let t=0;t<e.length;t++){const i=e[t],s=new CloudWatchChart({containerId:`cwrv-chart-${t}`,account:this.resourceType,category:i.key,slug:this.slug,title:i.label,height:200,yAxis:ot(i.unit),showGranularity:!0,showDateRange:!1,defaultDateRange:"24h",granularity:"hours"});this.addChild(s)}}_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={},s={}){const n=new CloudWatchResourceView({resourceType:e,slug:t,resource:i}),o=at[e]||"bi-cloud",l=nt[e]||"Resource";await a.Modal.dialog(n,{header:`<i class="bi ${o} me-2"></i>${t} <small class="text-muted">— ${l}</small>`,size:"xl",scrollable:!0})}}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 i=(e.queued_count||0)+(e.inflight_count||0);return i>50&&(t="warning"),(i>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(i){console.error("Failed to refresh health:",i)}finally{t.disabled=!1}}async onActionSystemSettings(){await a.Modal.alert({title:"System Settings",message:"System settings interface coming soon!",type:"info"})}}const lt={global:"bg-primary",user:"bg-info",group:"bg-warning text-dark"};class AssistantSkillView extends t.View{constructor(e={}){super({className:"assistant-skill-view",...e}),this.model=e.model||new c.AssistantSkill(e.data||{}),this.template='\n <div class="d-flex flex-column h-100">\n \x3c!-- Header --\x3e\n <div class="d-flex justify-content-between align-items-start mb-3">\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n {{{tierBadge}}}\n {{{statusBadge}}}\n <span class="text-muted small">Skill #{{model.id}}</span>\n </div>\n <h4 class="mb-1">{{model.name}}</h4>\n <p class="text-muted mb-2">{{model.description}}</p>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n {{#model.auto_execute|bool}}\n <span><i class="bi bi-lightning-fill text-warning me-1"></i>Auto-execute enabled</span>\n {{/model.auto_execute|bool}}\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-danger btn-sm" data-action="delete-skill">\n <i class="bi bi-trash me-1"></i>Delete\n </button>\n </div>\n </div>\n\n \x3c!-- Auto-Execute Info --\x3e\n {{#model.auto_execute|bool}}\n <div class="alert alert-warning small py-2 mb-3">\n <i class="bi bi-lightning-fill me-1"></i>\n <strong>Auto-execute:</strong> The assistant will run this skill without asking for confirmation when triggered.\n </div>\n {{/model.auto_execute|bool}}\n\n \x3c!-- Triggers --\x3e\n {{#hasTriggers|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-chat-quote me-1"></i>Trigger Phrases</h6>\n <div class="d-flex flex-wrap gap-2">\n {{#triggers}}\n <span class="badge bg-light text-dark border px-2 py-1">{{.}}</span>\n {{/triggers}}\n </div>\n </div>\n {{/hasTriggers|bool}}\n\n \x3c!-- Steps --\x3e\n {{#hasSteps|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-list-ol me-1"></i>Steps ({{stepCount}})</h6>\n <div class="skill-steps-list">\n {{{stepsHtml}}}\n </div>\n </div>\n {{/hasSteps|bool}}\n\n \x3c!-- Metadata --\x3e\n {{#hasMetadata|bool}}\n <div class="mb-3">\n <h6 class="text-muted mb-2"><i class="bi bi-braces me-1"></i>Metadata</h6>\n <pre class="bg-light p-3 rounded small mb-0"><code>{{metadataJson}}</code></pre>\n </div>\n {{/hasMetadata|bool}}\n </div>\n '}async onInit(){if(!this.model.get("steps"))try{await this.model.fetch({params:{graph:"detail"}})}catch(e){}}async onBeforeRender(){const e=this.model.get("tier")||"user",t=lt[e]||"bg-secondary";this.tierBadge=`<span class="badge ${t}">${e}</span>`;const i=this.model.get("is_active");this.statusBadge=i?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>',this.triggers=this.model.get("triggers")||[],this.hasTriggers=this.triggers.length>0;const s=this.model.get("steps")||[];this.hasSteps=s.length>0,this.stepCount=s.length,this.stepsHtml=this._buildStepsHtml(s);const a=this.model.get("metadata");this.hasMetadata=a&&Object.keys(a).length>0,this.metadataJson=this.hasMetadata?JSON.stringify(a,null,2):""}_buildStepsHtml(e){return e.map((t,i)=>{const s=this._escapeHtml,a=t.params?JSON.stringify(t.params,null,2):null,n=`step-params-${this.model.get("id")}-${i}`;let o=`\n <div class="skill-step-item d-flex gap-3 py-2 ${i<e.length-1?"border-bottom":""}">\n <div class="skill-step-number flex-shrink-0">\n <span class="badge bg-dark rounded-pill">${i+1}</span>\n </div>\n <div class="flex-grow-1" style="min-width: 0;">\n <div class="d-flex align-items-center gap-2 mb-1">\n <code class="small">${s(t.tool||"unknown")}</code>\n ${t.description?`<span class="text-muted small">— ${s(t.description)}</span>`:""}\n </div>`;return t.condition&&(o+=`\n <div class="small text-warning">\n <i class="bi bi-funnel me-1"></i>Condition: <code>${s(t.condition)}</code>\n </div>`),a&&(o+=`\n <div class="mt-1">\n <a class="small text-muted" data-bs-toggle="collapse" href="#${n}" role="button" aria-expanded="false">\n <i class="bi bi-chevron-right me-1"></i>Parameters\n </a>\n <div class="collapse" id="${n}">\n <pre class="bg-light p-2 rounded small mt-1 mb-0"><code>${s(a)}</code></pre>\n </div>\n </div>`),o+="\n </div>\n </div>",o}).join("")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}async onActionDeleteSkill(){if(await a.Modal.confirm(`Delete skill "${this.model.get("name")}"? This cannot be undone.`,"Delete Skill",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Skill deleted"),this.emit("item:deleted",{id:this.model.get("id")})}catch(e){this.getApp()?.toast?.error("Failed to delete skill")}}}c.AssistantSkill.VIEW_CLASS=AssistantSkillView;const rt={global:"bg-primary",user:"bg-info",group:"bg-warning text-dark"};class AssistantSkillTablePage extends n.TablePage{constructor(e={}){super({name:"assistant_skills",pageName:"Assistant Skills",router:"admin/assistant/skills",Collection:c.AssistantSkillList,viewDialogOptions:{header:!1},defaultQuery:{sort:"-modified"},columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"name",label:"Name",sortable:!0},{key:"description",label:"Description",sortable:!1,formatter:"truncate:60"},{key:"tier",label:"Tier",sortable:!0,width:"100px",formatter:e=>`<span class="badge ${rt[e]||"bg-secondary"}">${e||"unknown"}</span>`,filter:{type:"multiselect",placeHolder:"Select Tier",options:["global","user","group"]}},{key:"steps",label:"Steps",width:"80px",sortable:!1,formatter:e=>`<span class="badge bg-secondary">${Array.isArray(e)?e.length:0}</span>`},{key:"auto_execute",label:"Auto",width:"80px",sortable:!0,formatter:e=>e?'<i class="bi bi-check-circle-fill text-success"></i>':'<i class="bi bi-circle text-muted"></i>'},{key:"is_active",label:"Active",width:"80px",sortable:!0,formatter:e=>e?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'},{key:"created",label:"Created",sortable:!0,formatter:"datetime"}],searchable:!0,sortable:!0,filterable:!0,paginated:!1,showRefresh:!0,showAdd:!1,showExport:!1,emptyMessage:"No skills found. Skills are created through the assistant chat.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}}class AssistantConversationView extends t.View{constructor(e={}){super({className:"assistant-conversation-view",...e}),this.model=e.model||new c.AssistantConversation(e.data||{}),this.template='\n <div class="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 <h4 class="mb-1 text-truncate">{{model.title}}</h4>\n <div class="text-muted small d-flex align-items-center gap-3 flex-wrap">\n <span><i class="bi bi-hash me-1"></i>Conversation #{{model.id}}</span>\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>Last active {{model.modified|relative}}</span>\n {{/model.modified}}\n {{#messageCount}}\n <span><i class="bi bi-chat-left-text me-1"></i>{{messageCount}} messages</span>\n {{/messageCount}}\n </div>\n </div>\n <div class="d-flex align-items-center gap-2 flex-shrink-0">\n <button class="btn btn-outline-danger btn-sm" data-action="delete-conversation">\n <i class="bi bi-trash me-1"></i>Delete\n </button>\n </div>\n </div>\n\n \x3c!-- Messages --\x3e\n <div class="flex-grow-1 border rounded" style="min-height: 200px;" data-container="chat-view"></div>\n </div>\n '}async onInit(){try{await this.model.fetch({params:{graph:"detail"}})}catch(a){}const e=this.model.get("messages")||[];this.messageCount=e.length;const t=this.model.get("user"),i=t?.id,s=AssistantView._collapseMessages(e.filter(e=>"tool_result"!==e.role).map(e=>this._transformMessage(e,t)));this.chatView=new n.ChatView({containerId:"chat-view",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:i,showInput:!1,showFileInput:!1,adapter:{fetch:async()=>s,addNote:async()=>({success:!1})}}),this.addChild(this.chatView)}_transformMessage(e,t){let i,s=e.content||"",a=e.blocks||[],n=e.tool_calls||[];if(n.length>0){const e=n.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!s&&e.length>0&&(s=e.join("\n\n")),n=n.filter(e=>"tool_use"===e.type)}if(0===a.length&&s.includes("assistant_block")){const e=AssistantView._parseBlocks(s);s=e.content,a=e.blocks}if("assistant"===e.role)i={name:"Mojo"};else if(e.author)i=e.author;else{const s=e.user||t,a=s?.avatar?.thumbnail||s?.avatar?.url||"";i={name:s?.display_name||"Unknown",id:s?.id,...a?{avatarUrl:a}:{}}}return{id:e.id,role:e.role||"user",author:i,content:s,timestamp:e.created||e.timestamp,blocks:a,tool_calls:n,_conversationId:this.model.get("id")}}async onActionDeleteConversation(){if(await a.Modal.confirm("Delete this conversation? This cannot be undone.","Delete Conversation",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await this.model.destroy(),this.getApp()?.toast?.success("Conversation deleted"),this.emit("item:deleted",{id:this.model.get("id")})}catch(e){this.getApp()?.toast?.error("Failed to delete conversation")}}}c.AssistantConversation.VIEW_CLASS=AssistantConversationView;class AssistantConversationTablePage extends n.TablePage{constructor(e={}){super({name:"assistant_conversations",pageName:"Conversation History",router:"admin/assistant/conversations",Collection:c.AssistantConversationList,viewDialogOptions:{header:!1,size:"xl"},defaultQuery:{sort:"-modified"},dayRangeFilter:{field:"modified",value:"7d"},searchPlaceholder:"Search title or user",columns:[{key:"id",label:"ID",width:"70px",sortable:!0,class:"text-muted"},{key:"user.username",label:"User",sortable:!0},{key:"title",label:"Title",sortable:!0,formatter:"truncate:80"},{key:"created",label:"Created",sortable:!0,formatter:"datetime"},{key:"modified",label:"Last Active",sortable:!0,formatter:"relative"}],selectable:!0,searchable:!0,sortable:!0,filterable:!1,paginated:!0,showRefresh:!0,showAdd:!1,showExport:!1,batchActions:[{label:"Delete",action:"delete",icon:"bi bi-trash",class:"text-danger"}],emptyMessage:"No conversations yet. Start a conversation with the assistant.",tableOptions:{striped:!0,bordered:!1,hover:!0,responsive:!1},...e})}onActionBatchDelete(){return this.batchAction({destroy:!0,label:"Delete"})}}const ct={global:{label:"Global",icon:"bi-globe",badge:"bg-primary",description:"Visible to all Mojo users"},user:{label:"Personal",icon:"bi-person",badge:"bg-info",description:"Private to you"},group:{label:"Group",icon:"bi-people",badge:"bg-warning text-dark",description:"Shared with your group"}};class AssistantMemoryPage extends e.Page{constructor(e={}){super({pageName:"Mojo Memory",className:"mojo-page assistant-memory-page",...e}),this._memories={},this._activeTier="user",this._loading=!0,this.template='\n <div class="container-fluid py-3">\n <div class="d-flex justify-content-between align-items-center mb-3">\n <div>\n <h4 class="mb-0"><i class="bi bi-lightbulb me-2"></i>Mojo Memory</h4>\n <p class="text-muted small mb-0">Facts and preferences Mojo has learned during conversations.</p>\n </div>\n <button class="btn btn-outline-secondary btn-sm" data-action="refresh">\n <i class="bi bi-arrow-clockwise me-1"></i>Refresh\n </button>\n </div>\n\n \x3c!-- Tier Tabs --\x3e\n <ul class="nav nav-tabs mb-3">\n {{#tierTabs}}\n <li class="nav-item">\n <a class="nav-link {{#active}}active{{/active}}" href="#"\n data-action="switch-tier" data-tier="{{key}}">\n <i class="bi {{icon}} me-1"></i>{{label}}\n {{#count}}<span class="badge bg-secondary ms-1">{{count}}</span>{{/count}}\n </a>\n </li>\n {{/tierTabs}}\n </ul>\n\n \x3c!-- Content --\x3e\n {{#loading|bool}}\n <div class="text-center py-5">\n <div class="spinner-border spinner-border-sm text-muted" role="status"></div>\n <div class="text-muted small mt-2">Loading memories...</div>\n </div>\n {{/loading|bool}}\n\n {{^loading|bool}}\n {{{tierContent}}}\n {{/loading|bool}}\n </div>\n '}async onEnter(){await this._loadMemories()}async onBeforeRender(){this.loading=this._loading,this.tierTabs=["user","global","group"].filter(e=>"group"!==e||this._memories[e]).map(e=>{const t=ct[e],i=this._memories[e]||{};return{key:e,label:t.label,icon:t.icon,count:Object.keys(i).length||0,active:e===this._activeTier}}),this.tierContent=this._buildTierContent(this._activeTier)}async _loadMemories(){this._loading=!0,this.render();try{const e=await t.rest.get("/api/assistant/memory"),i=e?.data?.data||e?.data||{};this._memories={global:i.global||{},user:i.user||{},group:i.group||{}}}catch(e){this._memories={global:{},user:{},group:{}},this.getApp()?.toast?.error("Failed to load memories")}this._loading=!1,this.render()}_buildTierContent(e){const t=this._memories[e]||{},i=Object.keys(t),s=ct[e];if(0===i.length)return`\n <div class="text-center py-5">\n <i class="bi ${s.icon} fs-1 text-muted"></i>\n <p class="text-muted mt-2 mb-0">No memories stored.</p>\n <p class="text-muted small">The assistant learns and stores memories during conversations.</p>\n </div>\n `;const a=i.map(i=>{const s=this._escapeHtml(i);return`\n <tr>\n <td class="align-middle" style="width: 30%; min-width: 150px;">\n <code class="small">${s}</code>\n </td>\n <td class="align-middle">${this._escapeHtml(String(t[i]))}</td>\n <td class="align-middle text-end" style="width: 60px;">\n <button class="btn btn-outline-danger btn-sm"\n data-action="delete-memory"\n data-tier="${e}"\n data-key="${s}"\n title="Delete this memory">\n <i class="bi bi-trash"></i>\n </button>\n </td>\n </tr>\n `}).join("");return`\n <div class="small text-muted mb-2">\n <i class="bi bi-info-circle me-1"></i>${s.description}\n </div>\n <div class="table-responsive">\n <table class="table table-striped table-hover mb-0">\n <thead>\n <tr>\n <th>Key</th>\n <th>Value</th>\n <th></th>\n </tr>\n </thead>\n <tbody>${a}</tbody>\n </table>\n </div>\n `}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}onActionSwitchTier(e,t){const i=t.dataset.tier||t.closest("[data-tier]")?.dataset.tier;i&&i!==this._activeTier&&(e.preventDefault(),this._activeTier=i,this.render())}async onActionRefresh(){await this._loadMemories()}async onActionDeleteMemory(e,i){const s=i.dataset.tier||i.closest("[data-tier]")?.dataset.tier,n=i.dataset.key||i.closest("[data-key]")?.dataset.key;if(s&&n&&await a.Modal.confirm(`Delete memory "${n}" from ${ct[s]?.label||s}?`,"Delete Memory",{confirmText:"Delete",confirmClass:"btn-danger"}))try{await t.rest.delete("/api/assistant/memory",{tier:s,key:n}),delete this._memories[s]?.[n],this.getApp()?.toast?.success("Memory deleted"),this.render()}catch(o){this.getApp()?.toast?.error("Failed to delete memory")}}}c.ScheduledTask.ADD_FORM=c.ScheduledTaskForms.create,c.ScheduledTask.EDIT_FORM=c.ScheduledTaskForms.edit;class AssistantPanelView extends t.View{constructor(e={}){super({className:"assistant-panel-view",...e}),this.app=e.app,this.ws=this.app?.ws,this.conversationId=e.conversationId||this.app?._assistantConversationId||null,this._wsHandlers={},this._messageIdCounter=0,this._hasMessages=!1,this._activePlans={},this._requestStartTime=null,this._showingHistory=!1}getTemplate(){return`\n <div class="assistant-panel-resize-handle" data-ref="resize-handle"></div>\n <div class="assistant-panel-layout">\n <div class="assistant-panel-header">\n <button class="assistant-panel-header-btn" data-action="toggle-history" type="button" title="Conversation history">\n <i class="bi bi-list"></i>\n </button>\n <span class="assistant-panel-title text-truncate" data-ref="panel-title">New conversation</span>\n <div class="d-flex gap-1 ms-auto">\n <button class="assistant-panel-header-btn" data-action="new-conversation" type="button" title="New conversation">\n <i class="bi bi-plus-lg"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="fullscreen" type="button" title="Open fullscreen">\n <i class="bi bi-arrows-fullscreen"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="pop-out" type="button" title="Open in popup window">\n <i class="bi bi-box-arrow-up-right"></i>\n </button>\n <button class="assistant-panel-header-btn" data-action="close-panel" type="button" title="Close">\n <i class="bi bi-x-lg"></i>\n </button>\n </div>\n </div>\n\n <div class="assistant-panel-history d-none" data-ref="history" data-container="conversation-list"></div>\n\n <div class="assistant-panel-chat" data-ref="chat-wrapper">\n <div class="assistant-welcome" data-ref="welcome">\n <div class="assistant-welcome-content">\n <div class="assistant-welcome-icon">\n <img src="https://mojo-verify.s3.amazonaws.com/signatures/14e7aab75c2749cb846f7d57298691ac/mojo_ai_7c0322e9.png" alt="Mojo">\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 </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-status d-none" data-ref="input-status"></div>\n <div class="assistant-input-box">\n <textarea class="assistant-input" placeholder="Message Mojo..." 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">Enter to send</span>\n </div>\n </div>\n </div>\n </div>\n `}async onInit(){this.conversations=new c.AssistantConversationList,this.conversations.params.user=this.app?.activeUser?.id,this.conversationListView=new AssistantConversationListView({containerId:"conversation-list",collection:this.conversations}),this.addChild(this.conversationListView),this.chatView=new n.ChatView({containerId:"chat-area",theme:"compact",messageViewClass:AssistantMessageView,currentUserId:this.app?.activeUser?.id,showFileInput:!1,showInput:!1,adapter:this._createAdapter()}),this.addChild(this.chatView);const e=this.chatView.addMessage.bind(this.chatView);this.chatView.addMessage=(t,i)=>{e(t,i),"assistant"===t.role&&(t.content||t.blocks?.length)&&(this.chatView.hideThinking(),this._setInputEnabled(!0))},this.conversationListView.on("conversation:select",e=>{this._onConversationSelect(e),this._toggleHistory(!1)}),this.conversationListView.on("conversation:new",()=>{this._onNewConversation(),this._toggleHistory(!1)}),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.conversationId&&(this._showChatArea(),await this.chatView.refresh()),this._updateConnectionStatus(),this._updateTitle(),this._setupResizeHandle()}_setupResizeHandle(){const e=this.element?.querySelector('[data-ref="resize-handle"]');if(!e)return;const t="mojo:assistant_panel_width",i=localStorage.getItem(t);if(i){const e=parseInt(i,10);if(e>=300&&e<=700){const t=document.getElementById("assistant-panel");t&&(t.style.width=e+"px")}}let s,a;const n=e=>{const t=s-e.clientX,i=Math.min(700,Math.max(300,a+t)),n=document.getElementById("assistant-panel");n&&(n.style.width=i+"px")},o=()=>{document.removeEventListener("mousemove",n),document.removeEventListener("mouseup",o),document.body.style.cursor="",document.body.style.userSelect="";const e=document.getElementById("assistant-panel");e&&localStorage.setItem(t,parseInt(e.style.width,10))};e.addEventListener("mousedown",e=>{e.preventDefault(),s=e.clientX;const t=document.getElementById("assistant-panel");a=t?t.offsetWidth:500,document.body.style.cursor="col-resize",document.body.style.userSelect="none",document.addEventListener("mousemove",n),document.addEventListener("mouseup",o)})}onActionToggleHistory(){this._toggleHistory(!this._showingHistory)}onActionNewConversation(){this._onNewConversation(),this._showingHistory&&this._toggleHistory(!1)}onActionClosePanel(){this.emit("panel:close")}onActionFullscreen(){this.emit("panel:fullscreen",{conversationId:this.conversationId})}onActionPopOut(){this.emit("panel:popout",{conversationId:this.conversationId})}onActionUseSuggestion(e,t){const i=t.dataset.text||t.closest("[data-text]")?.dataset.text;if(!i)return;const s=this.element.querySelector('[data-ref="input"]');s&&(s.value=i,this._autoResize(s)),this._sendMessage()}onActionSend(){this._sendMessage()}onActionStop(){this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Response cancelled.");const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}_toggleHistory(e){this._showingHistory=e;const t=this.element?.querySelector('[data-ref="history"]'),i=this.element?.querySelector('[data-ref="chat-wrapper"]'),s=this.element?.querySelector('[data-action="toggle-history"] i');t&&t.classList.toggle("d-none",!e),i&&i.classList.toggle("d-none",e),s&&(s.className=e?"bi bi-chat-dots":"bi bi-list"),e&&this.conversationListView.refresh()}_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())}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,t){const i=this.element?.querySelector('[data-ref="input"]'),s=this.element?.querySelector('[data-ref="send-btn"]'),a=this.element?.querySelector('[data-ref="stop-btn"]');i&&(i.disabled=!e),s&&s.classList.toggle("d-none",!e),a&&a.classList.toggle("d-none",e),this._setInputStatus(e?null:t),this._responseTimeout&&clearTimeout(this._responseTimeout),e?this._requestStartTime=null:this._responseTimeout=setTimeout(()=>this._onResponseTimeout(),6e4)}_setInputStatus(e){const t=this.element?.querySelector('[data-ref="input-status"]');t&&(e?(t.innerHTML=`${this._escapeHtml(e)} <span class="assistant-input-status-dismiss">Click to dismiss</span>`,t.classList.remove("d-none"),t._hasDismiss||(t._hasDismiss=!0,t.addEventListener("click",()=>{this.chatView.hideThinking(),this._setInputEnabled(!0);const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}))):(t.classList.add("d-none"),t.innerHTML=""))}_onResponseTimeout(){this._responseTimeout=null,this.chatView.hideThinking(),this._setInputEnabled(!0),this._showSystemMessage("Request timed out. Please try again.")}_updateTitle(e){const t=this.element?.querySelector('[data-ref="panel-title"]');t&&(t.textContent=e||(this.conversationId?"Mojo":"New conversation"))}_createAdapter(){return{fetch:async()=>{if(!this.conversationId)return[];try{const e=new c.AssistantConversation({id:this.conversationId});await e.fetch({graph:"detail"});const t=e.get("title")||e.get("summary");t&&this._updateTitle(t);const i=(e.get("messages")||[]).map(e=>this._transformMessage(e)).filter(Boolean);return AssistantView._collapseMessages(i)}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.chatView.showThinking("Thinking..."),this._requestStartTime=Date.now(),this._setInputEnabled(!1,"Waiting for response…"),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}),i=t?.data?.data||t?.data||t;i.conversation_id&&(this.conversationId=i.conversation_id,this.app._assistantConversationId=this.conversationId),i.response&&this.chatView.addMessage(this._transformMessage(i.response)),this._setInputEnabled(!0)}catch(i){this._handleAPIError(i)}return{success:!0}}}}_subscribeWS(){this.ws&&(this._wsHandlers={thinking:e=>this._onThinking(e),text:e=>this._onText(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_text",this._wsHandlers.text),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_text",this._wsHandlers.text),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_text":this._onText(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.app._assistantConversationId=this.conversationId)}_onThinking(e){this._isMyConversation(e)&&(this._adoptConversationId(e),this._showChatArea(),this.chatView.showThinking("Thinking..."),this._setInputEnabled(!1,"Assistant is thinking…"))}_onText(e){if(!this._isMyConversation(e))return;this._adoptConversationId(e),this._resetResponseTimeout();const t=this._transformMessage({id:e.message_id||"text-"+ ++this._messageIdCounter,role:"assistant",content:e.text||"",blocks:e.blocks||[],tool_calls:[],created:e.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});t&&(t.content||t.blocks?.length)&&this.chatView.addMessage(t)}_onToolCall(e){this._isMyConversation(e)&&(this.chatView.showThinking(`Using ${e.tool||e.name||"tool"}...`),this._resetResponseTimeout())}_resetResponseTimeout(){if(this._responseTimeout){if(this._requestStartTime&&Date.now()-this._requestStartTime>=3e5)return void this._onResponseTimeout();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 i=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.created||e.timestamp||/* @__PURE__ */(new Date).toISOString()});i&&(i.content||i.blocks?.length||i.tool_calls?.length)&&this.chatView.addMessage(i)}_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:"Mojo"},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 i=t.steps.find(t=>t.id===e.step_id);i&&(i.status=e.status,i.summary=e.summary)}const i=this.chatView.messageViews.get(`plan-${e.plan_id}`);i?.updateProgressStep&&i.updateProgressStep(e.plan_id,e.step_id,e.status,e.summary),this._resetResponseTimeout()}async _onConversationSelect(e){this.conversationId=e.id,this.app._assistantConversationId=this.conversationId,this.conversationListView.setActive(e.id),this._showChatArea(),this._updateTitle(e.model?.get("title")||e.model?.get("summary")),await this.chatView.refresh()}_onNewConversation(){this.conversationId=null,this.app._assistantConversationId=null,this.conversationListView.setActive(null),this.chatView.clearMessages(),this._setInputEnabled(!0),this._showWelcome(),this._updateTitle();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||"",i=e.blocks||[],s=e.tool_calls||[];if(s.length>0){s=s.map(e=>!e.type&&e.tool?{type:"tool_use",name:e.tool,input:e.input}:e);const e=s.filter(e=>"text"===e.type&&e.text).map(e=>e.text);!t&&e.length>0&&(t=e.join("\n\n")),s=s.filter(e=>"tool_use"===e.type).filter(e=>!AssistantView.INTERNAL_TOOLS.has(e.name))}if(0===i.length&&t.includes("assistant_block")){const e=AssistantView._parseBlocks(t);t=e.content,i=e.blocks}const a=this.app?.activeUser?.id;return{id:e.id,role:e.role||"user",author:"assistant"===e.role?{name:"Mojo"}: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:i,tool_calls:s,_conversationId:this.conversationId}}_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");e&&(this.ws?.isConnected?(e.className="status-dot connected",e.title="Connected",this._responseTimeout?this._setInputEnabled(!1,"Waiting for response…"):this._setInputEnabled(!0)):this.ws?.isReconnecting?(e.className="status-dot reconnecting",e.title="Reconnecting...",this._setInputEnabled(!1,"Reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)):(e.className="status-dot disconnected",e.title="Disconnected",this._setInputEnabled(!1,"Disconnected — reconnecting…"),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)))}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}focusInput(){const e=this.element?.querySelector('[data-ref="input"]');e&&e.focus()}async onBeforeDestroy(){this._unsubscribeWS(),this._responseTimeout&&(clearTimeout(this._responseTimeout),this._responseTimeout=null)}}class TicketNoteAdapter{constructor(e){this.ticketId=e,this.collection=new c.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&&"system_event"!==e.type&&(e._rawContent=e.content,e.content=await this._renderMarkdown(e.content))})),e}transform(e){const t=e.get("metadata")||{},i=t.action&&"object"==typeof t.action,s="context"===t.type&&t.references,a=e.get("note")||"",n=!e.get("user")&&a.startsWith("[LLM Agent]"),o=!e.get("user")&&(i||s||n),l={id:e.get("id"),type:e.get("user")?"user_comment":o?"llm_response":"system_event",role:o?"assistant":void 0,author:{id:e.get("user.id"),name:e.get("user.display_name")||(o?"AI Agent":"System"),avatarUrl:e.get("user.avatar.url")},timestamp:e.get("created"),content:e.get("note"),attachments:e.get("media")?[e.get("media")]:[]};return"status_change"===t.type&&(t.old_status||t.new_status)?(l.type="system_event",l.content=this._renderStatusChange(t.old_status,t.new_status)):t.action&&"object"==typeof t.action?l.action=t.action:"context"===t.type&&t.references&&(l.action={type:"context",references:t.references}),t.action_response&&(l.actionResponse=t.action_response),l._metadata=t,l}_renderStatusChange(e,t){const i=e=>e?`<span class="badge ${{new:"bg-info",open:"bg-success",in_progress:"bg-warning text-dark",pending:"bg-warning text-dark",resolved:"bg-success",qa:"bg-success",closed:"bg-secondary",ignored:"bg-secondary"}[e]||"bg-secondary"}">${String(e).replace(/[<>&"]/g,e=>({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"}[e])).replace(/_/g," ")}</span>`:'<span class="badge bg-secondary">unknown</span>';return`Status changed from ${i(e)} to ${i(t)}`}async addNote(e){const t=new c.TicketNote,i={parent:this.ticketId,note:e.text,media:e.files&&e.files.length>0?e.files[0].id:null};e.metadata&&(i.metadata=e.metadata);const s=await t.save(i);return s.success&&await this.collection.fetch(),s}async addActionResponse(e,t){return this.addNote({text:"approve"===t?"Approved":"Denied",metadata:{action_response:{handler:e.action.handler,action:t,context:e.action.context}}})}async _renderMarkdown(e){if(!e)return"";try{const i=await t.rest.post("/api/docit/render",{markdown:e}),s=i?.data?.data?.html||i?.data?.html;if(s)return s}catch(s){}const i=document.createElement("div");return i.textContent=e,`<pre style="white-space: pre-wrap;">${i.innerHTML}</pre>`}}function dt(e){return e?String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}const ht=/* @__PURE__ */new Set(["incident.RuleSet","incident.Incident","incident.Event","incident.Ticket","account.GeoLocatedIP"]),ut={"incident.rule_approval":{dot:"accent",label:"Rule"},"incident.block_confirm":{dot:"red",label:"Block"},"incident.rule_update":{dot:"green",label:"Update"},"incident.escalate":{dot:"amber",label:"Escalate"}};class ActionCardView extends t.View{constructor(e={}){super({className:"action-card-view",...e}),this.action=e.action,this.noteId=e.noteId,this.ticketStatus=e.ticketStatus}get isResolved(){return!!this.action?.resolved}get isContext(){return"context"===this.action?.type}get isClosed(){return"closed"===this.ticketStatus||"resolved"===this.ticketStatus}get handlerConfig(){return ut[this.action?.handler]||{dot:"accent",label:"Action"}}onBeforeRender(){const e=this.handlerConfig;this.dotClass=e.dot,this.isContext?this._buildContextTemplate():this.isResolved?this._buildResolvedTemplate():this._buildPendingTemplate()}onActionToggleCompact(){const e=this.element?.querySelector(".ac.resolved");e&&e.classList.toggle("compact")}_buildContextTemplate(){const e=(this.action.references||[]).filter(e=>ht.has(e.model)).map(e=>{const t=dt(e.label)||`${dt(e.model.split(".").pop())} #${dt(e.pk)}`;return`<span class="ac-ref" data-action="open-ref" data-model="${dt(e.model)}" data-pk="${dt(e.pk)}"><i class="bi bi-box-arrow-up-right"></i>${t}</span>`}).join("");this.template=`\n <div class="ac ac-context">\n <div class="ac-top">\n <span class="ac-dot context"></span>\n <span class="ac-label">Referenced models</span>\n </div>\n <div class="ac-detail">${e}</div>\n </div>\n `}_buildResolvedTemplate(){const e=this.action.resolution||"approved",t="approved"===e?"approved":"denied",i="approved"===e?"Approved":"Denied",s=this.action.context?.target;let a="";if(s&&ht.has(s.model)){const e=dt(this.action.context.label)||`${dt(s.model.split(".").pop())} #${dt(s.pk)}`;a=`<div class="ac-detail"><span class="ac-ref" data-action="open-ref" data-model="${dt(s.model)}" data-pk="${dt(s.pk)}"><i class="bi bi-box-arrow-up-right"></i>${e}</span></div>`}this.template=`\n <div class="ac resolved compact">\n <div class="ac-top" data-action="toggle-compact" title="Click to toggle">\n <span class="ac-dot ${this.dotClass}"></span>\n <span class="ac-label">${dt(this.action.label)||"Action"}</span>\n <span class="ac-badge ${t}">${i}</span>\n <i class="bi bi-chevron-down ac-chevron"></i>\n </div>\n ${a}\n </div>\n `}_buildPendingTemplate(){const e=this.action.context?.target;let t="";if(e&&ht.has(e.model)){const i=dt(this.action.context.label)||`${dt(e.model.split(".").pop())} #${dt(e.pk)}`;t=`<br><span class="ac-ref" data-action="open-ref" data-model="${dt(e.model)}" data-pk="${dt(e.pk)}"><i class="bi bi-box-arrow-up-right"></i>${i}</span>`}const i=dt(this.action.context?.detail),s=this.isClosed?" disabled":"";this.template=`\n <div class="ac">\n <div class="ac-top">\n <span class="ac-dot ${this.dotClass}"></span>\n <span class="ac-label">${dt(this.action.label)||"Action"}</span>\n </div>\n <div class="ac-detail">${i}${t}</div>\n <div class="ac-foot">\n <button class="btn-approve" data-action="approve"${s}>Approve</button>\n <button class="btn-deny" data-action="deny"${s}>Deny</button>\n </div>\n </div>\n `}async onActionOpenRef(e,t){const i=t.dataset.model,s=t.dataset.pk;if(!ht.has(i)||!/^\d+$/.test(s))return;const n=this.getApp(),o=n?.getModelByRef(i);o?.VIEW_CLASS&&a.Modal.showModelById(o,s)}onActionApprove(){this.emit("action:respond",{noteId:this.noteId,action:"approve",handler:this.action.handler,context:this.action.context})}onActionDeny(){this.emit("action:respond",{noteId:this.noteId,action:"deny",handler:this.action.handler,context:this.action.context})}}const mt=["new","open","in_progress","pending","resolved","qa","closed","ignored"],pt={new:"pill-new",open:"pill-open",in_progress:"pill-prog",pending:"pill-prog",resolved:"pill-resolved",qa:"pill-open",closed:"pill-closed",ignored:"pill-closed"},bt=[{value:10,label:"P10 — Critical"},{value:9,label:"P9 — Severe"},{value:8,label:"P8 — High"},{value:7,label:"P7 — Elevated"},{value:5,label:"P5 — Normal"},{value:3,label:"P3 — Low"},{value:1,label:"P1 — Info"}];class TicketPanelView extends t.View{constructor(e={}){super({className:"ticket-panel-view",...e}),this.model=e.model||new c.Ticket(e.data||{})}onBeforeRender(){const e=this.model.get("status")||"new";this.statusPill=pt[e]||"pill-closed",this.statusLabel=e.replace(/_/g," "),this.priorityLabel=`P${this.model.get("priority")||5}`,this.assigneeName=this.model.get("assignee.display_name")||this.model.get("assignee")||"Unassigned",this.categoryLabel=this.model.get("category")||"ticket",this.groupName=this.model.get("group.name")||this.model.get("group")||"None",this.hasDescription=!!this.model.get("description"),this.hasIncident=!(!this.model.get("incident")||"object"!=typeof this.model.get("incident")||!this.model.get("incident").id),this.priorityColor=(this.model.get("priority")||5)>=7?"var(--bs-danger)":"var(--bs-secondary-color)",this.template=`\n <style>\n .ticket-panel-view { height: 100%; display: flex; flex-direction: column; }\n .tp-header { padding: 10px 16px 6px; border-bottom: 1px solid var(--bs-border-color-translucent); flex-shrink: 0; }\n .tp-title-row { display: flex; align-items: flex-start; gap: 6px; }\n .tp-title { font-size: 0.88rem; font-weight: 600; color: var(--bs-emphasis-color); line-height: 1.3; flex: 1; min-width: 0; cursor: ${this.hasDescription?"pointer":"default"}; transition: color 0.12s; }\n ${this.hasDescription?".tp-title:hover { color: var(--bs-primary); }":""}\n .tp-title i { font-size: 0.6rem; vertical-align: middle; margin-left: 3px; opacity: 0; transition: opacity 0.12s; }\n .tp-title:hover i { opacity: 0.6; }\n .tp-btns { display: flex; gap: 2px; align-items: center; flex-shrink: 0; }\n .tp-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: none; color: var(--bs-secondary-color); border-radius: 6px; cursor: pointer; font-size: 0.85rem; transition: all 0.12s; }\n .tp-btn:hover { background: var(--bs-tertiary-bg); color: var(--bs-body-color); }\n .tp-sub { display: flex; align-items: center; gap: 6px; margin-top: 3px; }\n .tp-id { font-family: var(--bs-font-monospace); font-size: 0.7rem; color: var(--bs-secondary-color); }\n .tp-pill { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 0.66rem; font-weight: 500; cursor: pointer; transition: filter 0.12s; }\n .tp-pill:hover { filter: brightness(0.9); }\n .tp-pill-new { background: rgba(var(--bs-info-rgb), 0.1); color: var(--bs-info); }\n .tp-pill-open { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tp-pill-prog { background: rgba(var(--bs-warning-rgb), 0.1); color: var(--bs-warning); }\n .tp-pill-closed { background: var(--bs-secondary-bg); color: var(--bs-secondary-color); }\n .tp-pill-resolved { background: rgba(var(--bs-success-rgb), 0.1); color: var(--bs-success); }\n .tp-time { font-size: 0.66rem; color: var(--bs-secondary-color); }\n .tp-desc-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; color: var(--bs-primary); cursor: pointer; padding: 2px 8px; border-radius: 5px; background: rgba(var(--bs-primary-rgb), 0.08); transition: all 0.12s; }\n .tp-desc-chip:hover { background: rgba(var(--bs-primary-rgb), 0.16); }\n .tp-desc-chip i { font-size: 0.7rem; }\n .tp-meta { display: flex; align-items: center; gap: 3px; margin-top: 5px; }\n .tp-fields { display: inline-flex; align-items: center; gap: 2px; flex-wrap: wrap; }\n .tp-field { display: inline-flex; align-items: center; gap: 4px; font-size: 0.72rem; color: var(--bs-secondary-color); padding: 2px 7px; border-radius: 5px; cursor: pointer; transition: all 0.12s; border: 1px solid transparent; }\n .tp-field:hover { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }\n .tp-field i { font-size: 0.68rem; }\n .tp-field .caret { font-size: 0.55rem; opacity: 0; transition: opacity 0.12s; margin-left: -1px; }\n .tp-field:hover .caret { opacity: 0.6; }\n .tp-sep { color: var(--bs-secondary-color); font-size: 0.6rem; margin: 0 1px; user-select: none; }\n\n .tp-linked { display: flex; align-items: center; gap: 6px; padding: 7px 16px; border-bottom: 1px solid var(--bs-border-color-translucent); font-size: 0.75rem; color: var(--bs-secondary-color); flex-shrink: 0; }\n .tp-linked i { color: var(--bs-warning); font-size: 0.72rem; }\n .tp-linked a { color: var(--bs-primary); text-decoration: none; font-weight: 500; }\n .tp-linked a:hover { text-decoration: underline; }\n .tp-linked .lpill { font-size: 0.62rem; padding: 0 5px; border-radius: 3px; background: rgba(var(--bs-warning-rgb), 0.1); color: var(--bs-warning); font-weight: 500; }\n\n .tp-conv { flex: 1; overflow-y: auto; min-height: 0; }\n .tp-conv .chat-container { border: none; border-radius: 0; }\n .tp-conv .chat-messages { padding: 6px 0; }\n .tp-conv .chat-input-wrapper { display: none; }\n .tp-conv .message-item { position: relative; }\n .tp-conv .tp-edit-btn { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; display: none; align-items: center; justify-content: center; border: none; background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); border-radius: 5px; cursor: pointer; font-size: 0.72rem; transition: all 0.12s; }\n .tp-conv .tp-edit-btn:hover { background: var(--bs-secondary-bg); color: var(--bs-body-color); }\n .tp-conv .message-item:hover .tp-edit-btn { display: flex; }\n .tp-conv .message-text { white-space: normal; }\n .tp-conv .message-text p { margin-bottom: 6px; }\n .tp-conv .message-text p:last-child { margin-bottom: 0; }\n .tp-conv .message-text h1,\n .tp-conv .message-text h2,\n .tp-conv .message-text h3,\n .tp-conv .message-text h4,\n .tp-conv .message-text h5,\n .tp-conv .message-text h6 { font-weight: 600; margin-top: 10px; margin-bottom: 4px; line-height: 1.3; }\n .tp-conv .message-text h1 { font-size: 1.05rem; }\n .tp-conv .message-text h2 { font-size: 1rem; }\n .tp-conv .message-text h3 { font-size: 0.95rem; }\n .tp-conv .message-text h4,\n .tp-conv .message-text h5,\n .tp-conv .message-text h6 { font-size: 0.88rem; }\n .tp-conv .message-text h1:first-child,\n .tp-conv .message-text h2:first-child,\n .tp-conv .message-text h3:first-child { margin-top: 0; }\n .tp-conv .message-text hr { margin: 4px 0; opacity: 0.15; }\n .tp-conv .message-text ul,\n .tp-conv .message-text ol { padding-left: 20px; margin-top: 2px; margin-bottom: 6px; }\n .tp-conv .message-text li { margin-bottom: 2px; }\n .tp-conv .message-text pre { background: var(--bs-tertiary-bg); border-radius: 6px; padding: 10px 14px; margin: 8px 0; font-size: 0.8rem; overflow-x: auto; }\n .tp-conv .message-text code { font-size: 0.85em; padding: 1px 5px; background: var(--bs-tertiary-bg); border-radius: 4px; }\n .tp-conv .message-text pre code { padding: 0; background: none; }\n .tp-conv .message-text table { width: 100%; margin: 8px 0; border-collapse: collapse; font-size: 0.82rem; }\n .tp-conv .message-text th,\n .tp-conv .message-text td { padding: 5px 8px; border: 1px solid var(--bs-border-color); text-align: left; }\n .tp-conv .message-text th { background: var(--bs-tertiary-bg); font-weight: 600; }\n .tp-conv .message-text blockquote { margin: 6px 0; padding: 4px 12px; border-left: 3px solid var(--bs-border-color); color: var(--bs-secondary-color); }\n\n .tp-action-area { padding: 0; }\n\n .tp-input { border-top: 1px solid var(--bs-border-color-translucent); padding: 10px 16px; flex-shrink: 0; }\n .tp-input-wrap { display: flex; align-items: flex-end; gap: 8px; }\n .tp-input textarea { flex: 1; font-size: 0.8rem; border: 1px solid var(--bs-border-color); border-radius: 8px; padding: 7px 10px; resize: none; background: var(--bs-body-bg); color: var(--bs-body-color); outline: none; transition: border-color 0.15s; font-family: inherit; max-height: 160px; overflow-y: auto; }\n .tp-input textarea:focus { border-color: var(--bs-primary); }\n .tp-input textarea::placeholder { color: var(--bs-secondary-color); }\n .tp-send-btn { width: 32px; height: 32px; border-radius: 8px; border: none; background: var(--bs-primary); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; font-size: 0.85rem; transition: filter 0.12s; }\n .tp-send-btn:hover { filter: brightness(1.1); }\n .tp-input-hint { display: flex; align-items: center; gap: 4px; margin-top: 4px; font-size: 0.7rem; color: var(--bs-secondary-color); }\n .tp-input-hint i { font-size: 0.75rem; }\n .tp-input.tp-dragover { background: rgba(var(--bs-primary-rgb), 0.04); }\n .tp-input.tp-dragover textarea { border-color: var(--bs-primary); border-style: dashed; }\n .tp-attachments { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 0; }\n .tp-attach-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: 6px; font-size: 0.72rem; color: var(--bs-body-color); }\n .tp-attach-chip i { font-size: 0.68rem; }\n .tp-attach-chip .remove { cursor: pointer; color: var(--bs-secondary-color); margin-left: 2px; }\n .tp-attach-chip .remove:hover { color: var(--bs-danger); }\n .tp-attach-chip.uploading { opacity: 0.6; }\n .tp-conv .message-content.tp-collapsed { max-height: var(--tp-collapse-h, 52px); overflow: hidden; position: relative; -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); mask-image: linear-gradient(to bottom, black 60%, transparent 100%); }\n .tp-conv .message-item:has(.tp-show-more) { flex-wrap: wrap; }\n .tp-show-more { background: none; border: none; padding: 2px 0; margin: -2px 0 0 48px; font-size: 0.7rem; color: var(--bs-secondary-color); cursor: pointer; text-align: left; width: calc(100% - 48px); }\n .tp-show-more:hover { color: var(--bs-body-color); }\n </style>\n\n <div class="tp-header">\n <div class="tp-title-row">\n <div class="tp-title" ${this.hasDescription?'data-action="show-description"':""} ${this.hasDescription?'title="View full description"':""}>\n {{model.title}}${this.hasDescription?' <i class="bi bi-arrow-up-right-square"></i>':""}\n </div>\n <div class="tp-btns">\n <div data-container="panel-menu"></div>\n <button class="tp-btn" data-action="close" title="Close panel"><i class="bi bi-x-lg"></i></button>\n </div>\n </div>\n <div class="tp-sub">\n <span class="tp-id">#{{model.id}}</span>\n <span class="tp-pill tp-${this.statusPill}" data-action="change-status" title="Change status">{{statusLabel}} <i class="bi bi-chevron-down" style="font-size:0.5rem;"></i></span>\n <span class="tp-time"><i class="bi bi-clock"></i> {{model.created|relative}}</span>\n <span class="tp-desc-chip" data-action="show-description" title="${this.hasDescription?"View / edit description":"Add description"}"><i class="bi bi-file-text"></i> ${this.hasDescription?"Description":"Add description"}</span>\n </div>\n <div class="tp-meta">\n <div class="tp-fields">\n <span class="tp-field" data-action="change-priority" title="Change priority">\n <i class="bi bi-flag-fill" style="color:${this.priorityColor};"></i>{{priorityLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-assignee" title="Assign">\n <i class="bi bi-person"></i>{{assigneeName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-category" title="Change category">\n <i class="bi bi-tag"></i>{{categoryLabel}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n <span class="tp-sep">&middot;</span>\n <span class="tp-field" data-action="change-group" title="Change group">\n <i class="bi bi-people"></i>{{groupName}}\n <i class="bi bi-chevron-down caret"></i>\n </span>\n </div>\n </div>\n </div>\n\n {{#hasIncident|bool}}\n <div class="tp-linked">\n <i class="bi bi-exclamation-triangle-fill"></i>\n <a href="#" data-action="view-incident">Incident #{{model.incident.id}}</a>\n {{#model.incident.status}}<span class="lpill">{{model.incident.status}}</span>{{/model.incident.status}}\n {{#model.incident.event_count}}<span>&middot; {{model.incident.event_count}} events</span>{{/model.incident.event_count}}\n </div>\n {{/hasIncident|bool}}\n\n <div class="tp-conv">\n <div data-container="chat-area"></div>\n </div>\n\n <div class="tp-input" data-ref="drop-zone">\n <div class="tp-attachments" data-ref="attachments"></div>\n <div class="tp-input-wrap">\n <textarea rows="2" placeholder="Add a note..." data-ref="note-textarea"></textarea>\n <button class="tp-send-btn" data-action="send-note" title="Send"><i class="bi bi-arrow-up"></i></button>\n </div>\n <div class="tp-input-hint">\n <i class="bi bi-paperclip"></i> Drag &amp; drop files to attach\n </div>\n </div>\n `}async onInit(){this.adapter=new TicketNoteAdapter(this.model.get("id")),this.chatView=new n.ChatView({containerId:"chat-area",adapter:this.adapter,theme:"compact",currentUserId:this._getCurrentUserId(),showInput:!1}),this.addChild(this.chatView);const t=new e.ContextMenu({containerId:"panel-menu",className:"context-menu-view header-menu-absolute",context:this.model,config:{icon:"bi-three-dots",btnClass:"tp-btn",items:[{label:"Ask AI",action:"ask-ai",icon:"bi-robot"},{type:"divider"},{label:"Edit Ticket",action:"edit-ticket",icon:"bi-pencil"},{label:"Refresh Notes",action:"refresh-notes",icon:"bi-arrow-clockwise"},{type:"divider"},{label:"Close Window",action:"close",icon:"bi-x-lg"}]}});this.addChild(t)}async onAfterRender(){this._setupTextarea(),this._setupDragDrop(),await new Promise(e=>setTimeout(e,0)),await this._loadActionCards(),this._addEditButtons(),this._setupCollapsible()}async _loadActionCards(){this._cleanupActionCards();const e=this.chatView.messages||[];if(e.length)for(const t of e){if(!t.action||"object"!=typeof t.action)continue;const e=this.chatView.messageViews.get(t.id);if(!e?.element)continue;const i=new ActionCardView({action:t.action,noteId:t.id,ticketStatus:this.model.get("status")});"context"===t.action.type||t.action.resolved||i.on("action:respond",e=>this._handleActionResponse(e)),this.addChild(i),await i.render(),e.element.after(i.element)}}_cleanupActionCards(){for(const e in this.children){const t=this.children[e];t instanceof ActionCardView&&this.removeChild(t)}}async _handleActionResponse(e){const t={action:{handler:e.handler,context:e.context}},i=this.getApp();i?.showLoading();try{await this.adapter.addActionResponse(t,e.action),await this.model.fetch(),await this.chatView.refresh(),this.render()}finally{i?.hideLoading()}}_getCurrentUserId(){const e=this.getApp();return e?.activeUser?.id||e?.getActiveUser?.()?.id||null}onActionClose(){this.emit("panel:close")}async onActionSendNote(){const e=this.element?.querySelector('[data-ref="note-textarea"]'),t=e?.value?.trim(),i=this._stagedFiles||[];(t||i.length)&&(e.value="",e.style.height="",this._stagedFiles=[],this._renderAttachments(),await this.adapter.addNote({text:t||"",files:i}),await this.chatView.refresh(),await this._afterChatRefresh())}_setupTextarea(){const e=this.element?.querySelector('[data-ref="note-textarea"]');if(!e)return;const t=(t,i=t.length)=>{const s=e.selectionStart,a=e.selectionEnd;e.setRangeText(t,s,a,"end"),e.selectionStart=e.selectionEnd=s+i,e.dispatchEvent(new Event("input")),e.scrollTop=e.scrollHeight},i=t=>{const i=e.selectionStart,s=e.selectionEnd,a=e.value.substring(i,s);a.startsWith(t)&&a.endsWith(t)?(e.setRangeText(a.slice(t.length,-t.length),i,s,"end"),e.selectionStart=i,e.selectionEnd=s-2*t.length):(e.setRangeText(t+a+t,i,s,"end"),e.selectionStart=i+t.length,e.selectionEnd=s+t.length)},s=t=>{const i=e.value.substring(0,t).lastIndexOf("\n")+1;return{start:i,text:e.value.substring(i,t)}},a=t=>(e.value.substring(0,t).match(/^```/gm)||[]).length%2==1;e.addEventListener("keydown",n=>{const o=n.ctrlKey||n.metaKey;if("Enter"===n.key&&!n.shiftKey&&!o)return n.preventDefault(),void this.onActionSendNote();if("Enter"===n.key&&n.shiftKey){const{start:i,text:a}=s(e.selectionStart),o=a.match(/^(\s*)([-*]|\d+\.)\s/);if(o){n.preventDefault();const s=o[1],l=o[2];if(a.trim()===l)e.setRangeText("",i,e.selectionStart,"end");else{const e=/^\d+\./.test(l)?`${parseInt(l)+1}.`:l;t(`\n${s}${e} `)}return}}if("`"===n.key&&!o){const i=e.selectionStart;if(e.value.substring(0,i).endsWith("``")&&!a(i-2))return n.preventDefault(),void t("`\n\n```",2)}if("Tab"!==n.key||!a(e.selectionStart))return o&&"b"===n.key?(n.preventDefault(),void i("**")):o&&"i"===n.key?(n.preventDefault(),void i("*")):void 0;if(n.preventDefault(),n.shiftKey){const{start:t,text:i}=s(e.selectionStart),a=i.replace(/^ {1,2}/,"");e.setRangeText(a,t,t+i.length,"end"),e.selectionStart=e.selectionEnd=t+a.length}else t(" ")});const n={"(":")","[":"]",'"':'"'};e.addEventListener("keydown",i=>{if(i.ctrlKey||i.metaKey||i.altKey)return;const s=n[i.key];if(!s)return;const a=e.selectionStart,o=e.selectionEnd;if(a!==o){i.preventDefault();const t=e.value.substring(a,o);e.setRangeText(i.key+t+s,a,o,"end"),e.selectionStart=a+1,e.selectionEnd=o+1}else i.preventDefault(),t(i.key+s,1)}),e.addEventListener("input",()=>{e.style.height="",e.style.height=Math.min(e.scrollHeight,160)+"px"})}_setupDragDrop(){const e=this.element?.querySelector('[data-ref="drop-zone"]');if(!e)return;this._stagedFiles=this._stagedFiles||[];let t=0;e.addEventListener("dragenter",i=>{i.preventDefault(),t++,e.classList.add("tp-dragover")}),e.addEventListener("dragleave",()=>{t--,t<=0&&(t=0,e.classList.remove("tp-dragover"))}),e.addEventListener("dragover",e=>e.preventDefault()),e.addEventListener("drop",async i=>{i.preventDefault(),t=0,e.classList.remove("tp-dragover");const s=Array.from(i.dataTransfer?.files||[]);if(s.length)for(const e of s){const t=Date.now()+Math.random();this._addAttachChip(t,e.name,!0);try{const i=new a.File;await i.upload({file:e,showToast:!1}),this._stagedFiles.push(i),this._updateAttachChip(t,i.get("name")||e.name,i)}catch(n){console.error("File upload failed:",n),this._removeAttachChip(t),this.getApp()?.toast?.error?.("Upload failed: "+e.name)}}})}_addAttachChip(e,t,i){const s=this.element?.querySelector('[data-ref="attachments"]');if(!s)return;const a=document.createElement("span");a.className="tp-attach-chip"+(i?" uploading":""),a.dataset.chipId=e,a.innerHTML=`<i class="bi bi-paperclip"></i>${this._escapeHtml(t)}`+(i?"":'<span class="remove" data-remove="1"><i class="bi bi-x"></i></span>'),i||a.querySelector(".remove").addEventListener("click",()=>{this._removeAttachChip(e)}),s.appendChild(a)}_updateAttachChip(e,t,i){const s=this.element?.querySelector(`[data-chip-id="${e}"]`);s&&(s.classList.remove("uploading"),s.innerHTML=`<i class="bi bi-paperclip"></i>${this._escapeHtml(t)}<span class="remove" data-remove="1"><i class="bi bi-x"></i></span>`,s.querySelector(".remove").addEventListener("click",()=>{this._stagedFiles=(this._stagedFiles||[]).filter(e=>e!==i),s.remove()}))}_removeAttachChip(e){const t=this.element?.querySelector(`[data-chip-id="${e}"]`);t&&t.remove()}_renderAttachments(){const e=this.element?.querySelector('[data-ref="attachments"]');e&&(e.innerHTML="")}_escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}_addEditButtons(){const e=this._getCurrentUserId();if(!e||!this.chatView?.messageViews)return;const t=new Map((this.chatView.messages||[]).map(e=>[e.id,e]));this.chatView.messageViews.forEach((i,s)=>{const a=t.get(s),n=i?.element?.querySelector(".message-item");if(!n)return;if(n.querySelectorAll(".tp-edit-btn").forEach(e=>e.remove()),!a||a.author?.id!==e)return;const o=document.createElement("button");o.className="tp-edit-btn",o.title="Edit note",o.innerHTML='<i class="bi bi-pencil"></i>',o.addEventListener("click",e=>{e.stopPropagation(),this._editNote(a)}),n.appendChild(o)})}_setupCollapsible(){const e=this.element?.querySelector('[data-container="chat-area"]');e&&(e.querySelectorAll(".tp-show-more").forEach(e=>e.remove()),e.querySelectorAll(".tp-collapsed").forEach(e=>e.classList.remove("tp-collapsed")),setTimeout(()=>{e.querySelectorAll(".message-content").forEach(e=>{if(e.scrollHeight<=52)return;e.classList.add("tp-collapsed"),e.style.setProperty("--tp-collapse-h","52px");const t=document.createElement("button");t.className="tp-show-more",t.textContent="Show more",t.addEventListener("click",()=>{const i=e.classList.toggle("tp-collapsed");t.textContent=i?"Show more":"Show less"}),e.after(t)})},150))}async _editNote(e){const t=Object.keys(e._metadata||{}).length?JSON.stringify(e._metadata,null,2):"",i=await a.Modal.form({title:"Edit Note",icon:"bi-pencil",size:"lg",fields:[{type:"tabset",tabs:[{label:"Note",fields:[{name:"note",type:"textarea",label:"Note",required:!0,cols:12,rows:8,value:e._rawContent||e.content}]},{label:"Metadata",fields:[{name:"metadata_json",type:"json",label:"Metadata (JSON)",cols:12,rows:10,value:t,help:'Action metadata — e.g. { "action": { "handler": "incident.rule_approval", "label": "...", "context": { ... } } }'}]}]}]});if(!i)return;const s=new c.TicketNote({id:e.id}),n={note:i.note};i.metadata_json&&(n.metadata="string"==typeof i.metadata_json?JSON.parse(i.metadata_json):i.metadata_json),await s.save(n),await this.chatView.refresh(),await this._afterChatRefresh()}async _saveAndSync(e){await this.model.save(e),await this.model.fetch(),this.chatView&&(await this.chatView.refresh(),await this._afterChatRefresh())}async onActionChangeStatus(e){const t=mt.map(e=>({label:e.replace(/_/g," "),value:e,active:e===this.model.get("status")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({status:i}),this.render())}async onActionChangePriority(e){const t=bt.map(e=>({label:e.label,value:e.value,active:e.value===this.model.get("priority")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({priority:parseInt(i)}),this.render())}async onActionChangeAssignee(){const e=await a.Modal.form({title:"Assign User",icon:"bi-person-plus",size:"sm",fields:[{name:"assignee",type:"collection",label:"User",Collection:t.UserList,labelField:"display_name",valueField:"id",required:!0,cols:12,value:this.model.get("assignee")}]});e&&(await this._saveAndSync({assignee:e.assignee}),this.render())}async onActionChangeCategory(e){const t=Object.entries(c.TicketCategories).map(([e,t])=>({label:t,value:e,active:e===this.model.get("category")})),i=await this._showInlineSelect(t,e);i&&(await this._saveAndSync({category:i}),this.render())}async onActionChangeGroup(){const e=await a.Modal.form({title:"Change Group",icon:"bi-people",size:"sm",fields:[{name:"group",type:"collection",label:"Group",Collection:t.GroupList,labelField:"name",valueField:"id",required:!1,cols:12,value:this.model.get("group")}]});e&&(await this._saveAndSync({group:e.group}),this.render())}async onActionShowDescription(){const e=this.model.get("description")||"";if(!e)return this._editDescription();let i=!1,s="";try{const a=await t.rest.post("/api/docit/render",{markdown:e});s=a?.data?.data?.html||a?.data?.html||"",i=!!s}catch(n){}if(!i){const t=document.createElement("div");t.textContent=e,s=`<pre style="white-space:pre-wrap;">${t.innerHTML}</pre>`}"edit"===await a.Modal.dialog({title:`Ticket #${this.model.get("id")} — Description`,body:`<div style="font-size:0.85rem; line-height:1.65;">${s}</div>`,size:"lg",buttons:[{text:"Edit",class:"btn-primary",value:"edit"},{text:"Close",class:"btn-secondary",value:"close"}]})&&await this._editDescription()}async _editDescription(){const e=this.model.get("id"),t=`\n <div class="tp-desc-edit">\n <textarea data-ref="desc-textarea" rows="16" placeholder="Description (markdown supported)..."\n style="width:100%; font-family: var(--bs-font-monospace); font-size: 0.85rem; padding: 10px 12px; border: 1px solid var(--bs-border-color); border-radius: 8px; background: var(--bs-body-bg); color: var(--bs-body-color); resize: vertical; outline: none;">${(this.model.get("description")||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</textarea>\n <div class="text-muted small mt-1">\n Markdown supported. Cmd/Ctrl+B = bold · Cmd/Ctrl+I = italic · Shift+Enter continues lists · \`\`\` opens a code block\n </div>\n </div>\n `,i=(async()=>{for(let e=0;e<20;e++){await new Promise(e=>setTimeout(e,50));const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');if(e)return this._wireMarkdownTextarea(e),e}return null})(),s=await a.Modal.dialog({title:`Ticket #${e} — Edit Description`,body:t,size:"lg",buttons:[{text:"Cancel",class:"btn-secondary",value:null},{text:"Save",class:"btn-primary",handler:()=>{const e=document.querySelector('.modal.show [data-ref="desc-textarea"]');return e?e.value:null}}]});await i,null!=s&&(await this._saveAndSync({description:s}),this.render())}_wireMarkdownTextarea(e){const t=(t,i=t.length)=>{const s=e.selectionStart,a=e.selectionEnd;e.setRangeText(t,s,a,"end"),e.selectionStart=e.selectionEnd=s+i,e.dispatchEvent(new Event("input"))},i=t=>{const i=e.selectionStart,s=e.selectionEnd,a=e.value.substring(i,s);a.startsWith(t)&&a.endsWith(t)?(e.setRangeText(a.slice(t.length,-t.length),i,s,"end"),e.selectionStart=i,e.selectionEnd=s-2*t.length):(e.setRangeText(t+a+t,i,s,"end"),e.selectionStart=i+t.length,e.selectionEnd=s+t.length)},s=t=>{const i=e.value.substring(0,t).lastIndexOf("\n")+1;return{start:i,text:e.value.substring(i,t)}},a=t=>(e.value.substring(0,t).match(/^```/gm)||[]).length%2==1;e.addEventListener("keydown",n=>{const o=n.ctrlKey||n.metaKey;if("Enter"===n.key&&n.shiftKey){const{start:i,text:a}=s(e.selectionStart),o=a.match(/^(\s*)([-*]|\d+\.)\s/);if(o){n.preventDefault();const s=o[1],l=o[2];if(a.trim()===l)e.setRangeText("",i,e.selectionStart,"end");else{const e=/^\d+\./.test(l)?`${parseInt(l)+1}.`:l;t(`\n${s}${e} `)}return}}if("`"===n.key&&!o){const i=e.selectionStart;if(e.value.substring(0,i).endsWith("``")&&!a(i-2))return n.preventDefault(),void t("`\n\n```",2)}if("Tab"!==n.key||!a(e.selectionStart))return o&&"b"===n.key?(n.preventDefault(),void i("**")):o&&"i"===n.key?(n.preventDefault(),void i("*")):void 0;if(n.preventDefault(),n.shiftKey){const{start:t,text:i}=s(e.selectionStart),a=i.replace(/^ {1,2}/,"");e.setRangeText(a,t,t+i.length,"end"),e.selectionStart=e.selectionEnd=t+a.length}else t(" ")});const n={"(":")","[":"]",'"':'"'};e.addEventListener("keydown",i=>{if(i.ctrlKey||i.metaKey||i.altKey)return;const s=n[i.key];if(!s)return;const a=e.selectionStart,o=e.selectionEnd;if(a!==o){i.preventDefault();const t=e.value.substring(a,o);e.setRangeText(i.key+t+s,a,o,"end"),e.selectionStart=a+1,e.selectionEnd=o+1}else i.preventDefault(),t(i.key+s,1)})}async onActionViewIncident(){const e=this.model.get("incident");e?.id&&a.Modal.showModel(new c.Incident({id:e.id}))}async onActionEditTicket(){await a.Modal.modelForm({title:`Edit Ticket #${this.model.get("id")}`,model:this.model,size:"lg",fields:c.TicketForms.edit.fields})&&this.render()}async onActionRefreshNotes(){await this.chatView.refresh(),await this._afterChatRefresh(),this.getApp()?.toast?.success("Notes refreshed")}async onActionAskAi(){await Z(this,"incident.Ticket")}async _showInlineSelect(t,i){return new Promise(s=>{let a=!1;const n=new e.ContextMenu({config:{items:t.map((e,t)=>({label:e.label,action:`pick-${t}`,class:e.active?"fw-bold":"",handler:()=>{a=!0,this.removeChild(n),s(e.value)}}))}}),o=n.closeDropdown.bind(n);n.closeDropdown=()=>{o(),a||(this.removeChild(n),s(null))},this.addChild(n),n.openAt(i.clientX,i.clientY)})}async _afterChatRefresh(){await new Promise(e=>setTimeout(e,0)),await this._loadActionCards(),this._addEditButtons(),this._setupCollapsible()}async setTicket(e){this.model=e,this.adapter=new TicketNoteAdapter(e.get("id")),this.chatView.adapter=this.adapter,this.chatView.clearMessages(),await this.render(),await this.chatView.refresh(),await this._afterChatRefresh()}}function gt(e,i=!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/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/shortlinks/links",ShortLinkTablePage,{permissions:["manage_shortlinks"]}),e.registerPage("system/shortlinks/clicks",ShortLinkClickTablePage,{permissions:["manage_shortlinks"]}),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/messaging/public-messages",PublicMessageTablePage,{permissions:["view_support","support","security"]}),e.registerPage("system/incident-dashboard",SecurityDashboardPage,{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"]}),e.registerPage("system/assistant/skills",AssistantSkillTablePage,{permissions:["view_admin","assistant"]}),e.registerPage("system/assistant/conversations",AssistantConversationTablePage,{permissions:["view_admin","assistant"]}),e.registerPage("system/assistant/memory",AssistantMemoryPage,{permissions:["view_admin","assistant"]}),i&&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:"Security Dashboard",route:"?page=system/incident-dashboard",icon:"bi-shield-check",permissions:["view_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:["jobs","view_jobs","manage_jobs"]},{text:"Runners",route:"?page=system/jobs/runners",icon:"bi-cpu",permissions:["jobs","view_jobs","manage_jobs"]},{text:"Jobs",route:"?page=system/jobs/list",icon:"bi-list-task",permissions:["jobs","view_jobs","manage_jobs"]}]},{text:"System Security",route:null,icon:"bi-shield-exclamation",permissions:["view_security"],children:[{text:"Tickets",route:"?page=system/tickets",icon:"bi-ticket-detailed",permissions:["manage_security"]},{text:"Incidents",route:"?page=system/incidents",icon:"bi-exclamation-triangle",permissions:["view_security"]},{text:"Events",route:"?page=system/events",icon:"bi-bell",permissions:["view_security"]},{text:"Rules",route:"?page=system/rulesets",icon:"bi-funnel",permissions:["manage_security"]}]},{text:"Network Security",route:null,icon:"bi-hdd-network",permissions:["view_security"],children:[{text:"IPs",route:"?page=system/system/geoip",icon:"bi-globe",permissions:["view_security"]},{text:"IP Sets",route:"?page=system/security/ipsets",icon:"bi-shield-shaded",permissions:["view_security"]},{text:"Blocked",route:"?page=system/security/blocked-ips",icon:"bi-slash-circle",permissions:["view_security"]},{text:"Firewall Log",route:"?page=system/security/firewall-log",icon:"bi-journal-code",permissions:["view_security"]}]},{text:"Bouncer",route:null,icon:"bi-fingerprint",permissions:["view_security"],children:[{text:"Signals",route:"?page=system/security/bouncer-signals",icon:"bi-activity",permissions:["view_security"]},{text:"Devices",route:"?page=system/security/bouncer-devices",icon:"bi-fingerprint",permissions:["view_security"]},{text:"Bots",route:"?page=system/security/bot-signatures",icon:"bi-robot",permissions:["manage_security"]}]},{text:"Messaging",route:null,icon:"bi-envelope",permissions:["manage_aws","view_support","support"],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:"Contact Messages",route:"?page=system/messaging/public-messages",icon:"bi-chat-square-text",permissions:["view_support","support"]}]},{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:"Shortlinks",route:null,icon:"bi-link-45deg",permissions:["manage_shortlinks"],children:[{text:"Links",route:"?page=system/shortlinks/links",icon:"bi-link",permissions:["manage_shortlinks"]},{text:"Click History",route:"?page=system/shortlinks/clicks",icon:"bi-cursor",permissions:["manage_shortlinks"]}]},{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:"Mojo",route:null,icon:"bi-robot",permissions:["view_admin","assistant"],children:[{text:"Skills",route:"?page=system/assistant/skills",icon:"bi-lightning",permissions:["view_admin","assistant"]},{text:"Memory",route:"?page=system/assistant/memory",icon:"bi-lightbulb",permissions:["view_admin","assistant"]},{text:"Conversations",route:"?page=system/assistant/conversations",icon:"bi-chat-left-text",permissions:["view_admin","assistant"]}]},{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)}}const s=[c.Incident,c.IncidentEvent,c.RuleSet,c.Ticket,n.GeoLocatedIP,t.User,t.Group,l.Member,c.Job,c.JobRunner,a.File,l.Log,n.ShortLink];for(const t of s)t.MODEL_REF&&e.registerModelRef(t.MODEL_REF,t);vt(e)}function vt(e){function t(){if(!e._ticketPanel)return;const t=document.querySelector(".portal-layout");t&&t.classList.remove("ticket-panel-open"),e._ticketPanel.destroy(),e._ticketPanel=null;const i=document.getElementById("ticket-panel");i&&i.remove()}e.openTicketPanel=async function(i){let s;if(i&&"object"==typeof i&&i.get?(s=i,await s.fetch()):(s=new c.Ticket({id:i}),await s.fetch()),e._ticketPanel&&e._ticketPanel.isMounted())return void e._ticketPanel.setTicket(s);const a=document.querySelector(".portal-layout");if(!a)return;let n=document.getElementById("ticket-panel");n||(n=document.createElement("div"),n.id="ticket-panel",a.appendChild(n));const o=new TicketPanelView({model:s,app:e});o.on("panel:close",()=>t()),await o.render(!0,n),e._ticketPanel=o,requestAnimationFrame(()=>{a.classList.add("ticket-panel-open")})},e.closeTicketPanel=t}exports.FileView=n.FileView,exports.PushDeviceView=c.PushDeviceView,exports.WebApp=h.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.AssistantConversationTablePage=AssistantConversationTablePage,exports.AssistantConversationView=AssistantConversationView,exports.AssistantMemoryPage=AssistantMemoryPage,exports.AssistantSkillTablePage=AssistantSkillTablePage,exports.AssistantSkillView=AssistantSkillView,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.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=SecurityDashboardPage,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.PublicMessageTablePage=PublicMessageTablePage,exports.PublicMessageView=PublicMessageView,exports.PushConfigTablePage=PushConfigTablePage,exports.PushDashboardPage=PushDashboardPage,exports.PushDeliveryTablePage=PushDeliveryTablePage,exports.PushDeliveryView=PushDeliveryView,exports.PushDeviceTablePage=PushDeviceTablePage,exports.PushTemplateTablePage=PushTemplateTablePage,exports.RuleSetTablePage=RuleSetTablePage,exports.RuleSetView=RuleSetView,exports.RunnerDetailsView=RunnerDetailsView,exports.S3BucketTablePage=S3BucketTablePage,exports.SMSTablePage=SMSTablePage,exports.SentMessageTablePage=SentMessageTablePage,exports.SettingTablePage=SettingTablePage,exports.SettingView=SettingView,exports.ShortLinkClickTablePage=ShortLinkClickTablePage,exports.ShortLinkTablePage=ShortLinkTablePage,exports.ShortLinkView=ShortLinkView,exports.TicketTablePage=TicketTablePage,exports.TicketView=TicketView,exports.UserDeviceLocationTablePage=UserDeviceLocationTablePage,exports.UserDeviceLocationView=UserDeviceLocationView,exports.UserDeviceTablePage=UserDeviceTablePage,exports.UserTablePage=UserTablePage,exports.UserView=UserView,exports.registerAdminPages=gt,exports.registerAssistant=function(e){function t(){if(!e._assistantPanel)return;const t=document.querySelector(".portal-layout");t&&t.classList.remove("assistant-panel-open"),e._assistantPanel.destroy(),e._assistantPanel=null;const i=document.getElementById("assistant-panel");i&&i.remove()}async function i(){if(e._assistantPanel&&e._assistantPanel.isMounted())return void e._assistantPanel.focusInput();const a=document.querySelector(".portal-layout");if(!a)return s();let n=document.getElementById("assistant-panel");n||(n=document.createElement("div"),n.id="assistant-panel",a.appendChild(n));const o=new AssistantPanelView({app:e});o.on("panel:close",()=>t()),o.on("panel:fullscreen",()=>s()),o.on("panel:popout",a=>async function(a){t();const n=window.open("","mojo-assistant","width=480,height=700,toolbar=no,menubar=no,status=no,location=no,resizable=yes");if(!n)return e.toast&&e.toast.warning("Popup blocked — opening sidebar instead"),void(window.innerWidth>=1e3?await i():await s());const o=document.querySelectorAll('link[rel="stylesheet"], style');let l="";o.forEach(e=>{"LINK"===e.tagName?l+=`<link rel="stylesheet" href="${e.href}">`:l+=e.outerHTML}),n.document.write(`<!DOCTYPE html>\n<html><head><title>Mojo</title>${l}\n<style>\n body { margin: 0; height: 100vh; overflow: hidden; }\n #assistant-popup-root { height: 100vh; }\n .assistant-panel-view { height: 100%; }\n .assistant-panel-resize-handle { display: none; }\n</style>\n</head><body class="assistant-popup">\n<div id="assistant-popup-root"></div>\n</body></html>`),n.document.close();const r=new AssistantPanelView({app:e,conversationId:a||e._assistantConversationId||null});r.on("panel:close",()=>n.close()),r.on("panel:popout",()=>{});const c=n.document.getElementById("assistant-popup-root");await r.render(!0,c),e._assistantPopup=n,e._assistantPopupView=r,n.addEventListener("beforeunload",()=>{e._assistantPopupView&&(e._assistantPopupView.destroy(),e._assistantPopupView=null),e._assistantPopup=null})}(a?.conversationId)),await o.render(!0,n),e._assistantPanel=o;const l=localStorage.getItem("mojo:assistant_panel_width");if(l){const e=parseInt(l,10);e>=300&&e<=700&&(n.style.width="0px")}requestAnimationFrame(()=>{if(a.classList.add("assistant-panel-open"),l){const e=parseInt(l,10);e>=300&&e<=700&&(n.style.width=e+"px")}})}async function s(){t();const i=new AssistantView({app:e});a.Modal.show(i,{size:"fullscreen",noBodyPadding:!0,title:" ",buttons:[]})}let n=null;window.addEventListener("resize",function(){n&&clearTimeout(n),n=setTimeout(()=>{e._assistantPanel&&window.innerWidth<1e3&&s()},250)});const o={id:"assistant",icon:"bi-robot",action:"open-assistant",isButton:!0,buttonClass:"btn btn-link nav-link",tooltip:"Mojo",permissions:["view_admin"],handler:async()=>{e._assistantPanel&&e._assistantPanel.isMounted()?t():window.innerWidth>=1e3?await i():await s()}};e.topbar&&e.topbar.config?(e.topbar.config.rightItems.unshift(o),e.topbar.isMounted()&&e.topbar.render()):e.topbarConfig&&(e.topbarConfig.rightItems||(e.topbarConfig.rightItems=[]),e.topbarConfig.rightItems.unshift(o))},exports.registerSystemPages=gt,exports.registerTicketPanel=vt;
2
2
  //# sourceMappingURL=admin.cjs.js.map