@logboard/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. package/views/users.ejs +153 -0
@@ -0,0 +1,334 @@
1
+ <%- include('partials/head', { title: 'Insights' }) %>
2
+ <style>
3
+ .tab-bar{display:flex;gap:0;border-bottom:2px solid var(--border);margin-bottom:16px;}
4
+ .tab-btn{padding:10px 20px;font-size:13px;font-weight:500;background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-2px;color:var(--text2);cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:7px;}
5
+ .tab-btn:hover{color:var(--text);}
6
+ .tab-btn.active{color:var(--accent-l);border-bottom-color:var(--accent);}
7
+ .tab-panel{display:none;}.tab-panel.active{display:block;}
8
+ .method-badge{display:inline-flex;align-items:center;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:.5px;font-family:'JetBrains Mono',monospace;white-space:nowrap;}
9
+ .method-GET{background:rgba(34,197,94,.15);color:var(--green);}
10
+ .method-POST{background:rgba(59,130,246,.15);color:var(--blue);}
11
+ .method-PUT{background:rgba(245,158,11,.15);color:var(--yellow);}
12
+ .method-DELETE{background:rgba(239,68,68,.15);color:var(--red);}
13
+ .method-PATCH{background:rgba(99,102,241,.15);color:var(--accent-l);}
14
+ .dur-pill{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-family:'JetBrains Mono',monospace;font-weight:600;}
15
+ .dur-ok{background:rgba(34,197,94,.12);color:var(--green);}
16
+ .dur-med{background:rgba(245,158,11,.12);color:var(--yellow);}
17
+ .dur-slow{background:rgba(239,68,68,.12);color:var(--red);}
18
+ .sort-btn{background:none;border:none;color:var(--text3);cursor:pointer;padding:0 4px;font-size:11px;}
19
+ .sort-btn.asc::after{content:' ↑';}.sort-btn.desc::after{content:' ↓';}
20
+ </style>
21
+ <div class="app-shell">
22
+ <%- include('partials/sidebar') %>
23
+ <div class="main-area">
24
+ <header class="top-header">
25
+ <div class="page-title">Insights</div>
26
+ <div class="header-actions">
27
+ <span id="api-ctrl" style="display:none;gap:8px;align-items:center;">
28
+ <select id="svc-select" class="form-select" style="max-width:200px;" onchange="apiLoadAll()"></select>
29
+ <input type="date" id="api-date" class="form-input" style="max-width:140px;" value="<%= today %>" max="<%= today %>" onchange="apiLoadAll()"/>
30
+ <button class="btn btn-secondary btn-sm" onclick="apiLoadAll()">
31
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
32
+ Refresh
33
+ </button>
34
+ </span>
35
+ </div>
36
+ </header>
37
+ <div class="page-content">
38
+ <div class="tab-bar">
39
+ <button class="tab-btn active" id="tab-log" onclick="switchTab('log')">
40
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
41
+ Log Analytics
42
+ </button>
43
+ <button class="tab-btn" id="tab-api" onclick="switchTab('api')">
44
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
45
+ API Analytics
46
+ <% if(apiServices&&apiServices.length){%><span class="badge badge-purple" style="font-size:9px;padding:1px 5px;"><%=apiServices.length%></span><%}%>
47
+ </button>
48
+ </div>
49
+
50
+ <!-- ── LOG ANALYTICS ──────────────────────────────────────── -->
51
+ <div class="tab-panel active" id="panel-log">
52
+ <div class="card" style="margin-bottom:12px;">
53
+ <form method="GET" action="/insights">
54
+ <input type="hidden" name="tab" value="log"/>
55
+ <div class="form-row">
56
+ <div class="form-group" style="min-width:180px;flex:1.5">
57
+ <label class="form-label">Service</label>
58
+ <select name="service" class="form-select" onchange="this.form.submit()">
59
+ <option value="">All Services</option>
60
+ <% logServices.forEach(function(s){%><option value="<%=s.appName%>" <%=selected.service===s.appName?'selected':''%>><%=s.appName%></option><%})%>
61
+ </select>
62
+ </div>
63
+ <div class="form-group" style="min-width:150px;">
64
+ <label class="form-label">Date</label>
65
+ <input type="date" name="date" class="form-input" value="<%=selected.date%>" max="<%=today%>" onchange="this.form.submit()"/>
66
+ </div>
67
+ <div class="form-group" style="display:flex;align-items:flex-end;">
68
+ <button type="submit" class="btn btn-primary">Apply</button>
69
+ </div>
70
+ </div>
71
+ </form>
72
+ </div>
73
+
74
+ <% if(hasCard('ins-stats')&&breakdown){%>
75
+ <div class="stats-grid" style="margin-bottom:12px;">
76
+ <div class="stat-card"><div class="stat-icon blue"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></div><div><div class="stat-value"><%=breakdown.total.toLocaleString()%></div><div class="stat-label">Total Logs</div><div class="stat-sub"><%=selected.date%></div></div></div>
77
+ <div class="stat-card"><div class="stat-icon red"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div><div class="stat-value"><%=breakdown.error.toLocaleString()%></div><div class="stat-label">Errors</div><div class="stat-sub"><%=breakdown.total?((breakdown.error/breakdown.total*100).toFixed(1))+'%':'0%'%> of total</div></div></div>
78
+ <div class="stat-card"><div class="stat-icon yellow"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div><div><div class="stat-value"><%=breakdown.warn.toLocaleString()%></div><div class="stat-label">Warnings</div><div class="stat-sub"><%=breakdown.total?((breakdown.warn/breakdown.total*100).toFixed(1))+'%':'0%'%></div></div></div>
79
+ <div class="stat-card"><div class="stat-icon green"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg></div><div><div class="stat-value"><%=breakdown.info.toLocaleString()%></div><div class="stat-label">Info</div><div class="stat-sub"><%=breakdown.debug%> debug</div></div></div>
80
+ </div>
81
+ <%}%>
82
+
83
+ <div class="grid-2" style="margin-bottom:12px;">
84
+ <% if(hasCard('ins-hourly')){%>
85
+ <div class="card span-2">
86
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>Log Volume by Hour<span style="margin-left:auto;font-size:11px;color:var(--text3);font-weight:400"><%=selected.date%> · <%=selected.service||'all services'%></span></div>
87
+ <div class="chart-wrap-lg"><canvas id="hourlyChart"></canvas></div>
88
+ </div>
89
+ <%}%>
90
+ </div>
91
+
92
+ <div class="grid-2" style="margin-bottom:12px;">
93
+ <% if(hasCard('ins-donut')){%>
94
+ <div class="card">
95
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg>Level Distribution</div>
96
+ <div class="chart-wrap"><canvas id="levelChart"></canvas></div>
97
+ </div>
98
+ <%}%>
99
+ <% if(hasCard('ins-trend')){%>
100
+ <div class="card">
101
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>7-Day Trend</div>
102
+ <div class="chart-wrap"><canvas id="trendChart"></canvas></div>
103
+ </div>
104
+ <%}%>
105
+ </div>
106
+
107
+ <% if(hasCard('ins-services')&&topSvcs&&topSvcs.length){%>
108
+ <div class="card">
109
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>Top Services — <span style="font-weight:400;color:var(--text2)"><%=selected.date%></span></div>
110
+ <div class="table-wrap">
111
+ <table><thead><tr><th>Service</th><th>Total</th><th>Errors</th><th>Warnings</th><th>Info</th><th>Error Rate</th><th>Volume</th></tr></thead>
112
+ <tbody><%var mx=topSvcs[0]?topSvcs[0].total:1;topSvcs.forEach(function(s){%>
113
+ <tr><td><a href="/insights?service=<%=s.appName%>&date=<%=selected.date%>&tab=log" style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:500"><%=s.appName%></a></td>
114
+ <td><strong><%=s.total.toLocaleString()%></strong></td>
115
+ <td><span class="badge badge-error"><%=s.error%></span></td>
116
+ <td><span class="badge badge-warn"><%=s.warn%></span></td>
117
+ <td><span class="badge badge-info"><%=s.info%></span></td>
118
+ <td style="color:<%=s.total&&s.error/s.total>0.1?'var(--red)':'var(--text2)'%>"><%=s.total?(s.error/s.total*100).toFixed(1):'0.0'%>%</td>
119
+ <td style="min-width:120px;"><div class="progress-bar" style="margin-top:4px;"><div class="progress-fill <%=s.error/s.total>0.1?'red':''%>" style="width:<%=Math.round(s.total/mx*100)%>%"></div></div></td>
120
+ </tr><%})%></tbody></table>
121
+ </div>
122
+ </div>
123
+ <%}%>
124
+ </div>
125
+
126
+ <!-- ── API ANALYTICS ──────────────────────────────────────── -->
127
+ <div class="tab-panel" id="panel-api">
128
+ <% if(!apiServices||!apiServices.length){%>
129
+ <div class="card"><div style="text-align:center;padding:40px 20px;color:var(--text3);">
130
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 12px;opacity:.4"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
131
+ <div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px;">No API request data yet</div>
132
+ <p style="font-size:12px;max-width:440px;margin:0 auto;line-height:1.7;color:var(--text3);">
133
+ Add <code style="background:var(--surface3);padding:1px 6px;border-radius:3px;">logger.express()</code>
134
+ from <strong>@logboard/logger</strong> to your app — it automatically instruments every HTTP request
135
+ and ships latency, status codes, and endpoint stats to LogBoard.
136
+ <br/><br/>
137
+ <code style="background:var(--surface3);padding:4px 8px;border-radius:4px;font-size:11px;display:block;text-align:left;">
138
+ app.use(logger.express()); // Express, Fastify, NestJS, etc.
139
+ </code>
140
+ </p>
141
+ </div></div>
142
+ <%}else{%>
143
+
144
+ <% if(hasCard('api-stats')){%>
145
+ <div class="stats-grid" style="grid-template-columns:repeat(auto-fit,minmax(165px,1fr));margin-bottom:16px;">
146
+ <div class="stat-card"><div class="stat-icon blue"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg></div><div><div class="stat-value" id="sc-total">–</div><div class="stat-label">Total Requests</div><div class="stat-sub" id="sc-total-sub">today</div></div></div>
147
+ <div class="stat-card"><div class="stat-icon purple"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg></div><div><div class="stat-value" id="sc-avg">–</div><div class="stat-label">Avg Response</div><div class="stat-sub" id="sc-max-sub">–</div></div></div>
148
+ <div class="stat-card"><div class="stat-icon red"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div><div class="stat-value" id="sc-err">–</div><div class="stat-label">Error Rate</div><div class="stat-sub" id="sc-err-sub">–</div></div></div>
149
+ <div class="stat-card"><div class="stat-icon green"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg></div><div><div class="stat-value" id="sc-data">–</div><div class="stat-label">Data Transferred</div><div class="stat-sub" id="sc-data-sub">–</div></div></div>
150
+ <div class="stat-card"><div class="stat-icon yellow"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13,2 13,9 20,9"/></svg></div><div><div class="stat-value" id="sc-max">–</div><div class="stat-label">Slowest</div><div class="stat-sub">max duration</div></div></div>
151
+ </div>
152
+ <%}%>
153
+
154
+ <% if(hasCard('api-hourly')||hasCard('api-status')){%>
155
+ <div class="grid-2" style="margin-bottom:12px;">
156
+ <% if(hasCard('api-hourly')){%>
157
+ <div class="card"><div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>Hourly Request Volume</div><div class="chart-wrap"><canvas id="hourly-chart"></canvas></div></div>
158
+ <%}%>
159
+ <% if(hasCard('api-status')){%>
160
+ <div class="card"><div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>Status Distribution</div>
161
+ <div style="display:flex;align-items:center;gap:20px;"><div style="width:140px;height:140px;flex-shrink:0;"><canvas id="status-chart"></canvas></div><div id="status-legend" style="display:flex;flex-direction:column;gap:8px;flex:1;"></div></div>
162
+ </div>
163
+ <%}%>
164
+ </div>
165
+ <%}%>
166
+
167
+ <% if(hasCard('api-slowest')){%>
168
+ <div class="card" style="margin-bottom:12px;">
169
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>Top 10 Slowest Endpoints</div>
170
+ <div style="height:220px;"><canvas id="slowest-chart"></canvas></div>
171
+ </div>
172
+ <%}%>
173
+
174
+ <% if(hasCard('api-endpoints')){%>
175
+ <div class="card" style="margin-bottom:12px;">
176
+ <div class="card-title" style="justify-content:space-between;">
177
+ <span style="display:flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>All Endpoints</span>
178
+ <div style="display:flex;gap:8px;align-items:center;">
179
+ <div class="search-wrap" style="width:200px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><input type="text" id="ep-search" class="form-input search-input" placeholder="Filter endpoints…" oninput="filterTable()"/></div>
180
+ <button class="btn btn-secondary btn-xs" onclick="downloadCsv()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>CSV</button>
181
+ </div>
182
+ </div>
183
+ <div class="table-wrap">
184
+ <table id="ep-table"><thead><tr><th>Method</th><th>Endpoint <button class="sort-btn" data-col="path" onclick="sortTable(this)">↕</button></th><th>Calls <button class="sort-btn" data-col="count" onclick="sortTable(this)">↕</button></th><th>Avg <button class="sort-btn" data-col="avgDuration" onclick="sortTable(this)">↕</button></th><th>P95</th><th>Max</th><th>Avg Res</th><th>Errors <button class="sort-btn" data-col="errors" onclick="sortTable(this)">↕</button></th><th>Err %</th></tr></thead>
185
+ <tbody id="ep-tbody"><tr><td colspan="9" style="text-align:center;color:var(--text3);padding:24px;">Loading…</td></tr></tbody></table>
186
+ </div>
187
+ </div>
188
+ <%}%>
189
+
190
+ <% if(hasCard('api-errors')){%>
191
+ <div class="card" style="margin-bottom:12px;">
192
+ <div class="card-title"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>Error Hot-spots<span id="error-count-badge" class="badge" style="margin-left:4px;"></span></div>
193
+ <div class="table-wrap"><table><thead><tr><th>Method</th><th>Endpoint</th><th>Errors</th><th>5xx</th><th>Error Rate</th><th>Status Codes</th><th>Last Seen</th></tr></thead>
194
+ <tbody id="err-tbody"><tr><td colspan="7" style="text-align:center;color:var(--text3);padding:24px;">Loading…</td></tr></tbody></table></div>
195
+ </div>
196
+ <%}%>
197
+
198
+ <% if(hasCard('api-trend')){%>
199
+ <div class="card">
200
+ <div class="card-title" style="justify-content:space-between;flex-wrap:wrap;gap:10px;">
201
+ <span style="display:flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,6 13.5,15.5 8.5,10.5 1,18"/><polyline points="17,6 23,6 23,12"/></svg>Trend Analysis<span id="trend-label" style="font-size:11px;color:var(--text3);font-weight:400;"></span></span>
202
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
203
+ <label style="font-size:11px;color:var(--text3);">From</label>
204
+ <input type="date" id="trend-start" class="form-input" style="max-width:130px;" max="<%=today%>"/>
205
+ <label style="font-size:11px;color:var(--text3);">To</label>
206
+ <input type="date" id="trend-end" class="form-input" style="max-width:130px;" max="<%=today%>" value="<%=today%>"/>
207
+ <button class="btn btn-primary btn-sm" onclick="loadTrends()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>Analyse</button>
208
+ </div>
209
+ </div>
210
+ <div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:16px;">
211
+ <button id="ttab-0" onclick="switchTrendTab(0)" class="ttab" style="padding:8px 16px;font-size:12px;font-weight:500;background:none;border:none;border-bottom:2px solid var(--accent);color:var(--accent-l);cursor:pointer;">Slow Trend</button>
212
+ <button id="ttab-1" onclick="switchTrendTab(1)" class="ttab" style="padding:8px 16px;font-size:12px;font-weight:500;background:none;border:none;border-bottom:2px solid transparent;color:var(--text2);cursor:pointer;">Hourly Pattern</button>
213
+ <button id="ttab-2" onclick="switchTrendTab(2)" class="ttab" style="padding:8px 16px;font-size:12px;font-weight:500;background:none;border:none;border-bottom:2px solid transparent;color:var(--text2);cursor:pointer;">Error Trend</button>
214
+ </div>
215
+ <div id="tpanel-0"><div style="height:240px;"><canvas id="slow-trend-chart"></canvas></div><div id="slow-trend-meta" style="margin-top:12px;display:flex;gap:12px;flex-wrap:wrap;"></div></div>
216
+ <div id="tpanel-1" style="display:none;"><div style="height:240px;"><canvas id="pattern-chart"></canvas></div><div id="pattern-insight" style="margin-top:10px;font-size:12px;color:var(--text2);"></div></div>
217
+ <div id="tpanel-2" style="display:none;"><div style="height:240px;"><canvas id="error-trend-chart"></canvas></div><div id="error-trend-meta" style="margin-top:12px;display:flex;gap:12px;flex-wrap:wrap;"></div></div>
218
+ <div id="trend-loading" style="display:none;text-align:center;padding:32px 0;color:var(--text3);font-size:12px;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin:0 auto 8px;display:block;animation:spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>Loading…</div>
219
+ </div>
220
+ <%}%>
221
+ <%}%>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
228
+ <script>
229
+ const API_SVCS=<%- JSON.stringify(apiServices||[]) %>;
230
+ const isDark=()=>document.documentElement.dataset.theme!=='light';
231
+ const gc=()=>isDark()?'rgba(255,255,255,.05)':'rgba(0,0,0,.06)';
232
+ const tc=()=>isDark()?'#5a5a78':'#9494b8';
233
+
234
+ // Tab switching
235
+ let _apiLoaded=false;
236
+ function switchTab(t){
237
+ ['log','api'].forEach(x=>{document.getElementById('tab-'+x).classList.toggle('active',x===t);document.getElementById('panel-'+x).classList.toggle('active',x===t);});
238
+ const ac=document.getElementById('api-ctrl');
239
+ if(ac){ac.style.display=t==='api'?'flex':'none';}
240
+ location.hash=t;
241
+ if(t==='api'&&!_apiLoaded){_apiLoaded=true;apiInit();setTimeout(()=>apiLoadAll(),50);}
242
+ }
243
+ (function(){const h=location.hash.replace('#',''),q=new URLSearchParams(location.search).get('tab');if(h==='api'||q==='api')switchTab('api');})();
244
+
245
+ // Log analytics charts
246
+ const hRaw=<%- JSON.stringify(hourly?hourly.hours:[]) %>;
247
+ const bRaw=<%- JSON.stringify(breakdown||{error:0,warn:0,info:0,debug:0}) %>;
248
+ const tRaw=<%- JSON.stringify(trend||[]) %>;
249
+ const d=isDark(),g=d?'rgba(255,255,255,.05)':'rgba(0,0,0,.06)',tx=d?'#5a5a78':'#9494b8';
250
+ if(hRaw.length&&document.getElementById('hourlyChart')){new Chart(document.getElementById('hourlyChart'),{type:'bar',data:{labels:hRaw.map(h=>h.hour+':00'),datasets:[{label:'Error',data:hRaw.map(h=>h.error),backgroundColor:'rgba(239,68,68,.75)',stack:'s'},{label:'Warn',data:hRaw.map(h=>h.warn),backgroundColor:'rgba(245,158,11,.65)',stack:'s'},{label:'Info',data:hRaw.map(h=>h.info),backgroundColor:'rgba(59,130,246,.6)',stack:'s'},{label:'Debug',data:hRaw.map(h=>h.debug),backgroundColor:'rgba(107,114,128,.4)',stack:'s'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'top',labels:{color:tx,font:{size:11},boxWidth:12}}},scales:{x:{stacked:true,ticks:{color:tx,font:{size:10}},grid:{color:g}},y:{stacked:true,ticks:{color:tx,font:{size:10}},grid:{color:g}}}}});}
251
+ if(document.getElementById('levelChart')){new Chart(document.getElementById('levelChart'),{type:'doughnut',data:{labels:['Error','Warn','Info','Debug'],datasets:[{data:[bRaw.error,bRaw.warn,bRaw.info,bRaw.debug],backgroundColor:['rgba(239,68,68,.85)','rgba(245,158,11,.85)','rgba(59,130,246,.85)','rgba(107,114,128,.75)'],borderWidth:2,borderColor:d?'#13131e':'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{color:tx,font:{size:11},boxWidth:12}}},cutout:'65%'}});}
252
+ if(tRaw.length&&document.getElementById('trendChart')){new Chart(document.getElementById('trendChart'),{type:'line',data:{labels:tRaw.map(x=>x.date.slice(5)),datasets:[{label:'Total',data:tRaw.map(x=>x.total),borderColor:'rgba(99,102,241,.9)',backgroundColor:'rgba(99,102,241,.1)',tension:.3,fill:true,pointRadius:3},{label:'Error',data:tRaw.map(x=>x.error),borderColor:'rgba(239,68,68,.8)',backgroundColor:'transparent',tension:.3,pointRadius:3}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:tx,font:{size:11},boxWidth:12}}},scales:{x:{ticks:{color:tx,font:{size:10}},grid:{color:g}},y:{ticks:{color:tx,font:{size:10}},grid:{color:g},beginAtZero:true}}}});}
253
+
254
+ // API analytics
255
+ let _ep=[],_sCol='count',_sDir='desc',_hC=null,_stC=null,_slC=null,_stTC=null,_ptC=null,_etC=null;
256
+ function fmtMs(ms){if(ms==null)return'–';if(ms<1)return'<1ms';if(ms>=1000)return(ms/1000).toFixed(2)+'s';return ms+'ms';}
257
+ function fmtB(b){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i];}
258
+ function dCls(ms){if(ms<200)return'dur-ok';if(ms<800)return'dur-med';return'dur-slow';}
259
+ function eCls(r){return parseFloat(r)>5?'color:var(--red)':parseFloat(r)>1?'color:var(--yellow)':'color:var(--text2)';}
260
+ async function afetch(url){const r=await fetch(url);if(!r.ok)throw new Error(await r.text());return r.json();}
261
+ function setEl(id,v){const e=document.getElementById(id);if(e)e.textContent=v;}
262
+ function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
263
+ function gp(){return{svc:document.getElementById('svc-select')?.value,date:document.getElementById('api-date')?.value};}
264
+
265
+ function apiInit(){
266
+ const sel=document.getElementById('svc-select');
267
+ if(!sel) return;
268
+ if(API_SVCS.length===0){ sel.innerHTML='<option value="">No API services yet</option>'; return; }
269
+ API_SVCS.forEach(s=>{const o=document.createElement('option');o.value=s.appName;o.textContent=s.baseApp||s.appName;sel.appendChild(o);});
270
+ if(!sel.value && API_SVCS.length) sel.value = API_SVCS[0].appName;
271
+ }
272
+ async function apiLoadAll(){
273
+ const{svc,date}=gp();if(!svc)return;
274
+ await Promise.all([apiOvr(svc,date),apiHrly(svc,date),apiStat(svc,date),apiEp(svc,date),apiErr(svc,date),apiSlow(svc,date)]);
275
+ }
276
+ async function apiOvr(svc,date){try{const d=await afetch('/api/api-analytics/overview?service='+encodeURIComponent(svc)+'&date='+date);setEl('sc-total',d.totalRequests.toLocaleString());setEl('sc-total-sub',date);setEl('sc-avg',fmtMs(d.avgDuration));setEl('sc-max-sub','max: '+fmtMs(d.maxDuration));const e=document.getElementById('sc-err');if(e){e.textContent=d.errorRate+'%';e.style.color=parseFloat(d.errorRate)>5?'var(--red)':parseFloat(d.errorRate)>1?'var(--yellow)':'var(--green)';}setEl('sc-err-sub',d.totalErrors+' total ('+d.totalErrors5xx+' 5xx)');setEl('sc-data',d.totalDataHuman);setEl('sc-data-sub','↑ '+d.totalReqHuman+' ↓ '+d.totalResHuman);setEl('sc-max',fmtMs(d.maxDuration));}catch{}}
277
+ async function apiHrly(svc,date){try{const d=await afetch('/api/api-analytics/hourly?service='+encodeURIComponent(svc)+'&date='+date);const h=d.hours,l=h.map(x=>x.hour+':00');if(_hC)_hC.destroy();_hC=new Chart(document.getElementById('hourly-chart'),{type:'bar',data:{labels:l,datasets:[{label:'2xx',data:h.map(x=>x.ok),backgroundColor:'rgba(34,197,94,.55)',stack:'s'},{label:'4xx',data:h.map(x=>x.warn),backgroundColor:'rgba(245,158,11,.55)',stack:'s'},{label:'5xx',data:h.map(x=>x.error),backgroundColor:'rgba(239,68,68,.75)',stack:'s'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:tc(),font:{size:11}}}},scales:{x:{stacked:true,ticks:{color:tc(),font:{size:10},maxTicksLimit:12},grid:{color:gc()}},y:{stacked:true,ticks:{color:tc(),font:{size:10}},grid:{color:gc()}}}}});}catch{}}
278
+ async function apiStat(svc,date){try{const d=await afetch('/api/api-analytics/status?service='+encodeURIComponent(svc)+'&date='+date);const dist=d.distribution,lbl=Object.keys(dist).map(k=>k+'xx'),vals=Object.values(dist),clrs={'2xx':'rgba(34,197,94,.8)','3xx':'rgba(59,130,246,.8)','4xx':'rgba(245,158,11,.8)','5xx':'rgba(239,68,68,.8)'};const bg=lbl.map(l=>clrs[l]||'rgba(107,114,128,.5)');if(_stC)_stC.destroy();_stC=new Chart(document.getElementById('status-chart'),{type:'doughnut',data:{labels:lbl,datasets:[{data:vals,backgroundColor:bg,borderColor:'transparent',borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,cutout:'70%',plugins:{legend:{display:false}}}});const leg=document.getElementById('status-legend');if(leg)leg.innerHTML=lbl.map((l,i)=>'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;"><div style="display:flex;align-items:center;gap:6px;"><div style="width:10px;height:10px;border-radius:2px;background:'+bg[i]+';flex-shrink:0;"></div><span style="font-size:12px;color:var(--text2);">'+l+'</span></div><span style="font-size:12px;font-weight:600;font-family:\'JetBrains Mono\',monospace;color:var(--text);">'+vals[i].toLocaleString()+'</span></div>').join('');}catch{}}
279
+ async function apiSlow(svc,date){try{const data=await afetch('/api/api-analytics/slowest?service='+encodeURIComponent(svc)+'&date='+date+'&top=10');if(!data.length)return;if(_slC)_slC.destroy();_slC=new Chart(document.getElementById('slowest-chart'),{type:'bar',data:{labels:data.map(e=>e.method+' '+(e.path.length>30?'…'+e.path.slice(-28):e.path)),datasets:[{label:'Avg (ms)',data:data.map(e=>e.avgDuration),backgroundColor:'rgba(99,102,241,.65)',borderRadius:3},{label:'P95 (ms)',data:data.map(e=>e.p95Duration),backgroundColor:'rgba(245,158,11,.5)',borderRadius:3}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:tc(),font:{size:11}}}},scales:{x:{ticks:{color:tc(),font:{size:10},callback:v=>v+'ms'},grid:{color:gc()}},y:{ticks:{color:tc(),font:{size:10}},grid:{display:false}}}}});}catch{}}
280
+ async function apiEp(svc,date){try{_ep=await afetch('/api/api-analytics/endpoints?service='+encodeURIComponent(svc)+'&date='+date);renderTable();}catch{const t=document.getElementById('ep-tbody');if(t)t.innerHTML='<tr><td colspan="9" style="text-align:center;color:var(--red);padding:16px;">Failed to load</td></tr>';}}
281
+ function renderTable(){const q=(document.getElementById('ep-search')?.value||'').toLowerCase();let rows=_ep.filter(e=>!q||(e.method+' '+e.path).toLowerCase().includes(q));rows.sort((a,b)=>{const av=a[_sCol],bv=b[_sCol];if(typeof av==='string')return _sDir==='asc'?av.localeCompare(bv):bv.localeCompare(av);return _sDir==='asc'?av-bv:bv-av;});const t=document.getElementById('ep-tbody');if(!t)return;t.innerHTML=rows.length?rows.map(e=>'<tr><td><span class="method-badge method-'+e.method+'">'+e.method+'</span></td><td style="font-family:\'JetBrains Mono\',monospace;font-size:11.5px;max-width:260px;" class="truncate">'+esc(e.path)+'</td><td style="font-weight:600;color:var(--text);">'+e.count.toLocaleString()+'</td><td><span class="dur-pill '+dCls(e.avgDuration)+'">'+fmtMs(e.avgDuration)+'</span></td><td style="font-family:\'JetBrains Mono\',monospace;font-size:11.5px;color:var(--text2);">'+fmtMs(e.p95Duration)+'</td><td style="font-family:\'JetBrains Mono\',monospace;font-size:11.5px;color:var(--text2);">'+fmtMs(e.maxDuration)+'</td><td style="font-size:11.5px;color:var(--text3);">'+fmtB(e.avgResBytes)+'</td><td style="font-family:\'JetBrains Mono\',monospace;font-size:12px;font-weight:600;color:'+(e.errors>0?'var(--red)':'var(--text3)')+';">'+e.errors+'</td><td style="font-size:12px;'+eCls(e.errorRate)+'">'+e.errorRate+'%</td></tr>').join(''):'<tr><td colspan="9" style="text-align:center;color:var(--text3);padding:24px;">No data</td></tr>';}
282
+ function filterTable(){renderTable();}
283
+ function sortTable(btn){const col=btn.dataset.col;document.querySelectorAll('.sort-btn').forEach(b=>b.className='sort-btn');_sDir=(_sCol===col&&_sDir==='asc')?'desc':'asc';if(_sCol!==col){_sDir='desc';}_sCol=col;btn.className='sort-btn '+_sDir;renderTable();}
284
+ async function apiErr(svc,date){try{const data=await afetch('/api/api-analytics/errors?service='+encodeURIComponent(svc)+'&date='+date+'&top=20');const badge=document.getElementById('error-count-badge');if(badge){badge.textContent=data.length?data.reduce((a,e)=>a+e.errors,0)+' errors':'';badge.className='badge '+(data.length?'badge-error':'');}const t=document.getElementById('err-tbody');if(!t)return;t.innerHTML=data.length?data.map(e=>{const codes=Object.entries(e.statusCodes).map(([sc,cnt])=>'<span class="badge badge-'+(sc>=500?'error':sc>=400?'warn':'info')+'" style="font-size:9px;">'+sc+'×'+cnt+'</span>').join(' ');return'<tr><td><span class="method-badge method-'+e.method+'">'+e.method+'</span></td><td style="font-family:\'JetBrains Mono\',monospace;font-size:11.5px;" class="truncate">'+esc(e.path)+'</td><td style="font-weight:700;color:var(--red);">'+e.errors+'</td><td style="color:'+(e.errors5xx>0?'var(--red)':'var(--text3)')+';">'+e.errors5xx+'</td><td style="font-weight:600;'+eCls(e.errorRate)+'">'+e.errorRate+'%</td><td>'+codes+'</td><td style="font-size:11px;color:var(--text3);">'+(e.lastSeen?new Date(e.lastSeen).toLocaleTimeString():'–')+'</td></tr>';}).join(''):'<tr><td colspan="7" style="text-align:center;color:var(--green);padding:24px;">No errors 🎉</td></tr>';}catch{}}
285
+ function downloadCsv(){const h=['Method','Path','Calls','Avg(ms)','P95(ms)','Max(ms)','AvgResBytes','Errors','ErrorRate%'];const rows=_ep.map(e=>[e.method,e.path,e.count,e.avgDuration,e.p95Duration,e.maxDuration,e.avgResBytes,e.errors,e.errorRate]);const csv=[h,...rows].map(r=>r.map(v=>'"'+String(v||'').replace(/"/g,'""')+'"').join(',')).join('\n');const a=document.createElement('a');a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv);a.download='api-analytics-'+gp().date+'.csv';a.click();}
286
+
287
+ // Trend
288
+ function switchTrendTab(i){[0,1,2].forEach(j=>{document.getElementById('tpanel-'+j).style.display=j===i?'':'none';document.getElementById('ttab-'+j).style.borderBottomColor=j===i?'var(--accent)':'transparent';document.getElementById('ttab-'+j).style.color=j===i?'var(--accent-l)':'var(--text2)';});}
289
+ (function(){const e=new Date(),s=new Date(e);s.setDate(s.getDate()-6);const f=d=>d.toISOString().slice(0,10);const te=document.getElementById('trend-end'),ts=document.getElementById('trend-start');if(te)te.value=f(e);if(ts)ts.value=f(s);})();
290
+ async function loadTrends(){const{svc}=gp();const s=document.getElementById('trend-start')?.value,e=document.getElementById('trend-end')?.value;if(!svc||!s||!e)return;const lbl=document.getElementById('trend-label');if(lbl)lbl.textContent=s+' → '+e;const ld=document.getElementById('trend-loading');if(ld)ld.style.display='';try{const[sd,pd,ed]=await Promise.all([afetch('/api/api-analytics/slow-trend?service='+encodeURIComponent(svc)+'&start='+s+'&end='+e),afetch('/api/api-analytics/hourly-pattern?service='+encodeURIComponent(svc)+'&start='+s+'&end='+e),afetch('/api/api-analytics/error-trend?service='+encodeURIComponent(svc)+'&start='+s+'&end='+e)]);renderSlow(sd);renderPat(pd);renderErrTrend(ed);}catch(err){if(ld)ld.textContent='Failed: '+err.message;}finally{if(ld)ld.style.display='none';}}
291
+ function renderSlow(data){const lbl=data.map(d=>d.date.slice(5));if(_stTC)_stTC.destroy();_stTC=new Chart(document.getElementById('slow-trend-chart'),{type:'line',data:{labels:lbl,datasets:[{label:'Avg (ms)',data:data.map(d=>d.avgDuration),borderColor:'rgba(99,102,241,.9)',backgroundColor:'rgba(99,102,241,.1)',borderWidth:2,tension:.3,pointRadius:3,fill:true},{label:'P95 (ms)',data:data.map(d=>d.p95Duration),borderColor:'rgba(245,158,11,.8)',backgroundColor:'transparent',borderWidth:2,tension:.3,pointRadius:3,borderDash:[4,3]}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:tc(),font:{size:11}}}},scales:{x:{ticks:{color:tc(),font:{size:10}},grid:{color:gc()}},y:{ticks:{color:tc(),font:{size:10},callback:v=>v+'ms'},grid:{color:gc()}}}}});const wd=data.filter(d=>d.totalRequests>0);if(!wd.length)return;const wo=wd.reduce((a,b)=>a.avgDuration>b.avgDuration?a:b),be=wd.reduce((a,b)=>a.avgDuration<b.avgDuration?a:b);const m=document.getElementById('slow-trend-meta');if(m)m.innerHTML='<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);">Slowest day</div><div style="font-size:13px;font-weight:600;color:var(--red);">'+wo.date+'</div><div style="font-size:11px;color:var(--text2);">'+fmtMs(wo.avgDuration)+' avg</div></div><div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);">Fastest day</div><div style="font-size:13px;font-weight:600;color:var(--green);">'+be.date+'</div><div style="font-size:11px;color:var(--text2);">'+fmtMs(be.avgDuration)+' avg</div></div>';}
292
+ function renderPat(data){const bg=data.map(v=>{const p=v.avgDuration/Math.max(...data.map(d=>d.avgDuration),1);return p<.4?'rgba(34,197,94,.65)':p<.7?'rgba(245,158,11,.65)':'rgba(239,68,68,.75)';});if(_ptC)_ptC.destroy();_ptC=new Chart(document.getElementById('pattern-chart'),{type:'bar',data:{labels:data.map(d=>d.hour+':00'),datasets:[{label:'Avg (ms)',data:data.map(d=>d.avgDuration),backgroundColor:bg,borderRadius:2}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{color:tc(),font:{size:10},maxTicksLimit:12},grid:{color:gc()}},y:{ticks:{color:tc(),font:{size:10},callback:v=>v+'ms'},grid:{color:gc()}}}}});}
293
+ function renderErrTrend(data){const rates=data.map(d=>parseFloat(d.errorRate)||0),counts=data.map(d=>d.errors);if(_etC)_etC.destroy();_etC=new Chart(document.getElementById('error-trend-chart'),{type:'line',data:{labels:data.map(d=>d.date.slice(5)),datasets:[{label:'Error rate (%)',data:rates,borderColor:'rgba(239,68,68,.9)',backgroundColor:'rgba(239,68,68,.1)',borderWidth:2,tension:.3,pointRadius:3,fill:true,yAxisID:'y'},{label:'Error count',data:counts,borderColor:'rgba(245,158,11,.7)',backgroundColor:'transparent',borderWidth:1.5,tension:.3,pointRadius:2,borderDash:[4,3],yAxisID:'y2'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:tc(),font:{size:11}}}},scales:{x:{ticks:{color:tc(),font:{size:10}},grid:{color:gc()}},y:{position:'left',ticks:{color:tc(),font:{size:10},callback:v=>v+'%'},grid:{color:gc()}},y2:{position:'right',ticks:{color:tc(),font:{size:10}},grid:{display:false}}}}});}
294
+ </script>
295
+
296
+ <!-- ── Error Clustering ────────────────────────────────────────── -->
297
+ <div class="card" style="margin-top:12px;" id="cluster-section">
298
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
299
+ <div class="card-title">Error Clustering</div>
300
+ <div style="display:flex;gap:8px;align-items:center;">
301
+ <input type="date" id="cluster-from" class="form-input" style="width:140px;" value="<%= typeof selected !== 'undefined' && selected.date ? selected.date : today %>"/>
302
+ <span style="font-size:11px;color:var(--text3);">–</span>
303
+ <input type="date" id="cluster-to" class="form-input" style="width:140px;" value="<%= today %>"/>
304
+ <button class="btn btn-primary btn-sm" onclick="runClusters()">Cluster Errors</button>
305
+ </div>
306
+ </div>
307
+ <div id="cluster-results">
308
+ <div style="text-align:center;padding:24px;color:var(--text3);font-size:12px;">
309
+ Select a date range and click Cluster Errors to group similar error patterns
310
+ </div>
311
+ </div>
312
+ </div>
313
+ <script>
314
+ async function runClusters(){
315
+ const svc=document.querySelector('[name="service"]')?.value || (document.getElementById('svc-select')||{}).value;
316
+ if(!svc){toast('Select a service first','error');return;}
317
+ const from=document.getElementById('cluster-from').value;
318
+ const to =document.getElementById('cluster-to').value;
319
+ if(!from||!to){toast('Select a date range','error');return;}
320
+ const el=document.getElementById('cluster-results');
321
+ el.innerHTML='<div style="text-align:center;padding:20px;color:var(--text3)">Clustering errors…</div>';
322
+ try{
323
+ const r=await fetch('/api/logs/clusters-range/'+encodeURIComponent(svc)+'?fromDate='+from+'&toDate='+to);
324
+ const d=await r.json();
325
+ if(!d.length){el.innerHTML='<div style="text-align:center;padding:20px;color:var(--text3)">No error clusters found in this range</div>';return;}
326
+ el.innerHTML='<div style="font-size:12px;color:var(--text2);margin-bottom:8px;">'+(d.length)+' unique error patterns</div>'
327
+ +'<div class="table-wrap"><table><thead><tr><th>Pattern</th><th>Count</th><th>First</th><th>Last</th></tr></thead><tbody>'
328
+ +d.slice(0,20).map(c=>'<tr><td style="font-family:monospace;font-size:11px;max-width:350px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+escHtml(c.pattern)+'</td><td style="text-align:right;color:var(--red);font-weight:600;">'+c.count+'</td><td style="font-size:10px;color:var(--text3);">'+((c.first||'')).slice(0,10)+'</td><td style="font-size:10px;color:var(--text3);">'+((c.last||'')).slice(0,10)+'</td></tr>').join('')
329
+ +'</tbody></table></div>';
330
+ }catch(e){el.innerHTML='<div style="color:var(--red);padding:20px">Error: '+e.message+'</div>';}
331
+ }
332
+ function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
333
+ </script>
334
+ </body></html>
@@ -0,0 +1,74 @@
1
+ <%- include('partials/head', { title: title }) %>
2
+ <div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:20px;">
3
+ <div style="width:100%;max-width:420px;">
4
+ <div class="card" style="padding:32px;">
5
+ <!-- Logo -->
6
+ <div style="text-align:center;margin-bottom:24px;">
7
+ <% if(settings&&settings.appLogoUrl&&settings.appLogoUrl!=='/public/logo.png'){ %>
8
+ <img src="<%= settings.appLogoUrl %>" style="height:40px;margin-bottom:12px;" alt="Logo"/>
9
+ <% } else { %>
10
+ <div style="width:44px;height:44px;background:var(--accent);border-radius:10px;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;">
11
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
12
+ </div>
13
+ <% } %>
14
+ <div style="font-size:18px;font-weight:600;color:var(--text);">
15
+ <%= settings&&settings.appName ? settings.appName : 'LogBoard' %>
16
+ </div>
17
+ </div>
18
+
19
+ <% if(expired){ %>
20
+ <div style="text-align:center;padding:16px 0;">
21
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="1.5" style="margin-bottom:12px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
22
+ <div style="font-size:16px;font-weight:500;color:var(--text);margin-bottom:8px;">Invite expired</div>
23
+ <p style="font-size:13px;color:var(--text2);">This invite link has expired or been used. Contact your admin for a new one.</p>
24
+ </div>
25
+ <% } else { %>
26
+ <div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:13px;">
27
+ You've been invited to <strong style="color:var(--accent-l);"><%= invite.orgName||invite.orgSlug %></strong> as
28
+ <span style="background:var(--accent-dim);color:var(--accent-l);padding:1px 7px;border-radius:4px;font-weight:600;font-size:11px;text-transform:uppercase;"><%= invite.role %></span>
29
+ </div>
30
+
31
+ <div id="inv-error" style="display:none;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:var(--red);padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:14px;"></div>
32
+
33
+ <div class="form-group">
34
+ <label class="form-label">Username</label>
35
+ <input type="text" id="inv-username" class="form-input" placeholder="Choose a username"/>
36
+ </div>
37
+ <div class="form-group">
38
+ <label class="form-label">Password</label>
39
+ <input type="password" id="inv-password" class="form-input" placeholder="Min 8 characters"/>
40
+ </div>
41
+ <div class="form-group">
42
+ <label class="form-label">Email</label>
43
+ <input type="email" id="inv-email" class="form-input" value="<%= invite.email||'' %>" readonly style="opacity:.7;cursor:not-allowed;"/>
44
+ </div>
45
+ <button class="btn btn-primary" style="width:100%;margin-top:8px;" id="inv-btn" onclick="acceptInvite()">Join Organisation</button>
46
+ <% } %>
47
+ </div>
48
+ <p style="text-align:center;font-size:11px;color:var(--text3);margin-top:16px;">Powered by LogBoard</p>
49
+ </div>
50
+ </div>
51
+
52
+ <script>
53
+ async function acceptInvite() {
54
+ const username = document.getElementById('inv-username').value.trim();
55
+ const password = document.getElementById('inv-password').value;
56
+ const errEl = document.getElementById('inv-error');
57
+ const btn = document.getElementById('inv-btn');
58
+ if (!username || !password) { errEl.textContent='Username and password required'; errEl.style.display=''; return; }
59
+ if (password.length < 8) { errEl.textContent='Password must be at least 8 characters'; errEl.style.display=''; return; }
60
+ btn.disabled = true; btn.textContent = 'Joining…';
61
+ try {
62
+ const r = await fetch('/api/orgs/invites/accept', {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ token: '<%= invite.token %>', username, password }),
66
+ });
67
+ const d = await r.json();
68
+ if (!r.ok) { errEl.textContent = d.error||'Failed'; errEl.style.display=''; btn.disabled=false; btn.textContent='Join Organisation'; return; }
69
+ // Redirect to login with org context
70
+ location.href = '/login?org=<%= invite.orgSlug %>&username=' + encodeURIComponent(username) + '&joined=1';
71
+ } catch(e) { errEl.textContent='Network error'; errEl.style.display=''; btn.disabled=false; btn.textContent='Join Organisation'; }
72
+ }
73
+ </script>
74
+ </body></html>