@greenarmor/ges-web-dashboard 1.1.7 → 1.2.1

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/template.js CHANGED
@@ -8,17 +8,16 @@ function gradeColor(grade) {
8
8
  default: return "#6b7280";
9
9
  }
10
10
  }
11
- function severityBadge(severity) {
11
+ function severityColor(severity) {
12
12
  const colors = {
13
13
  critical: "#ef4444",
14
14
  high: "#f97316",
15
15
  medium: "#eab308",
16
16
  low: "#3b82f6",
17
17
  };
18
- const color = colors[severity] || "#6b7280";
19
- return `<span style="background:${color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severity.toUpperCase()}</span>`;
18
+ return colors[severity] || "#6b7280";
20
19
  }
21
- function statusBadge(status) {
20
+ function statusColor(status) {
22
21
  const colors = {
23
22
  pass: "#22c55e",
24
23
  fail: "#ef4444",
@@ -26,6 +25,9 @@ function statusBadge(status) {
26
25
  "not-implemented": "#6b7280",
27
26
  "not-applicable": "#9ca3af",
28
27
  };
28
+ return colors[status] || "#6b7280";
29
+ }
30
+ function statusLabel(status) {
29
31
  const labels = {
30
32
  pass: "PASS",
31
33
  fail: "FAIL",
@@ -33,32 +35,7 @@ function statusBadge(status) {
33
35
  "not-implemented": "NOT IMPL",
34
36
  "not-applicable": "N/A",
35
37
  };
36
- const color = colors[status] || "#6b7280";
37
- const label = labels[status] || status.toUpperCase();
38
- return `<span style="background:${color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${label}</span>`;
39
- }
40
- function scoreBar(score) {
41
- const color = score >= 80 ? "#22c55e" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
42
- return `<div style="width:100%;background:#e5e7eb;border-radius:4px;height:8px;margin-top:4px;">
43
- <div style="width:${score}%;background:${color};height:8px;border-radius:4px;"></div>
44
- </div>`;
45
- }
46
- function donutSvg(passed, total) {
47
- const r = 54;
48
- const cx = 70;
49
- const cy = 70;
50
- const circumference = 2 * Math.PI * r;
51
- const pct = total > 0 ? passed / total : 0;
52
- const offset = circumference * (1 - pct);
53
- const color = pct >= 0.8 ? "#22c55e" : pct >= 0.6 ? "#eab308" : pct >= 0.4 ? "#f97316" : "#ef4444";
54
- return `<svg width="140" height="140" viewBox="0 0 140 140">
55
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
56
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="12"
57
- stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
58
- stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
59
- <text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="28" font-weight="700" fill="#1f2937">${Math.round(pct * 100)}%</text>
60
- <text x="${cx}" y="${cy + 16}" text-anchor="middle" font-size="11" fill="#6b7280">${passed}/${total} passed</text>
61
- </svg>`;
38
+ return labels[status] || status.toUpperCase();
62
39
  }
63
40
  export function renderDashboard(data) {
64
41
  const score = data.score;
@@ -67,6 +44,7 @@ export function renderDashboard(data) {
67
44
  const frameworks = score?.frameworks || {};
68
45
  const findings = data.findings;
69
46
  const controls = data.controls;
47
+ const packs = data.packs;
70
48
  const findingsBySeverity = {
71
49
  critical: findings.filter(f => f.severity === "critical").length,
72
50
  high: findings.filter(f => f.severity === "high").length,
@@ -82,6 +60,52 @@ export function renderDashboard(data) {
82
60
  };
83
61
  const frameworkKeys = Object.keys(frameworks);
84
62
  const missingControls = controls.filter(c => c.status !== "pass" && c.status !== "not-applicable");
63
+ const findingsByPackId = {};
64
+ for (const f of findings) {
65
+ for (const cid of f.controlIds) {
66
+ const pack = packs.find(p => {
67
+ const pControls = getAllControlsForPack(p.id, controls);
68
+ return pControls.some(c => c.id === cid);
69
+ });
70
+ if (pack) {
71
+ if (!findingsByPackId[pack.id])
72
+ findingsByPackId[pack.id] = [];
73
+ if (!findingsByPackId[pack.id].includes(f))
74
+ findingsByPackId[pack.id].push(f);
75
+ }
76
+ }
77
+ }
78
+ function getAllControlsForPack(packId, allControls) {
79
+ return allControls.filter(c => {
80
+ const idUpper = c.id.toUpperCase();
81
+ const packPrefix = packId.toUpperCase().replace(/-/g, "");
82
+ if (packId === "gdpr")
83
+ return idUpper.startsWith("GDPR-");
84
+ if (packId === "owasp")
85
+ return idUpper.startsWith("OWASP-");
86
+ if (packId === "cis")
87
+ return idUpper.startsWith("CIS-");
88
+ if (packId === "nist")
89
+ return idUpper.startsWith("NIST-");
90
+ if (packId === "ai")
91
+ return idUpper.startsWith("AI-");
92
+ if (packId === "blockchain")
93
+ return idUpper.startsWith("BC-");
94
+ if (packId === "government")
95
+ return idUpper.startsWith("GOV-");
96
+ if (packId === "iso27001")
97
+ return idUpper.startsWith("ISO27K-");
98
+ if (packId === "iso27701")
99
+ return idUpper.startsWith("ISO277-");
100
+ if (packId === "hipaa")
101
+ return idUpper.startsWith("HIPAA-");
102
+ return false;
103
+ });
104
+ }
105
+ const packJson = JSON.stringify(packs.map(p => ({
106
+ ...p,
107
+ _controls: undefined,
108
+ })));
85
109
  return `<!DOCTYPE html>
86
110
  <html lang="en">
87
111
  <head>
@@ -91,177 +115,1084 @@ export function renderDashboard(data) {
91
115
  <style>
92
116
  * { margin: 0; padding: 0; box-sizing: border-box; }
93
117
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.6; }
94
- .header { background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: white; padding: 24px 32px; }
118
+ .header { background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: white; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
95
119
  .header h1 { font-size: 24px; font-weight: 700; }
96
120
  .header .subtitle { font-size: 14px; opacity: 0.9; margin-top: 4px; }
97
- .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
121
+ .nav-tabs { display: flex; gap: 4px; }
122
+ .nav-tab { background: rgba(255,255,255,0.15); color: white; border: none; padding: 8px 18px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.2s; }
123
+ .nav-tab:hover { background: rgba(255,255,255,0.25); }
124
+ .nav-tab.active { background: #f3f4f6; color: #0f766e; }
125
+ .container { max-width: 1400px; margin: 0 auto; padding: 24px; }
126
+ .page { display: none; }
127
+ .page.active { display: block; }
98
128
  .grid { display: grid; gap: 20px; }
99
129
  .grid-2 { grid-template-columns: 1fr 1fr; }
100
130
  .grid-3 { grid-template-columns: repeat(3, 1fr); }
101
131
  .grid-4 { grid-template-columns: repeat(4, 1fr); }
102
- @media (max-width: 768px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
132
+ @media (max-width: 900px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
103
133
  .card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
104
134
  .card-title { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; margin-bottom: 12px; }
105
135
  .big-number { font-size: 42px; font-weight: 700; }
106
- .flex-center { display: flex; align-items: center; justify-content: center; }
107
136
  .stat { text-align: center; }
108
137
  .stat .num { font-size: 32px; font-weight: 700; }
109
138
  .stat .label { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
110
139
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
111
140
  th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #e5e7eb; font-weight: 600; color: #6b7280; font-size: 11px; text-transform: uppercase; }
112
- td { padding: 10px 8px; border-bottom: 1px solid #f3f4f6; }
141
+ td { padding: 10px 8px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
113
142
  tr:hover td { background: #f9fafb; }
114
- .badge-row { display: flex; gap: 6px; flex-wrap: wrap; }
115
143
  .framework-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f3f4f6; }
116
144
  .framework-row:last-child { border-bottom: none; }
117
145
  .framework-name { font-size: 14px; font-weight: 600; min-width: 140px; }
118
146
  .footer { text-align: center; padding: 24px; color: #9ca3af; font-size: 12px; }
119
- a { color: #0f766e; text-decoration: none; }
120
- a:hover { text-decoration: underline; }
147
+ a, .link { color: #0f766e; text-decoration: none; cursor: pointer; }
148
+ a:hover, .link:hover { text-decoration: underline; }
121
149
  .pct-text { font-size: 13px; font-weight: 600; min-width: 50px; text-align: right; }
122
150
  .bar-container { flex: 1; margin: 0 12px; }
123
151
  .tag { display: inline-block; background: #e0f2fe; color: #0369a1; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; margin: 2px; }
152
+ .badge { display: inline-block; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
153
+ .badge-sev { min-width: 60px; text-align: center; }
154
+ .badge-status { min-width: 70px; text-align: center; }
155
+
156
+ .score-bar-wrap { width: 100%; background: #e5e7eb; border-radius: 4px; height: 8px; margin-top: 4px; }
157
+ .score-bar-fill { height: 8px; border-radius: 4px; transition: width 0.4s; }
158
+
159
+ .pack-card { cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s, box-shadow 0.2s; }
160
+ .pack-card:hover { border-color: #14b8a6; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
161
+ .pack-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
162
+ .pack-name { font-size: 16px; font-weight: 700; color: #1f2937; }
163
+ .pack-score { font-size: 24px; font-weight: 700; }
164
+ .pack-desc { font-size: 13px; color: #6b7280; margin-bottom: 12px; line-height: 1.5; }
165
+ .pack-stats { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #4b5563; }
166
+ .pack-stats span { display: flex; align-items: center; gap: 4px; }
167
+
168
+ .detail-back { display: inline-flex; align-items: center; gap: 6px; color: #0f766e; font-size: 14px; font-weight: 600; cursor: pointer; margin-bottom: 16px; }
169
+ .detail-back:hover { text-decoration: underline; }
170
+ .detail-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; }
171
+ .detail-title { font-size: 22px; font-weight: 700; }
172
+ .detail-meta { font-size: 13px; color: #6b7280; }
173
+
174
+ .control-row { cursor: pointer; }
175
+ .control-row:hover td { background: #ecfdf5; }
176
+
177
+ .findings-trace { margin-top: 8px; }
178
+ .trace-item { background: #fef2f2; border-left: 3px solid #ef4444; padding: 10px 14px; border-radius: 0 6px 6px 0; margin-bottom: 8px; font-size: 13px; }
179
+ .trace-item.high { background: #fff7ed; border-left-color: #f97316; }
180
+ .trace-item.medium { background: #fefce8; border-left-color: #eab308; }
181
+ .trace-item.low { background: #eff6ff; border-left-color: #3b82f6; }
182
+ .trace-title { font-weight: 600; margin-bottom: 4px; }
183
+ .trace-detail { color: #6b7280; font-size: 12px; }
184
+ .trace-fix { background: #f0fdf4; border-left: 3px solid #22c55e; padding: 10px 14px; border-radius: 0 6px 6px 0; margin: 6px 0 12px 6px; font-size: 13px; }
185
+ .trace-fix-label { font-weight: 600; color: #166534; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
186
+
187
+ .guidance-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; margin-top: 12px; }
188
+ .guidance-box h4 { font-size: 13px; color: #166534; margin-bottom: 8px; }
189
+ .guidance-box p { font-size: 13px; color: #374151; line-height: 1.6; }
190
+
191
+ .check-list { list-style: none; padding: 0; }
192
+ .check-list li { padding: 6px 0; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; gap: 8px; font-size: 13px; }
193
+ .check-list li:last-child { border-bottom: none; }
194
+ .check-icon { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: 700; }
195
+
196
+ .breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
197
+ .breadcrumb span { color: #0f766e; cursor: pointer; }
198
+ .breadcrumb span:hover { text-decoration: underline; }
199
+
200
+ .fix-detail-card { background: white; border-radius: 12px; padding: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; border: 1px solid #e5e7eb; }
201
+ .fix-detail-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; }
202
+ .fix-detail-header:hover { background: #f9fafb; }
203
+ .fix-detail-header.critical { border-left: 4px solid #ef4444; }
204
+ .fix-detail-header.high { border-left: 4px solid #f97316; }
205
+ .fix-detail-header.medium { border-left: 4px solid #eab308; }
206
+ .fix-detail-header.low { border-left: 4px solid #3b82f6; }
207
+ .fix-detail-num { font-size: 24px; font-weight: 700; min-width: 40px; text-align: center; }
208
+ .fix-detail-info { flex: 1; }
209
+ .fix-detail-title { font-size: 15px; font-weight: 700; color: #1f2937; }
210
+ .fix-detail-meta { font-size: 12px; color: #6b7280; margin-top: 3px; }
211
+ .fix-detail-badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
212
+ .fix-detail-body { display: none; padding: 0 20px 20px; border-top: 1px solid #f3f4f6; }
213
+ .fix-detail-body.open { display: block; }
214
+ .fix-section { margin-top: 16px; }
215
+ .fix-section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #374151; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; }
216
+ .fix-finding-item { background: #fef2f2; border-left: 3px solid #ef4444; padding: 10px 14px; border-radius: 0 6px 6px 0; margin-bottom: 8px; }
217
+ .fix-finding-item.high { background: #fff7ed; border-left-color: #f97316; }
218
+ .fix-finding-item.medium { background: #fefce8; border-left-color: #eab308; }
219
+ .fix-finding-item.low { background: #eff6ff; border-left-color: #3b82f6; }
220
+ .fix-guidance-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 16px; font-size: 13px; color: #374151; line-height: 1.7; }
221
+ .fix-guidance-box strong { color: #166534; }
222
+ .fix-checks-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
223
+ @media (max-width: 768px) { .fix-checks-grid { grid-template-columns: 1fr; } }
224
+ .fix-check-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 13px; background: #f9fafb; }
225
+ .fix-check-pass { background: #f0fdf4; }
226
+ .fix-check-fail { background: #fef2f2; }
227
+ .fix-check-icon { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: 700; flex-shrink: 0; }
228
+ .fix-toggle { font-size: 12px; color: #0f766e; cursor: pointer; font-weight: 600; }
229
+ .fix-toggle:hover { text-decoration: underline; }
230
+ .fix-evidence { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; font-family: monospace; font-size: 11px; color: #4b5563; margin-top: 4px; white-space: pre-wrap; word-break: break-all; }
231
+
232
+ .tab-bar { display: flex; gap: 2px; border-bottom: 2px solid #e5e7eb; margin-bottom: 16px; }
233
+ .tab-btn { padding: 8px 16px; font-size: 13px; font-weight: 600; color: #6b7280; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; margin-bottom: -2px; transition: color 0.2s, border-color 0.2s; }
234
+ .tab-btn:hover { color: #0f766e; }
235
+ .tab-btn.active { color: #0f766e; border-bottom-color: #0f766e; }
236
+ .tab-panel { display: none; }
237
+ .tab-panel.active { display: block; }
238
+
239
+ .empty-state { text-align: center; padding: 40px 20px; color: #9ca3af; }
240
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
241
+ .empty-state .msg { font-size: 15px; font-weight: 600; }
242
+ .empty-state .sub { font-size: 13px; margin-top: 4px; }
243
+
244
+ @media (max-width: 768px) {
245
+ .header { padding: 16px; }
246
+ .container { padding: 16px; }
247
+ .nav-tabs { width: 100%; overflow-x: auto; }
248
+ }
124
249
  </style>
125
250
  </head>
126
251
  <body>
127
252
 
128
253
  <div class="header">
129
- <h1>GESF Compliance Dashboard</h1>
130
- <div class="subtitle">${escapeHtml(data.projectName)} | ${escapeHtml(data.projectType)} | GESF v${escapeHtml(data.gesfVersion)}</div>
254
+ <div>
255
+ <h1>GESF Compliance Dashboard</h1>
256
+ <div class="subtitle">${escapeHtml(data.projectName)} | ${escapeHtml(data.projectType)} | GESF v${escapeHtml(data.gesfVersion)}</div>
257
+ </div>
258
+ <div class="nav-tabs">
259
+ <button class="nav-tab active" onclick="showPage('overview', this)">Overview</button>
260
+ <button class="nav-tab" onclick="showPage('packs', this)">Policy Packs</button>
261
+ <button class="nav-tab" onclick="showPage('fixes', this)">Fixes Detail</button>
262
+ <button class="nav-tab" onclick="showPage('findings', this)">Findings</button>
263
+ <button class="nav-tab" onclick="showPage('traceability', this)">Traceability</button>
264
+ </div>
131
265
  </div>
132
266
 
133
267
  <div class="container">
134
- <div class="grid">
135
268
 
136
- <div class="grid grid-3">
137
- <div class="card stat">
138
- ${donutSvg(score ? Object.values(frameworks).reduce((n, f) => n + f.passed_controls, 0) : 0, controls.length || 1)}
139
- <div class="label">Overall Compliance</div>
140
- </div>
141
- <div class="card">
142
- <div class="card-title">Overall Score</div>
143
- <div class="big-number" style="color:${gradeColor(overallGrade)};">${overall}%</div>
144
- <div style="margin-top:4px;"><span style="background:${gradeColor(overallGrade)};color:white;padding:4px 16px;border-radius:6px;font-weight:700;">Grade: ${overallGrade}</span></div>
145
- ${scoreBar(overall)}
146
- </div>
147
- <div class="card">
148
- <div class="card-title">Security Findings</div>
149
- <div class="big-number" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'};">${findings.length}</div>
150
- <div style="margin-top:8px;font-size:13px;color:#6b7280;">
151
- <span style="color:#ef4444;font-weight:600;">${findingsBySeverity.critical} critical</span> |
152
- <span style="color:#f97316;font-weight:600;">${findingsBySeverity.high} high</span> |
153
- <span style="color:#eab308;font-weight:600;">${findingsBySeverity.medium} medium</span> |
154
- <span style="color:#3b82f6;font-weight:600;">${findingsBySeverity.low} low</span>
269
+ <div id="page-overview" class="page active">
270
+ <div class="grid">
271
+ <div class="grid grid-3">
272
+ <div class="card stat">
273
+ ${donutSvg(controls.filter(c => c.status === "pass" || c.status === "not-applicable").length, controls.length || 1)}
274
+ <div class="label">Overall Compliance</div>
275
+ </div>
276
+ <div class="card">
277
+ <div class="card-title">Overall Score</div>
278
+ <div class="big-number" style="color:${gradeColor(overallGrade)};">${overall}%</div>
279
+ <div style="margin-top:4px;"><span class="badge" style="background:${gradeColor(overallGrade)};padding:4px 16px;border-radius:6px;font-size:14px;">Grade: ${overallGrade}</span></div>
280
+ ${scoreBarHtml(overall)}
281
+ </div>
282
+ <div class="card">
283
+ <div class="card-title">Security Findings</div>
284
+ <div class="big-number" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'};">${findings.length}</div>
285
+ <div style="margin-top:8px;font-size:13px;color:#6b7280;">
286
+ <span style="color:#ef4444;font-weight:600;">${findingsBySeverity.critical} critical</span> |
287
+ <span style="color:#f97316;font-weight:600;">${findingsBySeverity.high} high</span> |
288
+ <span style="color:#eab308;font-weight:600;">${findingsBySeverity.medium} medium</span> |
289
+ <span style="color:#3b82f6;font-weight:600;">${findingsBySeverity.low} low</span>
290
+ </div>
155
291
  </div>
156
292
  </div>
157
- </div>
158
-
159
- <div class="grid grid-2">
160
293
 
161
- <div class="card">
162
- <div class="card-title">Framework Scores</div>
163
- ${frameworkKeys.length > 0 ? frameworkKeys.map(fw => {
294
+ <div class="grid grid-2">
295
+ <div class="card">
296
+ <div class="card-title">Framework Scores</div>
297
+ ${frameworkKeys.length > 0 ? frameworkKeys.map(fw => {
164
298
  const f = frameworks[fw];
165
299
  const pct = f.score;
166
300
  const color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
167
301
  return `<div class="framework-row">
168
- <div class="framework-name">${escapeHtml(fw)}</div>
169
- <div class="bar-container">${scoreBar(pct)}</div>
170
- <div class="pct-text" style="color:${color};">${pct}% (${f.grade})</div>
171
- </div>`;
172
- }).join('') : '<div style="padding:20px;text-align:center;color:#9ca3af;">No framework data. Run "ges score".</div>'}
302
+ <div class="framework-name">${escapeHtml(fw)}</div>
303
+ <div class="bar-container">${scoreBarHtml(pct)}</div>
304
+ <div class="pct-text" style="color:${color};">${pct}% (${f.grade})</div>
305
+ </div>`;
306
+ }).join('') : '<div class="empty-state"><div class="msg">No framework data</div><div class="sub">Run "ges score" to generate scores</div></div>'}
307
+ </div>
308
+
309
+ <div class="card">
310
+ <div class="card-title">Control Status Breakdown</div>
311
+ <div class="grid grid-3" style="gap:12px;">
312
+ <div class="stat"><div class="num" style="color:#22c55e;">${controlsByStatus.pass}</div><div class="label">Pass</div></div>
313
+ <div class="stat"><div class="num" style="color:#ef4444;">${controlsByStatus.fail}</div><div class="label">Fail</div></div>
314
+ <div class="stat"><div class="num" style="color:#eab308;">${controlsByStatus.warning}</div><div class="label">Warning</div></div>
315
+ <div class="stat"><div class="num" style="color:#6b7280;">${controlsByStatus["not-implemented"]}</div><div class="label">Not Impl</div></div>
316
+ <div class="stat"><div class="num" style="color:#9ca3af;">${controlsByStatus["not-applicable"]}</div><div class="label">N/A</div></div>
317
+ <div class="stat"><div class="num">${controls.length}</div><div class="label">Total</div></div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <div class="grid grid-2">
323
+ <div class="card">
324
+ <div class="card-title">Security Findings Detail (${findings.length})</div>
325
+ ${findings.length > 0 ? `<table>
326
+ <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th><th>Controls</th></tr></thead>
327
+ <tbody>
328
+ ${findings.slice(0, 20).map(f => `<tr>
329
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
330
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
331
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
332
+ <td>${escapeHtml(f.title)}</td>
333
+ <td>${f.controlIds.length > 0 ? f.controlIds.map(cid => `<span class="tag" style="cursor:pointer;" onclick="showControlDetail('${escapeHtml(cid)}')">${escapeHtml(cid)}</span>`).join(' ') : '<span style="color:#9ca3af;">-</span>'}</td>
334
+ </tr>`).join('')}
335
+ </tbody>
336
+ </table>
337
+ ${findings.length > 20 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${findings.length - 20} more findings</div>` : ''}` : '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No security findings</div><div class="sub">Project is clean</div></div>'}
338
+ </div>
339
+
340
+ <div class="card">
341
+ <div class="card-title">Missing Controls (${missingControls.length})</div>
342
+ ${missingControls.length > 0 ? `<table>
343
+ <thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th><th>Findings</th></tr></thead>
344
+ <tbody>
345
+ ${missingControls.slice(0, 20).map(c => {
346
+ const ctrlFindings = findings.filter(f => f.controlIds.includes(c.id));
347
+ return `<tr class="control-row" onclick="showControlDetail('${escapeHtml(c.id)}')">
348
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(c.id)}</td>
349
+ <td>${escapeHtml(c.name)}</td>
350
+ <td><span class="badge badge-sev" style="background:${severityColor(c.severity)}">${c.severity.toUpperCase()}</span></td>
351
+ <td><span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span></td>
352
+ <td>${ctrlFindings.length > 0 ? `<span style="color:#ef4444;font-weight:600;">${ctrlFindings.length}</span>` : '<span style="color:#9ca3af;">0</span>'}</td>
353
+ </tr>`;
354
+ }).join('')}
355
+ </tbody>
356
+ </table>
357
+ ${missingControls.length > 20 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${missingControls.length - 20} more</div>` : ''}` : '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">All controls passing</div></div>'}
358
+ </div>
173
359
  </div>
174
360
 
175
361
  <div class="card">
176
- <div class="card-title">Control Status Breakdown</div>
177
- <div class="grid grid-3" style="gap:12px;">
178
- <div class="stat">
179
- <div class="num" style="color:#22c55e;">${controlsByStatus.pass}</div>
180
- <div class="label">Pass</div>
181
- </div>
182
- <div class="stat">
183
- <div class="num" style="color:#ef4444;">${controlsByStatus.fail}</div>
184
- <div class="label">Fail</div>
185
- </div>
186
- <div class="stat">
187
- <div class="num" style="color:#eab308;">${controlsByStatus.warning}</div>
188
- <div class="label">Warning</div>
189
- </div>
190
- <div class="stat">
191
- <div class="num" style="color:#6b7280;">${controlsByStatus["not-implemented"]}</div>
192
- <div class="label">Not Impl</div>
362
+ <div class="card-title">Active Frameworks</div>
363
+ <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
364
+ ${data.frameworks.map(fw => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(fw)}</span>`).join('') || '<span style="color:#9ca3af;">No frameworks configured</span>'}
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ <div id="page-packs" class="page">
371
+ <div id="packs-list">
372
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:16px;">Policy Packs &mdash; Detailed Reports</h2>
373
+ <p style="color:#6b7280;font-size:14px;margin-bottom:20px;">Click any pack to drill down into controls, findings, and fix guidance.</p>
374
+ <div class="grid grid-2">
375
+ ${packs.map(p => {
376
+ const pct = p.score;
377
+ const color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
378
+ return `<div class="card pack-card" onclick="loadPackDetail('${p.id}')">
379
+ <div class="pack-header">
380
+ <div>
381
+ <div class="pack-name">${escapeHtml(p.name)}</div>
382
+ <div style="font-size:12px;color:#6b7280;margin-top:2px;">${escapeHtml(p.id)} v${escapeHtml(p.version)}</div>
383
+ </div>
384
+ <div class="pack-score" style="color:${color};">${pct}%</div>
193
385
  </div>
194
- <div class="stat">
195
- <div class="num" style="color:#9ca3af;">${controlsByStatus["not-applicable"]}</div>
196
- <div class="label">N/A</div>
386
+ <div class="pack-desc">${escapeHtml(p.description)}</div>
387
+ ${scoreBarHtml(pct)}
388
+ <div class="pack-stats" style="margin-top:12px;">
389
+ <span><span class="badge badge-status" style="background:#22c55e;">${p.passedCount - p.notApplicableCount}</span> pass</span>
390
+ <span><span class="badge badge-status" style="background:#ef4444;">${p.failedCount}</span> fail</span>
391
+ <span><span class="badge badge-status" style="background:#eab308;">${p.warningCount}</span> warn</span>
392
+ <span><span class="badge badge-status" style="background:#6b7280;">${p.notImplementedCount}</span> not impl</span>
393
+ <span><span class="badge badge-status" style="background:#9ca3af;">${p.notApplicableCount}</span> N/A</span>
394
+ <span style="color:#ef4444;font-weight:600;">${p.findingsCount} findings</span>
395
+ <span>${p.controlCount} controls</span>
396
+ ${p.installed ? '<span style="color:#0f766e;font-weight:600;">Installed</span>' : '<span style="color:#9ca3af;">Not installed</span>'}
197
397
  </div>
198
- <div class="stat">
199
- <div class="num">${controls.length}</div>
200
- <div class="label">Total</div>
398
+ </div>`;
399
+ }).join('')}
400
+ </div>
401
+ </div>
402
+ <div id="pack-detail" style="display:none;"></div>
403
+ </div>
404
+
405
+ <div id="page-fixes" class="page">
406
+ <div class="tab-bar" style="margin-bottom:0;">
407
+ <button class="tab-btn active" onclick="showFixesTab('history', this)">Fix History (${data.fixHistory.length})</button>
408
+ <button class="tab-btn" onclick="showFixesTab('pending', this)">Pending Fixes (${findings.length})</button>
409
+ </div>
410
+
411
+ <div id="fixes-tab-history" class="tab-panel active">
412
+ ${renderFixHistorySection(data.fixHistory)}
413
+ </div>
414
+
415
+ <div id="fixes-tab-pending" class="tab-panel">
416
+ ${renderDetailedFixesList(findings, controls, packs)}
417
+ </div>
418
+ </div>
419
+
420
+ <div id="page-findings" class="page">
421
+ <div id="findings-main">
422
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:16px;">Security Findings Report</h2>
423
+ <div class="tab-bar">
424
+ <button class="tab-btn active" onclick="showFindingsTab('all', this)">All (${findings.length})</button>
425
+ <button class="tab-btn" onclick="showFindingsTab('critical', this)">Critical (${findingsBySeverity.critical})</button>
426
+ <button class="tab-btn" onclick="showFindingsTab('high', this)">High (${findingsBySeverity.high})</button>
427
+ <button class="tab-btn" onclick="showFindingsTab('medium', this)">Medium (${findingsBySeverity.medium})</button>
428
+ <button class="tab-btn" onclick="showFindingsTab('low', this)">Low (${findingsBySeverity.low})</button>
429
+ <button class="tab-btn" onclick="showFindingsTab('bypack', this)">By Pack</button>
430
+ </div>
431
+
432
+ <div id="findings-tab-all" class="tab-panel active">
433
+ ${renderFindingsTable(findings)}
434
+ </div>
435
+ <div id="findings-tab-critical" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "critical"))}</div>
436
+ <div id="findings-tab-high" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "high"))}</div>
437
+ <div id="findings-tab-medium" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "medium"))}</div>
438
+ <div id="findings-tab-low" class="tab-panel">${renderFindingsTable(findings.filter(f => f.severity === "low"))}</div>
439
+ <div id="findings-tab-bypack" class="tab-panel">
440
+ ${packs.filter(p => (findingsByPackId[p.id] || []).length > 0).length > 0 ? packs.filter(p => (findingsByPackId[p.id] || []).length > 0).map(p => `
441
+ <div class="card" style="margin-bottom:16px;">
442
+ <div class="card-title" style="cursor:pointer;" onclick="loadPackDetail('${p.id}')">
443
+ ${escapeHtml(p.name)} &mdash; ${(findingsByPackId[p.id] || []).length} findings
444
+ <span style="float:right;color:#0f766e;font-weight:400;font-size:11px;">View pack details &rarr;</span>
445
+ </div>
446
+ ${renderFindingsTable(findingsByPackId[p.id] || [])}
201
447
  </div>
202
- </div>
448
+ `).join('') : '<div class="empty-state"><div class="msg">No findings mapped to policy packs</div></div>'}
203
449
  </div>
204
450
  </div>
451
+ <div id="finding-detail" style="display:none;"></div>
452
+ </div>
205
453
 
206
- <div class="grid grid-2">
454
+ <div id="page-traceability" class="page">
455
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Fix Traceability Matrix</h2>
456
+ <p style="color:#6b7280;font-size:14px;margin-bottom:20px;">Finding &rarr; Fix &rarr; Control &rarr; Policy Pack traceability for every security issue.</p>
457
+ <div class="tab-bar">
458
+ <button class="tab-btn active" onclick="showTraceTab('matrix', this)">Matrix</button>
459
+ <button class="tab-btn" onclick="showTraceTab('fixes', this)">Prioritized Fixes</button>
460
+ <button class="tab-btn" onclick="showTraceTab('controls', this)">Control Coverage</button>
461
+ </div>
207
462
 
208
- <div class="card">
209
- <div class="card-title">Security Findings Detail (${findings.length})</div>
210
- ${findings.length > 0 ? `<table>
211
- <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th></tr></thead>
463
+ <div id="trace-tab-matrix" class="tab-panel active">
464
+ ${findings.length > 0 ? `<div class="card">
465
+ <table>
466
+ <thead><tr><th>Finding</th><th>Severity</th><th>File</th><th>Linked Controls</th><th>Policy Pack</th><th>Fix Guidance</th></tr></thead>
212
467
  <tbody>
213
- ${findings.slice(0, 15).map(f => `<tr>
214
- <td>${severityBadge(f.severity)}</td>
215
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
216
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
217
- <td>${escapeHtml(f.title)}</td>
218
- </tr>`).join('')}
468
+ ${findings.slice(0, 50).map(f => {
469
+ const linkedControls = controls.filter(c => f.controlIds.includes(c.id));
470
+ const linkedPackIds = new Set();
471
+ for (const ctrl of linkedControls) {
472
+ const pk = packs.find(pp => {
473
+ const pCtrls = getAllControlsForPack(pp.id, controls);
474
+ return pCtrls.some(c2 => c2.id === ctrl.id);
475
+ });
476
+ if (pk)
477
+ linkedPackIds.add(pk.id);
478
+ }
479
+ return `<tr>
480
+ <td>
481
+ <div style="font-weight:600;font-size:13px;">${escapeHtml(f.title)}</div>
482
+ <div style="font-size:11px;color:#6b7280;">${escapeHtml(f.ruleId)}</div>
483
+ </td>
484
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
485
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
486
+ <td>${linkedControls.length > 0 ? linkedControls.map(c => `<div style="margin-bottom:2px;"><span class="link" onclick="showControlDetail('${escapeHtml(c.id)}')">${escapeHtml(c.id)}</span> <span style="color:#6b7280;font-size:11px;">${escapeHtml(c.name)}</span></div>`).join('') : '<span style="color:#9ca3af;">No linked controls</span>'}</td>
487
+ <td>${linkedPackIds.size > 0 ? [...linkedPackIds].map(pid => {
488
+ const pk = packs.find(pp => pp.id === pid);
489
+ return pk ? `<span class="tag" style="cursor:pointer;" onclick="loadPackDetail('${pk.id}')">${escapeHtml(pk.name)}</span>` : '';
490
+ }).join(' ') : '<span style="color:#9ca3af;">-</span>'}</td>
491
+ <td style="max-width:300px;font-size:12px;color:#374151;">${escapeHtml(f.fix)}</td>
492
+ </tr>`;
493
+ }).join('')}
219
494
  </tbody>
220
495
  </table>
221
- ${findings.length > 15 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${findings.length - 15} more findings</div>` : ''}` : '<div style="padding:24px;text-align:center;color:#22c55e;font-weight:600;">No security findings. Project is clean.</div>'}
222
- </div>
496
+ ${findings.length > 50 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">Showing 50 of ${findings.length} findings</div>` : ''}
497
+ </div>` : '<div class="card"><div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No findings to trace</div><div class="sub">All clear</div></div></div>'}
498
+ </div>
499
+
500
+ <div id="trace-tab-fixes" class="tab-panel">
501
+ ${renderDetailedFixesList(findings, controls, packs)}
502
+ </div>
223
503
 
504
+ <div id="trace-tab-controls" class="tab-panel">
224
505
  <div class="card">
225
- <div class="card-title">Missing Controls (${missingControls.length})</div>
226
- ${missingControls.length > 0 ? `<table>
227
- <thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th></tr></thead>
506
+ <div class="card-title">Control Coverage by Policy Pack</div>
507
+ <table>
508
+ <thead><tr><th>Policy Pack</th><th>Total Controls</th><th>Pass</th><th>Fail</th><th>Warn</th><th>Not Impl</th><th>Coverage</th><th>Findings</th></tr></thead>
228
509
  <tbody>
229
- ${missingControls.slice(0, 15).map(c => `<tr>
230
- <td style="font-family:monospace;font-size:11px;">${escapeHtml(c.id)}</td>
231
- <td>${escapeHtml(c.name)}</td>
232
- <td>${severityBadge(c.severity)}</td>
233
- <td>${statusBadge(c.status)}</td>
234
- </tr>`).join('')}
510
+ ${packs.map(p => {
511
+ const coverage = p.controlCount > 0 ? Math.round((p.passedCount / p.controlCount) * 100) : 0;
512
+ const covColor = coverage >= 80 ? '#22c55e' : coverage >= 60 ? '#eab308' : '#f97316';
513
+ return `<tr style="cursor:pointer;" onclick="loadPackDetail('${p.id}')">
514
+ <td style="font-weight:600;">${escapeHtml(p.name)}</td>
515
+ <td>${p.controlCount}</td>
516
+ <td style="color:#22c55e;font-weight:600;">${p.passedCount}</td>
517
+ <td style="color:#ef4444;font-weight:600;">${p.failedCount}</td>
518
+ <td style="color:#eab308;font-weight:600;">${p.warningCount}</td>
519
+ <td style="color:#6b7280;">${p.notImplementedCount}</td>
520
+ <td><span style="color:${covColor};font-weight:700;">${coverage}%</span></td>
521
+ <td>${p.findingsCount > 0 ? `<span style="color:#ef4444;font-weight:600;">${p.findingsCount}</span>` : '0'}</td>
522
+ </tr>`;
523
+ }).join('')}
235
524
  </tbody>
236
525
  </table>
237
- ${missingControls.length > 15 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">... and ${missingControls.length - 15} more</div>` : ''}` : '<div style="padding:24px;text-align:center;color:#22c55e;font-weight:600;">All controls are passing.</div>'}
238
- </div>
239
- </div>
240
-
241
- <div class="card">
242
- <div class="card-title">Installed Policy Packs (${data.packs.length})</div>
243
- <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
244
- ${data.packs.map(p => `<span class="tag">${escapeHtml(p.id)} (${p.controlCount} controls)</span>`).join('')}
245
526
  </div>
246
527
  </div>
528
+ </div>
247
529
 
248
- <div class="card">
249
- <div class="card-title">Active Frameworks</div>
250
- <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
251
- ${data.frameworks.map(fw => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(fw)}</span>`).join('') || '<span style="color:#9ca3af;">No frameworks configured</span>'}
252
- </div>
253
- </div>
530
+ <div id="control-detail-modal" style="display:none;"></div>
254
531
 
255
- </div>
532
+ </div>
256
533
 
257
- <div class="footer">
258
- Generated by GESF v${escapeHtml(data.gesfVersion)} | Last audit: ${escapeHtml(new Date(data.lastAudit).toLocaleString())} | <a href="/api/data">JSON API</a>
259
- </div>
534
+ <div class="footer">
535
+ Generated by GESF v${escapeHtml(data.gesfVersion)} | Last audit: ${escapeHtml(new Date(data.lastAudit).toLocaleString())} | <a href="/api/data">JSON API</a> | <a href="/api/packs">Packs API</a> | <a href="/api/fix-history">Fix History API</a>
260
536
  </div>
261
537
 
538
+ <script>
539
+ (function() {
540
+ var packData = ${JSON.stringify(packs.map(p => ({ id: p.id, name: p.name, controlCount: p.controlCount })))};
541
+ var allControlIds = ${JSON.stringify(controls.map(c => c.id))};
542
+
543
+ window.toggleFix = function(id) {
544
+ var body = document.getElementById(id);
545
+ var toggle = document.getElementById(id + '-toggle');
546
+ if (!body) return;
547
+ if (body.classList.contains('open')) {
548
+ body.classList.remove('open');
549
+ if (toggle) toggle.textContent = 'Expand';
550
+ } else {
551
+ body.classList.add('open');
552
+ if (toggle) toggle.textContent = 'Collapse';
553
+ }
554
+ };
555
+
556
+ var navTabMap = { overview: 0, packs: 1, fixes: 2, findings: 3, traceability: 4 };
557
+
558
+ window.navigateToPage = function(page) {
559
+ var tabs = document.querySelectorAll('.nav-tab');
560
+ var idx = navTabMap[page];
561
+ showPage(page, idx !== undefined ? tabs[idx] : null);
562
+ };
563
+
564
+ window.showPage = function(page, btn) {
565
+ var pages = document.querySelectorAll('.page');
566
+ for (var i = 0; i < pages.length; i++) pages[i].classList.remove('active');
567
+ document.getElementById('page-' + page).classList.add('active');
568
+ var tabs = document.querySelectorAll('.nav-tab');
569
+ for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('active');
570
+ if (btn) btn.classList.add('active');
571
+ if (page === 'packs') {
572
+ document.getElementById('packs-list').style.display = '';
573
+ document.getElementById('pack-detail').style.display = 'none';
574
+ }
575
+ if (page === 'findings') {
576
+ document.getElementById('findings-main').style.display = '';
577
+ document.getElementById('finding-detail').style.display = 'none';
578
+ }
579
+ };
580
+
581
+ window.showFindingsTab = function(tab, btn) {
582
+ var panels = document.querySelectorAll('#page-findings .tab-panel');
583
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('active');
584
+ document.getElementById('findings-tab-' + tab).classList.add('active');
585
+ var btns = document.querySelectorAll('#page-findings .tab-btn');
586
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
587
+ if (btn) btn.classList.add('active');
588
+ };
589
+
590
+ window.showFixesTab = function(tab, btn) {
591
+ var panels = document.querySelectorAll('#page-fixes .tab-panel');
592
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('active');
593
+ var el = document.getElementById('fixes-tab-' + tab);
594
+ if (el) el.classList.add('active');
595
+ var btns = document.querySelectorAll('#page-fixes .tab-btn');
596
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
597
+ if (btn) btn.classList.add('active');
598
+ };
599
+
600
+ window.showTraceTab = function(tab, btn) {
601
+ var panels = document.querySelectorAll('#page-traceability .tab-panel');
602
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('active');
603
+ document.getElementById('trace-tab-' + tab).classList.add('active');
604
+ var btns = document.querySelectorAll('#page-traceability .tab-btn');
605
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
606
+ if (btn) btn.classList.add('active');
607
+ };
608
+
609
+ window.loadPackDetail = function(packId) {
610
+ var detail = document.getElementById('pack-detail');
611
+ detail.style.display = '';
612
+ detail.innerHTML = '<div class="card"><div class="card-title">Loading pack details...</div></div>';
613
+ fetch('/api/packs/' + packId)
614
+ .then(function(r) { return r.json(); })
615
+ .then(function(data) {
616
+ if (data.error) { detail.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(data.error) + '</p></div>'; return; }
617
+ renderPackDetail(packId, data, detail);
618
+ })
619
+ .catch(function(e) { detail.innerHTML = '<div class="card"><div class="card-title">Error loading pack</div><p>' + esc(e.message) + '</p></div>'; });
620
+ document.getElementById('packs-list').style.display = 'none';
621
+
622
+ var navTabs = document.querySelectorAll('.nav-tab');
623
+ for (var i = 0; i < navTabs.length; i++) {
624
+ if (navTabs[i].textContent.indexOf('Policy') >= 0) {
625
+ navTabs[i].classList.add('active');
626
+ } else {
627
+ navTabs[i].classList.remove('active');
628
+ }
629
+ }
630
+ document.getElementById('page-packs').classList.add('active');
631
+ var pages = document.querySelectorAll('.page');
632
+ for (var i = 0; i < pages.length; i++) {
633
+ if (pages[i].id !== 'page-packs') pages[i].classList.remove('active');
634
+ }
635
+ };
636
+
637
+ function renderPackDetail(packId, data, container) {
638
+ var p = data.pack;
639
+ var pct = p.score;
640
+ var color = pct >= 80 ? '#22c55e' : pct >= 60 ? '#eab308' : pct >= 40 ? '#f97316' : '#ef4444';
641
+ var controls = data.controls || [];
642
+ var topFixes = data.topFixes || [];
643
+
644
+ var html = '<div class="detail-back" onclick="backToPacks()">&larr; Back to all packs</div>';
645
+ html += '<div class="detail-header">';
646
+ html += '<div><div class="detail-title">' + esc(p.name) + '</div>';
647
+ html += '<div class="detail-meta">' + esc(p.description) + '</div></div>';
648
+ html += '<div style="text-align:right;">';
649
+ html += '<div style="font-size:36px;font-weight:700;color:' + color + ';">' + pct + '%</div>';
650
+ html += '<span class="badge" style="background:' + color + ';padding:4px 14px;border-radius:6px;font-size:13px;">Grade: ' + p.grade + '</span>';
651
+ html += '</div></div>';
652
+
653
+ html += '<div class="grid grid-4" style="margin-bottom:20px;">';
654
+ html += '<div class="card stat"><div class="num">' + p.controlCount + '</div><div class="label">Controls</div></div>';
655
+ html += '<div class="card stat"><div class="num" style="color:#22c55e;">' + p.passedCount + '</div><div class="label">Pass</div></div>';
656
+ html += '<div class="card stat"><div class="num" style="color:#ef4444;">' + p.findingsCount + '</div><div class="label">Findings</div></div>';
657
+ html += '<div class="card stat"><div class="num" style="color:#f97316;">' + topFixes.length + '</div><div class="label">Need Fix</div></div>';
658
+ html += '</div>';
659
+
660
+ if (topFixes.length > 0) {
661
+ html += '<div class="card" style="margin-bottom:20px;"><div class="card-title">Prioritized Fixes (' + topFixes.length + ')</div>';
662
+ for (var i = 0; i < topFixes.length; i++) {
663
+ var fix = topFixes[i];
664
+ var sevClass = fix.severity;
665
+ var fixId = 'packfix-' + i;
666
+ html += '<div class="fix-detail-card" style="margin-bottom:8px;">';
667
+ html += '<div class="fix-detail-header ' + sevClass + '" onclick="toggleFix(\\'' + fixId + '\\')">';
668
+ html += '<div class="fix-detail-num" style="color:' + sevColor(fix.severity) + ';">' + (i + 1) + '</div>';
669
+ html += '<div class="fix-detail-info">';
670
+ html += '<div class="fix-detail-title">' + esc(fix.controlName) + '</div>';
671
+ html += '<div class="fix-detail-meta">' + esc(fix.controlId) + ' &mdash; ' + fix.findings.length + ' finding(s)</div>';
672
+ html += '</div>';
673
+ html += '<div class="fix-detail-badges">';
674
+ html += '<span class="badge badge-sev" style="background:' + sevColor(fix.severity) + ';font-size:10px;">' + fix.severity.toUpperCase() + '</span>';
675
+ html += '<span class="fix-toggle" id="' + fixId + '-toggle">Expand</span>';
676
+ html += '</div></div>';
677
+ html += '<div class="fix-detail-body" id="' + fixId + '">';
678
+ html += '<div class="fix-section"><div class="fix-section-title">Findings</div>';
679
+ for (var j = 0; j < fix.findings.length; j++) {
680
+ var ff = fix.findings[j];
681
+ html += '<div class="fix-finding-item ' + ff.severity + '">';
682
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
683
+ html += '<span class="badge badge-sev" style="background:' + sevColor(ff.severity) + ';font-size:10px;">' + ff.severity.toUpperCase() + '</span>';
684
+ html += '<strong style="font-size:13px;">' + esc(ff.title) + '</strong></div>';
685
+ html += '<div style="font-size:12px;color:#6b7280;"><span style="font-family:monospace;font-weight:600;">' + esc(ff.ruleId) + '</span> &mdash; <span style="font-family:monospace;">' + esc(ff.file) + (ff.line ? ':' + ff.line : '') + '</span></div>';
686
+ if (ff.description) html += '<div style="font-size:12px;color:#4b5563;margin-top:4px;">' + esc(ff.description) + '</div>';
687
+ if (ff.evidence) html += '<div class="fix-evidence">' + esc(ff.evidence) + '</div>';
688
+ html += '</div>';
689
+ }
690
+ html += '</div>';
691
+ html += '<div class="fix-section"><div class="fix-section-title">Fix Guidance</div>';
692
+ html += '<div class="fix-guidance-box"><strong>How to fix:</strong> ' + esc(fix.guidance) + '</div>';
693
+ for (var j = 0; j < fix.findings.length; j++) {
694
+ if (fix.findings[j].fix) {
695
+ html += '<div class="fix-guidance-box" style="margin-top:8px;background:#eff6ff;border-color:#bfdbfe;"><strong>Fix for ' + esc(fix.findings[j].ruleId) + ':</strong> ' + esc(fix.findings[j].fix) + '</div>';
696
+ }
697
+ }
698
+ html += '</div>';
699
+ html += '</div></div>';
700
+ }
701
+ html += '</div>';
702
+ }
703
+
704
+ html += '<div class="tab-bar">';
705
+ html += '<button class="tab-btn active" onclick="showPackTab(\\'all\\',this)">All Controls (' + controls.length + ')</button>';
706
+ html += '<button class="tab-btn" onclick="showPackTab(\\'failing\\',this)">Failing (' + (controls.filter(function(c){return c.status!==\\'pass\\'&&c.status!==\\'not-applicable\\'}).length) + ')</button>';
707
+ html += '<button class="tab-btn" onclick="showPackTab(\\'withfindings\\',this)">With Findings (' + (controls.filter(function(c){return c.relatedFindings.length>0}).length) + ')</button>';
708
+ html += '</div>';
709
+
710
+ html += '<div id="pack-controls-all">';
711
+ html += renderControlsTable(controls);
712
+ html += '</div>';
713
+ html += '<div id="pack-controls-failing" style="display:none;">';
714
+ html += renderControlsTable(controls.filter(function(c){return c.status!==\\'pass\\'&&c.status!==\\'not-applicable\\'}));
715
+ html += '</div>';
716
+ html += '<div id="pack-controls-withfindings" style="display:none;">';
717
+ html += renderControlsTable(controls.filter(function(c){return c.relatedFindings.length>0}));
718
+ html += '</div>';
719
+
720
+ container.innerHTML = html;
721
+ }
722
+
723
+ function renderControlsTable(ctrls) {
724
+ if (ctrls.length === 0) return '<div class="empty-state"><div class="msg">No controls match this filter</div></div>';
725
+ var html = '<table><thead><tr><th>ID</th><th>Name</th><th>Severity</th><th>Status</th><th>Checks</th><th>Findings</th><th>Actions</th></tr></thead><tbody>';
726
+ for (var i = 0; i < ctrls.length; i++) {
727
+ var c = ctrls[i];
728
+ var passedChecks = c.checks.filter(function(ch){return ch.status==='pass'}).length;
729
+ html += '<tr class="control-row" onclick="showControlDetail(\\'' + c.id + '\\')">';
730
+ html += '<td style="font-family:monospace;font-size:11px;">' + esc(c.id) + '</td>';
731
+ html += '<td>' + esc(c.name) + '</td>';
732
+ html += '<td><span class="badge badge-sev" style="background:' + sevColor(c.severity) + '">' + c.severity.toUpperCase() + '</span></td>';
733
+ html += '<td><span class="badge badge-status" style="background:' + statColor(c.status) + '">' + statLabel(c.status) + '</span></td>';
734
+ html += '<td>' + passedChecks + '/' + c.checks.length + '</td>';
735
+ html += '<td>' + (c.relatedFindings.length > 0 ? '<span style="color:#ef4444;font-weight:600;">' + c.relatedFindings.length + '</span>' : '0') + '</td>';
736
+ html += '<td><span class="link">Details &rarr;</span></td>';
737
+ html += '</tr>';
738
+ }
739
+ html += '</tbody></table>';
740
+ return html;
741
+ }
742
+
743
+ window.showPackTab = function(tab, btn) {
744
+ var panels = ['all', 'failing', 'withfindings'];
745
+ for (var i = 0; i < panels.length; i++) {
746
+ var el = document.getElementById('pack-controls-' + panels[i]);
747
+ if (el) el.style.display = panels[i] === tab ? '' : 'none';
748
+ }
749
+ var btns = btn.parentElement.querySelectorAll('.tab-btn');
750
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
751
+ btn.classList.add('active');
752
+ };
753
+
754
+ window.backToPacks = function() {
755
+ document.getElementById('packs-list').style.display = '';
756
+ document.getElementById('pack-detail').style.display = 'none';
757
+ };
758
+
759
+ window.showControlDetail = function(controlId) {
760
+ var modal = document.getElementById('control-detail-modal');
761
+ modal.style.display = '';
762
+ modal.innerHTML = '<div class="card"><div class="card-title">Loading control details...</div></div>';
763
+ modal.scrollIntoView({ behavior: 'smooth' });
764
+
765
+ fetch('/api/controls/' + controlId)
766
+ .then(function(r) { return r.json(); })
767
+ .then(function(data) {
768
+ if (data.error) { modal.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(data.error) + '</p></div>'; return; }
769
+ renderControlModal(data, modal);
770
+ })
771
+ .catch(function(e) { modal.innerHTML = '<div class="card"><div class="card-title">Error</div><p>' + esc(e.message) + '</p></div>'; });
772
+ };
773
+
774
+ function renderControlModal(data, container) {
775
+ var html = '<div class="card" style="position:relative;">';
776
+ html += '<button onclick="document.getElementById(\\'control-detail-modal\\').style.display=\\'none\\'" style="position:absolute;top:16px;right:16px;background:none;border:none;font-size:20px;cursor:pointer;color:#6b7280;">&times;</button>';
777
+ html += '<div class="breadcrumb"><span onclick="navigateToPage(\\'packs\\')">Policy Packs</span> &rsaquo; <span onclick="loadPackDetail(\\'' + data.packId + '\\')">' + esc(data.packName) + '</span> &rsaquo; ' + esc(data.id) + '</div>';
778
+ html += '<div class="detail-header">';
779
+ html += '<div><div class="detail-title">' + esc(data.name) + '</div>';
780
+ html += '<div class="detail-meta">' + esc(data.id) + ' | ' + esc(data.category) + ' | ' + esc(data.framework) + (data.article ? ' | ' + esc(data.article) : '') + '</div></div>';
781
+ html += '<div style="text-align:right;">';
782
+ html += '<span class="badge badge-sev" style="background:' + sevColor(data.severity) + ';font-size:12px;padding:4px 12px;">' + data.severity.toUpperCase() + '</span> ';
783
+ html += '<span class="badge badge-status" style="background:' + statColor(data.status) + ';font-size:12px;padding:4px 12px;">' + statLabel(data.status) + '</span>';
784
+ html += '</div></div>';
785
+
786
+ html += '<div style="margin:16px 0;font-size:14px;color:#374151;line-height:1.7;">' + esc(data.description) + '</div>';
787
+
788
+ html += '<h3 style="font-size:15px;font-weight:700;margin:20px 0 8px;">Checks (' + data.checks.length + ')</h3>';
789
+ html += '<ul class="check-list">';
790
+ for (var i = 0; i < data.checks.length; i++) {
791
+ var ch = data.checks[i];
792
+ var icon = ch.status === 'pass' ? '&#10003;' : '&#10007;';
793
+ var iconBg = statColor(ch.status);
794
+ html += '<li><span class="check-icon" style="background:' + iconBg + '">' + icon + '</span> <span>' + esc(ch.description) + '</span>';
795
+ if (ch.evidence) html += ' <span style="color:#6b7280;font-size:11px;">(' + esc(ch.evidence) + ')</span>';
796
+ html += '</li>';
797
+ }
798
+ html += '</ul>';
799
+
800
+ if (data.relatedFindings && data.relatedFindings.length > 0) {
801
+ html += '<h3 style="font-size:15px;font-weight:700;margin:20px 0 8px;">Related Findings (' + data.relatedFindings.length + ')</h3>';
802
+ html += '<div class="findings-trace">';
803
+ for (var i = 0; i < data.relatedFindings.length; i++) {
804
+ var f = data.relatedFindings[i];
805
+ html += '<div class="trace-item ' + f.severity + '">';
806
+ html += '<div class="trace-title"><span class="badge badge-sev" style="background:' + sevColor(f.severity) + '">' + f.severity.toUpperCase() + '</span> ' + esc(f.title) + '</div>';
807
+ html += '<div class="trace-detail">' + esc(f.ruleId) + ' | ' + esc(f.file) + (f.line ? ':' + f.line : '') + '</div>';
808
+ html += '</div>';
809
+ if (f.fix) {
810
+ html += '<div class="trace-fix"><div class="trace-fix-label">Fix Guidance</div>' + esc(f.fix) + '</div>';
811
+ }
812
+ }
813
+ html += '</div>';
814
+ }
815
+
816
+ html += '<div class="guidance-box">';
817
+ html += '<h4>Implementation Guidance</h4>';
818
+ html += '<p>' + esc(data.implementation_guidance) + '</p>';
819
+ html += '</div>';
820
+
821
+ html += '</div>';
822
+ container.innerHTML = html;
823
+ }
824
+
825
+ function sevColor(s) {
826
+ var m = { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
827
+ return m[s] || '#6b7280';
828
+ }
829
+ function statColor(s) {
830
+ var m = { pass: '#22c55e', fail: '#ef4444', warning: '#eab308', 'not-implemented': '#6b7280', 'not-applicable': '#9ca3af' };
831
+ return m[s] || '#6b7280';
832
+ }
833
+ function statLabel(s) {
834
+ var m = { pass: 'PASS', fail: 'FAIL', warning: 'WARN', 'not-implemented': 'NOT IMPL', 'not-applicable': 'N/A' };
835
+ return m[s] || (s || '').toUpperCase();
836
+ }
837
+ function esc(str) {
838
+ if (!str) return '';
839
+ var d = document.createElement('div');
840
+ d.textContent = str;
841
+ return d.innerHTML;
842
+ }
843
+ })();
844
+ </script>
845
+
262
846
  </body>
263
847
  </html>`;
264
848
  }
849
+ function donutSvg(passed, total) {
850
+ const r = 54;
851
+ const cx = 70;
852
+ const cy = 70;
853
+ const circumference = 2 * Math.PI * r;
854
+ const pct = total > 0 ? passed / total : 0;
855
+ const offset = circumference * (1 - pct);
856
+ const color = pct >= 0.8 ? "#22c55e" : pct >= 0.6 ? "#eab308" : pct >= 0.4 ? "#f97316" : "#ef4444";
857
+ return `<svg width="140" height="140" viewBox="0 0 140 140">
858
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
859
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="12"
860
+ stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
861
+ stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
862
+ <text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="28" font-weight="700" fill="#1f2937">${Math.round(pct * 100)}%</text>
863
+ <text x="${cx}" y="${cy + 16}" text-anchor="middle" font-size="11" fill="#6b7280">${passed}/${total} passed</text>
864
+ </svg>`;
865
+ }
866
+ function scoreBarHtml(score) {
867
+ const color = score >= 80 ? "#22c55e" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
868
+ return `<div class="score-bar-wrap"><div class="score-bar-fill" style="width:${score}%;background:${color};"></div></div>`;
869
+ }
870
+ function renderFindingsTable(findings) {
871
+ if (findings.length === 0) {
872
+ return '<div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No findings in this category</div></div>';
873
+ }
874
+ return `<table>
875
+ <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>Issue</th><th>Fix Guidance</th></tr></thead>
876
+ <tbody>
877
+ ${findings.slice(0, 50).map(f => `<tr>
878
+ <td><span class="badge badge-sev" style="background:${severityColor(f.severity)}">${f.severity.toUpperCase()}</span></td>
879
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.ruleId)}</td>
880
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</td>
881
+ <td>${escapeHtml(f.title)}</td>
882
+ <td style="max-width:300px;font-size:12px;color:#374151;">${escapeHtml(f.fix)}</td>
883
+ </tr>`).join('')}
884
+ </tbody>
885
+ </table>
886
+ ${findings.length > 50 ? `<div style="text-align:center;padding:8px;color:#9ca3af;font-size:12px;">Showing 50 of ${findings.length}</div>` : ''}`;
887
+ }
888
+ function renderDetailedFixesList(findings, controls, packs) {
889
+ const fixesByControl = new Map();
890
+ for (const f of findings) {
891
+ for (const cid of f.controlIds) {
892
+ const ctrl = controls.find(c => c.id === cid);
893
+ if (!ctrl)
894
+ continue;
895
+ const existing = fixesByControl.get(cid);
896
+ if (existing) {
897
+ if (!existing.findings.some(ef => ef.ruleId === f.ruleId && ef.file === f.file && ef.line === f.line)) {
898
+ existing.findings.push(f);
899
+ }
900
+ }
901
+ else {
902
+ fixesByControl.set(cid, { control: ctrl, findings: [f] });
903
+ }
904
+ }
905
+ }
906
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
907
+ const fixEntries = [...fixesByControl.values()].sort((a, b) => {
908
+ const aMin = Math.min(...a.findings.map(f => sevOrder[f.severity] ?? 4));
909
+ const bMin = Math.min(...b.findings.map(f => sevOrder[f.severity] ?? 4));
910
+ if (aMin !== bMin)
911
+ return aMin - bMin;
912
+ return b.findings.length - a.findings.length;
913
+ });
914
+ if (fixEntries.length === 0) {
915
+ return '<div class="card"><div class="empty-state"><div class="icon">&#10003;</div><div class="msg" style="color:#22c55e;">No fixes needed</div><div class="sub">All findings are resolved</div></div></div>';
916
+ }
917
+ const totalFindings = fixEntries.reduce((sum, e) => sum + e.findings.length, 0);
918
+ const criticalFixes = fixEntries.filter(e => e.findings.some(f => f.severity === "critical")).length;
919
+ let html = '';
920
+ html += `<div style="margin-bottom:20px;">`;
921
+ html += `<h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Detailed Fix List</h2>`;
922
+ html += `<p style="color:#6b7280;font-size:14px;margin-bottom:16px;">Every finding that needs a fix, grouped by the control it belongs to, with full traceability to the policy pack.</p>`;
923
+ html += `<div class="grid grid-4" style="margin-bottom:20px;">`;
924
+ html += `<div class="card stat"><div class="num" style="color:#ef4444;">${fixEntries.length}</div><div class="label">Controls to Fix</div></div>`;
925
+ html += `<div class="card stat"><div class="num">${totalFindings}</div><div class="label">Total Findings</div></div>`;
926
+ html += `<div class="card stat"><div class="num" style="color:#ef4444;">${criticalFixes}</div><div class="label">Critical Fixes</div></div>`;
927
+ html += `<div class="card stat"><div class="num" style="color:#f97316;">${fixEntries.filter(e => e.findings.some(f => f.severity === "high")).length}</div><div class="label">High Fixes</div></div>`;
928
+ html += `</div>`;
929
+ html += `</div>`;
930
+ for (let i = 0; i < fixEntries.length; i++) {
931
+ const entry = fixEntries[i];
932
+ const c = entry.control;
933
+ const maxSev = entry.findings.reduce((max, f) => {
934
+ return (sevOrder[f.severity] ?? 4) < (sevOrder[max] ?? 4) ? f.severity : max;
935
+ }, "low");
936
+ const packMatch = packs.find(p => {
937
+ const idUpper = c.id.toUpperCase();
938
+ const packPrefix = p.id.toUpperCase().replace(/-/g, "");
939
+ if (p.id === "gdpr")
940
+ return idUpper.startsWith("GDPR-");
941
+ if (p.id === "owasp")
942
+ return idUpper.startsWith("OWASP-");
943
+ if (p.id === "cis")
944
+ return idUpper.startsWith("CIS-");
945
+ if (p.id === "nist")
946
+ return idUpper.startsWith("NIST-");
947
+ if (p.id === "ai")
948
+ return idUpper.startsWith("AI-");
949
+ if (p.id === "blockchain")
950
+ return idUpper.startsWith("BC-");
951
+ if (p.id === "government")
952
+ return idUpper.startsWith("GOV-");
953
+ if (p.id === "iso27001")
954
+ return idUpper.startsWith("ISO27K-");
955
+ if (p.id === "iso27701")
956
+ return idUpper.startsWith("ISO277-");
957
+ if (p.id === "hipaa")
958
+ return idUpper.startsWith("HIPAA-");
959
+ return false;
960
+ });
961
+ const fixId = `fix-${i}`;
962
+ const passedChecks = c.checks.filter(ch => ch.status === "pass").length;
963
+ const failedChecks = c.checks.filter(ch => ch.status === "fail").length;
964
+ html += `<div class="fix-detail-card">`;
965
+ html += `<div class="fix-detail-header ${maxSev}" onclick="toggleFix('${fixId}')">`;
966
+ html += `<div class="fix-detail-num" style="color:${severityColor(maxSev)};">${i + 1}</div>`;
967
+ html += `<div class="fix-detail-info">`;
968
+ html += `<div class="fix-detail-title">${escapeHtml(c.name)}</div>`;
969
+ html += `<div class="fix-detail-meta">${escapeHtml(c.id)} | ${escapeHtml(c.category)} | ${escapeHtml(c.framework)}${c.article ? ' | ' + escapeHtml(c.article) : ''} | Pack: ${escapeHtml(packMatch?.name || "Direct")}</div>`;
970
+ html += `</div>`;
971
+ html += `<div class="fix-detail-badges">`;
972
+ html += `<span class="badge badge-sev" style="background:${severityColor(maxSev)}">${maxSev.toUpperCase()}</span>`;
973
+ html += `<span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span>`;
974
+ html += `<span style="font-size:12px;color:#6b7280;">${entry.findings.length} finding(s)</span>`;
975
+ html += `<span style="font-size:12px;color:#6b7280;">${passedChecks}/${c.checks.length} checks</span>`;
976
+ html += `<span class="fix-toggle" id="${fixId}-toggle">Expand</span>`;
977
+ html += `</div>`;
978
+ html += `</div>`;
979
+ html += `<div class="fix-detail-body" id="${fixId}">`;
980
+ html += `<div class="fix-section">`;
981
+ html += `<div class="fix-section-title">Findings (${entry.findings.length})</div>`;
982
+ for (const f of entry.findings) {
983
+ html += `<div class="fix-finding-item ${f.severity}">`;
984
+ html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">`;
985
+ html += `<span class="badge badge-sev" style="background:${severityColor(f.severity)};font-size:10px;">${f.severity.toUpperCase()}</span>`;
986
+ html += `<strong style="font-size:13px;">${escapeHtml(f.title)}</strong>`;
987
+ html += `</div>`;
988
+ html += `<div style="font-size:12px;color:#6b7280;">`;
989
+ html += `<span style="font-family:monospace;font-weight:600;">${escapeHtml(f.ruleId)}</span>`;
990
+ html += ` &mdash; <span style="font-family:monospace;">${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</span>`;
991
+ html += `</div>`;
992
+ if (f.description) {
993
+ html += `<div style="font-size:12px;color:#4b5563;margin-top:4px;">${escapeHtml(f.description)}</div>`;
994
+ }
995
+ if (f.evidence) {
996
+ html += `<div class="fix-evidence">${escapeHtml(f.evidence)}</div>`;
997
+ }
998
+ html += `</div>`;
999
+ }
1000
+ html += `</div>`;
1001
+ html += `<div class="fix-section">`;
1002
+ html += `<div class="fix-section-title">Fix Guidance</div>`;
1003
+ html += `<div class="fix-guidance-box"><strong>How to fix:</strong> ${escapeHtml(c.implementation_guidance)}</div>`;
1004
+ for (const f of entry.findings) {
1005
+ if (f.fix) {
1006
+ html += `<div class="fix-guidance-box" style="margin-top:8px;background:#eff6ff;border-color:#bfdbfe;"><strong>Fix for ${escapeHtml(f.ruleId)}:</strong> ${escapeHtml(f.fix)}</div>`;
1007
+ }
1008
+ }
1009
+ html += `</div>`;
1010
+ if (c.checks.length > 0) {
1011
+ html += `<div class="fix-section">`;
1012
+ html += `<div class="fix-section-title">Control Checks (${passedChecks} passed, ${failedChecks} failed, ${c.checks.length - passedChecks - failedChecks} other)</div>`;
1013
+ html += `<div class="fix-checks-grid">`;
1014
+ for (const ch of c.checks) {
1015
+ const icon = ch.status === "pass" ? "&#10003;" : "&#10007;";
1016
+ const checkClass = ch.status === "pass" ? "fix-check-pass" : ch.status === "fail" ? "fix-check-fail" : "";
1017
+ html += `<div class="fix-check-item ${checkClass}">`;
1018
+ html += `<span class="fix-check-icon" style="background:${statusColor(ch.status)}">${icon}</span>`;
1019
+ html += `<span>${escapeHtml(ch.description)}</span>`;
1020
+ html += `</div>`;
1021
+ }
1022
+ html += `</div>`;
1023
+ html += `</div>`;
1024
+ }
1025
+ html += `<div class="fix-section">`;
1026
+ html += `<div class="fix-section-title">Traceability</div>`;
1027
+ html += `<table><tbody>`;
1028
+ html += `<tr><td style="font-weight:600;width:160px;">Control</td><td>${escapeHtml(c.id)} &mdash; ${escapeHtml(c.name)}</td></tr>`;
1029
+ html += `<tr><td style="font-weight:600;">Category</td><td>${escapeHtml(c.category)}</td></tr>`;
1030
+ html += `<tr><td style="font-weight:600;">Framework</td><td>${escapeHtml(c.framework)}${c.article ? ' / ' + escapeHtml(c.article) : ''}</td></tr>`;
1031
+ html += `<tr><td style="font-weight:600;">Policy Pack</td><td>${packMatch ? `<span class="tag" style="cursor:pointer;" onclick="loadPackDetail('${packMatch.id}')">${escapeHtml(packMatch.name)}</span>` : 'Direct'}</td></tr>`;
1032
+ html += `<tr><td style="font-weight:600;">Severity</td><td><span class="badge badge-sev" style="background:${severityColor(c.severity)}">${c.severity.toUpperCase()}</span></td></tr>`;
1033
+ html += `<tr><td style="font-weight:600;">Status</td><td><span class="badge badge-status" style="background:${statusColor(c.status)}">${statusLabel(c.status)}</span></td></tr>`;
1034
+ html += `<tr><td style="font-weight:600;">Findings Count</td><td>${entry.findings.length}</td></tr>`;
1035
+ html += `<tr><td style="font-weight:600;">Description</td><td style="font-size:13px;color:#4b5563;">${escapeHtml(c.description)}</td></tr>`;
1036
+ html += `</tbody></table>`;
1037
+ html += `</div>`;
1038
+ html += `</div>`;
1039
+ html += `</div>`;
1040
+ }
1041
+ return html;
1042
+ }
1043
+ function renderFixHistorySection(entries) {
1044
+ if (entries.length === 0) {
1045
+ return `<div class="card">
1046
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Compliance Fix History</h2>
1047
+ <p style="color:#6b7280;font-size:14px;margin-bottom:16px;">Every autofix applied via CLI or MCP is recorded here with full compliance traceability.</p>
1048
+ <div class="empty-state">
1049
+ <div class="icon">&#128203;</div>
1050
+ <div class="msg">No fixes recorded yet</div>
1051
+ <div class="sub">Run <code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;font-size:12px;">ges fix</code> or use the MCP <code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;font-size:12px;">auto_fix</code> tool to apply fixes. Each fix will be recorded here.</div>
1052
+ </div>
1053
+ </div>`;
1054
+ }
1055
+ const applied = entries.filter(e => e.fix.applied);
1056
+ const failed = entries.filter(e => !e.fix.applied);
1057
+ const bySource = { cli: entries.filter(e => e.source === "cli").length, mcp: entries.filter(e => e.source === "mcp").length };
1058
+ const frameworksAffected = [...new Set(entries.flatMap(e => e.compliance_impact.frameworks_affected))];
1059
+ const totalControlsAddressed = entries.reduce((sum, e) => sum + e.compliance_impact.controls_addressed, 0);
1060
+ const bySeverity = {
1061
+ critical: entries.filter(e => e.compliance_impact.severity_resolved === "critical").length,
1062
+ high: entries.filter(e => e.compliance_impact.severity_resolved === "high").length,
1063
+ medium: entries.filter(e => e.compliance_impact.severity_resolved === "medium").length,
1064
+ low: entries.filter(e => e.compliance_impact.severity_resolved === "low").length,
1065
+ };
1066
+ let html = '';
1067
+ html += `<h2 style="font-size:20px;font-weight:700;margin-bottom:8px;">Compliance Fix History</h2>`;
1068
+ html += `<p style="color:#6b7280;font-size:14px;margin-bottom:16px;">Every autofix applied via CLI or MCP is recorded here with full compliance traceability.</p>`;
1069
+ html += `<div class="grid grid-4" style="margin-bottom:20px;">`;
1070
+ html += `<div class="card stat"><div class="num">${entries.length}</div><div class="label">Total Fixes</div></div>`;
1071
+ html += `<div class="card stat"><div class="num" style="color:#22c55e;">${applied.length}</div><div class="label">Applied</div></div>`;
1072
+ html += `<div class="card stat"><div class="num" style="color:#ef4444;">${failed.length}</div><div class="label">Failed</div></div>`;
1073
+ html += `<div class="card stat"><div class="num" style="color:#0f766e;">${totalControlsAddressed}</div><div class="label">Controls Addressed</div></div>`;
1074
+ html += `</div>`;
1075
+ html += `<div class="grid grid-3" style="margin-bottom:20px;">`;
1076
+ html += `<div class="card"><div class="card-title">Severity Breakdown</div>
1077
+ <div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;">
1078
+ <div class="stat"><div class="num" style="color:#ef4444;font-size:20px;">${bySeverity.critical}</div><div class="label">Critical</div></div>
1079
+ <div class="stat"><div class="num" style="color:#f97316;font-size:20px;">${bySeverity.high}</div><div class="label">High</div></div>
1080
+ <div class="stat"><div class="num" style="color:#eab308;font-size:20px;">${bySeverity.medium}</div><div class="label">Medium</div></div>
1081
+ <div class="stat"><div class="num" style="color:#3b82f6;font-size:20px;">${bySeverity.low}</div><div class="label">Low</div></div>
1082
+ </div></div>`;
1083
+ html += `<div class="card"><div class="card-title">Fix Sources</div>
1084
+ <div style="display:flex;gap:16px;margin-top:8px;">
1085
+ <div class="stat"><div class="num" style="font-size:20px;">${bySource.cli}</div><div class="label">CLI (ges fix)</div></div>
1086
+ <div class="stat"><div class="num" style="font-size:20px;">${bySource.mcp}</div><div class="label">MCP (auto_fix)</div></div>
1087
+ </div></div>`;
1088
+ html += `<div class="card"><div class="card-title">Frameworks Impacted</div>
1089
+ <div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">
1090
+ ${frameworksAffected.length > 0 ? frameworksAffected.map(fw => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(fw)}</span>`).join('') : '<span style="color:#9ca3af;">None</span>'}
1091
+ </div></div>`;
1092
+ html += `</div>`;
1093
+ const sorted = [...entries].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1094
+ html += `<div class="card"><div class="card-title">All Recorded Fixes (newest first)</div>`;
1095
+ html += `<table><thead><tr><th>Time</th><th>Source</th><th>Severity</th><th>Rule</th><th>Finding</th><th>Fix Action</th><th>Controls</th><th>Frameworks</th><th>Status</th></tr></thead><tbody>`;
1096
+ for (const entry of sorted) {
1097
+ const time = new Date(entry.timestamp).toLocaleString();
1098
+ const sourceBadge = entry.source === "mcp"
1099
+ ? '<span class="badge" style="background:#7c3aed;font-size:10px;">MCP</span>'
1100
+ : '<span class="badge" style="background:#0f766e;font-size:10px;">CLI</span>';
1101
+ const sevBadge = `<span class="badge badge-sev" style="background:${severityColor(entry.compliance_impact.severity_resolved)};font-size:10px;">${entry.compliance_impact.severity_resolved.toUpperCase()}</span>`;
1102
+ const statusBadge = entry.fix.applied
1103
+ ? '<span class="badge badge-status" style="background:#22c55e;font-size:10px;">APPLIED</span>'
1104
+ : `<span class="badge badge-status" style="background:#ef4444;font-size:10px;">FAILED</span>`;
1105
+ const controlsHtml = entry.controls.length > 0
1106
+ ? entry.controls.map(c => `<div style="margin-bottom:2px;"><span class="link" onclick="showControlDetail('${escapeHtml(c.id)}')">${escapeHtml(c.id)}</span></div>`).join('')
1107
+ : '<span style="color:#9ca3af;">-</span>';
1108
+ const frameworksHtml = entry.compliance_impact.frameworks_affected.length > 0
1109
+ ? entry.compliance_impact.frameworks_affected.map(f => `<span class="tag">${escapeHtml(f)}</span>`).join(' ')
1110
+ : '<span style="color:#9ca3af;">-</span>';
1111
+ html += `<tr>
1112
+ <td style="font-size:11px;white-space:nowrap;">${time}</td>
1113
+ <td>${sourceBadge}</td>
1114
+ <td>${sevBadge}</td>
1115
+ <td style="font-family:monospace;font-size:11px;">${escapeHtml(entry.finding.rule_id)}</td>
1116
+ <td style="max-width:200px;">
1117
+ <div style="font-weight:600;font-size:12px;">${escapeHtml(entry.finding.title)}</div>
1118
+ <div style="font-size:11px;color:#6b7280;">${escapeHtml(entry.finding.file)}${entry.finding.line ? ':' + entry.finding.line : ''}</div>
1119
+ </td>
1120
+ <td style="max-width:200px;">
1121
+ <div style="font-size:12px;"><span class="badge" style="background:#6b7280;font-size:9px;">${entry.fix.action_type.toUpperCase()}</span> ${escapeHtml(entry.fix.file_path)}</div>
1122
+ <div style="font-size:11px;color:#6b7280;">${escapeHtml(entry.fix.description)}</div>
1123
+ </td>
1124
+ <td style="max-width:150px;">${controlsHtml}</td>
1125
+ <td>${frameworksHtml}</td>
1126
+ <td>${statusBadge}</td>
1127
+ </tr>`;
1128
+ }
1129
+ html += `</tbody></table></div>`;
1130
+ html += `<div class="card" style="margin-top:20px;"><div class="card-title">Detailed Fix Records</div>`;
1131
+ for (let i = 0; i < sorted.length; i++) {
1132
+ const entry = sorted[i];
1133
+ const fixId = `histfix-${i}`;
1134
+ const sevClass = entry.compliance_impact.severity_resolved;
1135
+ html += `<div class="fix-detail-card" style="margin-bottom:8px;">`;
1136
+ html += `<div class="fix-detail-header ${sevClass}" onclick="toggleFix('${fixId}')">`;
1137
+ html += `<div class="fix-detail-num" style="color:${severityColor(sevClass)};">${i + 1}</div>`;
1138
+ html += `<div class="fix-detail-info">`;
1139
+ html += `<div class="fix-detail-title">${escapeHtml(entry.finding.title)}</div>`;
1140
+ html += `<div class="fix-detail-meta">${escapeHtml(entry.finding.rule_id)} | ${escapeHtml(entry.finding.file)}${entry.finding.line ? ':' + entry.finding.line : ''} | ${entry.source.toUpperCase()} | ${new Date(entry.timestamp).toLocaleString()}</div>`;
1141
+ html += `</div>`;
1142
+ html += `<div class="fix-detail-badges">`;
1143
+ html += `<span class="badge badge-sev" style="background:${severityColor(sevClass)};font-size:10px;">${sevClass.toUpperCase()}</span>`;
1144
+ if (entry.fix.applied) {
1145
+ html += `<span class="badge" style="background:#22c55e;font-size:10px;">APPLIED</span>`;
1146
+ }
1147
+ else {
1148
+ html += `<span class="badge" style="background:#ef4444;font-size:10px;">FAILED</span>`;
1149
+ }
1150
+ html += `<span class="badge" style="background:${entry.source === 'mcp' ? '#7c3aed' : '#0f766e'};font-size:10px;">${entry.source.toUpperCase()}</span>`;
1151
+ html += `<span class="fix-toggle" id="${fixId}-toggle">Expand</span>`;
1152
+ html += `</div></div>`;
1153
+ html += `<div class="fix-detail-body" id="${fixId}">`;
1154
+ html += `<div class="fix-section"><div class="fix-section-title">Finding Details</div>`;
1155
+ html += `<table><tbody>`;
1156
+ html += `<tr><td style="font-weight:600;width:140px;">Rule</td><td style="font-family:monospace;">${escapeHtml(entry.finding.rule_id)}</td></tr>`;
1157
+ html += `<tr><td style="font-weight:600;">Category</td><td>${escapeHtml(entry.finding.category)}</td></tr>`;
1158
+ html += `<tr><td style="font-weight:600;">Severity</td><td><span class="badge badge-sev" style="background:${severityColor(entry.compliance_impact.severity_resolved)}">${sevClass.toUpperCase()}</span></td></tr>`;
1159
+ html += `<tr><td style="font-weight:600;">File</td><td style="font-family:monospace;">${escapeHtml(entry.finding.file)}${entry.finding.line ? ':' + entry.finding.line : ''}</td></tr>`;
1160
+ html += `<tr><td style="font-weight:600;">Title</td><td>${escapeHtml(entry.finding.title)}</td></tr>`;
1161
+ if (entry.finding.description) {
1162
+ html += `<tr><td style="font-weight:600;">Description</td><td style="color:#4b5563;">${escapeHtml(entry.finding.description)}</td></tr>`;
1163
+ }
1164
+ if (entry.finding.evidence) {
1165
+ html += `<tr><td style="font-weight:600;">Evidence</td><td><div class="fix-evidence">${escapeHtml(entry.finding.evidence)}</div></td></tr>`;
1166
+ }
1167
+ html += `</tbody></table></div>`;
1168
+ html += `<div class="fix-section"><div class="fix-section-title">Fix Applied</div>`;
1169
+ html += `<table><tbody>`;
1170
+ html += `<tr><td style="font-weight:600;width:140px;">Action</td><td><span class="badge" style="background:#6b7280;">${entry.fix.action_type.toUpperCase()}</span></td></tr>`;
1171
+ html += `<tr><td style="font-weight:600;">Target File</td><td style="font-family:monospace;">${escapeHtml(entry.fix.file_path)}</td></tr>`;
1172
+ html += `<tr><td style="font-weight:600;">Description</td><td>${escapeHtml(entry.fix.description)}</td></tr>`;
1173
+ html += `<tr><td style="font-weight:600;">Status</td><td>${entry.fix.applied ? '<span style="color:#22c55e;font-weight:600;">Applied successfully</span>' : `<span style="color:#ef4444;font-weight:600;">Failed: ${escapeHtml(entry.fix.error || 'Unknown error')}</span>`}</td></tr>`;
1174
+ html += `<tr><td style="font-weight:600;">Source</td><td>${entry.source === 'mcp' ? 'MCP auto_fix tool' : 'CLI ges fix command'}</td></tr>`;
1175
+ html += `<tr><td style="font-weight:600;">Timestamp</td><td>${new Date(entry.timestamp).toLocaleString()}</td></tr>`;
1176
+ if (entry.dry_run) {
1177
+ html += `<tr><td style="font-weight:600;">Mode</td><td><span class="badge" style="background:#eab308;color:white;">DRY RUN</span></td></tr>`;
1178
+ }
1179
+ html += `</tbody></table></div>`;
1180
+ if (entry.fix.guidance) {
1181
+ html += `<div class="fix-section"><div class="fix-section-title">Fix Guidance</div>`;
1182
+ html += `<div class="fix-guidance-box">${escapeHtml(entry.fix.guidance)}</div></div>`;
1183
+ }
1184
+ html += `<div class="fix-section"><div class="fix-section-title">Compliance Traceability</div>`;
1185
+ html += `<table><tbody>`;
1186
+ html += `<tr><td style="font-weight:600;width:160px;">Controls Addressed</td><td>${entry.controls.length > 0 ? entry.controls.map(c => `<div style="margin-bottom:4px;"><span class="link" onclick="showControlDetail('${escapeHtml(c.id)}')">${escapeHtml(c.id)}</span> &mdash; ${escapeHtml(c.name)} <span style="color:#6b7280;font-size:11px;">(${escapeHtml(c.framework)}${c.article ? ' / ' + escapeHtml(c.article) : ''})</span></div>`).join('') : '<span style="color:#9ca3af;">No controls mapped</span>'}</td></tr>`;
1187
+ html += `<tr><td style="font-weight:600;">Frameworks Affected</td><td>${entry.compliance_impact.frameworks_affected.length > 0 ? entry.compliance_impact.frameworks_affected.map(f => `<span class="tag" style="background:#d1fae5;color:#065f46;">${escapeHtml(f)}</span>`).join(' ') : '<span style="color:#9ca3af;">-</span>'}</td></tr>`;
1188
+ html += `<tr><td style="font-weight:600;">Controls Count</td><td>${entry.compliance_impact.controls_addressed}</td></tr>`;
1189
+ html += `<tr><td style="font-weight:600;">Severity Resolved</td><td><span class="badge badge-sev" style="background:${severityColor(entry.compliance_impact.severity_resolved)}">${entry.compliance_impact.severity_resolved.toUpperCase()}</span></td></tr>`;
1190
+ html += `</tbody></table></div>`;
1191
+ html += `</div></div>`;
1192
+ }
1193
+ html += `</div>`;
1194
+ return html;
1195
+ }
265
1196
  function escapeHtml(str) {
266
1197
  return str
267
1198
  .replace(/&/g, "&amp;")