openclaw-smartmeter 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canvas-template/app.js +230 -14
- package/canvas-template/index.html +42 -6
- package/canvas-template/styles.css +228 -1
- package/package.json +1 -1
- package/src/cli/commands.js +19 -4
package/canvas-template/app.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
/* ─── Globals ─── */
|
|
7
7
|
const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
|
|
8
|
+
const STORAGE_PREFIX = window.__SMARTMETER_USER ? `smartmeter_${window.__SMARTMETER_USER}_` : 'smartmeter_';
|
|
8
9
|
let analysisData = null;
|
|
9
10
|
let modelChart = null;
|
|
10
11
|
let taskChart = null;
|
|
@@ -123,6 +124,7 @@ function renderAll() {
|
|
|
123
124
|
safe(updateModelDetails);
|
|
124
125
|
safe(updateLastUpdated);
|
|
125
126
|
safe(checkCostDataNotice);
|
|
127
|
+
safe(() => populateStepPanel(activeStep));
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/* ─── KPIs ─── */
|
|
@@ -900,7 +902,7 @@ async function validateInlineApiKey() {
|
|
|
900
902
|
btn.textContent = 'Save & Validate';
|
|
901
903
|
|
|
902
904
|
if (validated) {
|
|
903
|
-
localStorage.setItem('
|
|
905
|
+
localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
|
|
904
906
|
showStatus('✅ API key saved and validated!', 'success');
|
|
905
907
|
|
|
906
908
|
// Show balance section, hide key input
|
|
@@ -958,7 +960,7 @@ function showApiKeyInput() {
|
|
|
958
960
|
|
|
959
961
|
/** On init, check if key is already stored and auto-show balance */
|
|
960
962
|
async function initGetStartedCard() {
|
|
961
|
-
const stored = localStorage.getItem('
|
|
963
|
+
const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
|
|
962
964
|
if (!stored) return;
|
|
963
965
|
|
|
964
966
|
// Pre-fill the input
|
|
@@ -991,6 +993,228 @@ async function initGetStartedCard() {
|
|
|
991
993
|
}
|
|
992
994
|
}
|
|
993
995
|
|
|
996
|
+
/* ─── Step Panels (Analyze / Evaluate / Guide) ─── */
|
|
997
|
+
let activeStep = 1;
|
|
998
|
+
|
|
999
|
+
function activateStep(n) {
|
|
1000
|
+
activeStep = n;
|
|
1001
|
+
// Toggle active class on step elements
|
|
1002
|
+
for (let i = 1; i <= 3; i++) {
|
|
1003
|
+
const step = document.getElementById('gsStep' + i);
|
|
1004
|
+
if (step) step.classList.toggle('gs-step-active', i === n);
|
|
1005
|
+
}
|
|
1006
|
+
// Show/hide panels
|
|
1007
|
+
for (let i = 1; i <= 3; i++) {
|
|
1008
|
+
const panel = document.getElementById('gsPanel' + i);
|
|
1009
|
+
if (panel) panel.style.display = (i === n) ? '' : 'none';
|
|
1010
|
+
}
|
|
1011
|
+
// Populate panel content
|
|
1012
|
+
populateStepPanel(n);
|
|
1013
|
+
// Update contextual info banner
|
|
1014
|
+
updateContextualBanner(n);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function populateStepPanel(n) {
|
|
1018
|
+
const d = analysisData;
|
|
1019
|
+
if (n === 1) {
|
|
1020
|
+
const panel = document.getElementById('gsPanel1');
|
|
1021
|
+
if (!panel) return;
|
|
1022
|
+
if (!d) {
|
|
1023
|
+
panel.innerHTML = '<p class="panel-empty">Run an analysis to see your usage summary here.</p>';
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const days = d.days_analyzed || 0;
|
|
1027
|
+
const tasks = d.total_tasks || 0;
|
|
1028
|
+
const current = d.monthly_projected_current || 0;
|
|
1029
|
+
const optimized = d.monthly_projected_optimized || 0;
|
|
1030
|
+
const models = (d.model_breakdown || []).length;
|
|
1031
|
+
panel.innerHTML = `
|
|
1032
|
+
<h4>Analysis Summary</h4>
|
|
1033
|
+
<p>${days} day${days !== 1 ? 's' : ''} analyzed · ${tasks} task${tasks !== 1 ? 's' : ''} · ${models} model${models !== 1 ? 's' : ''} detected</p>
|
|
1034
|
+
<table>
|
|
1035
|
+
<tr><td>Projected Monthly Cost</td><td style="text-align:right;font-weight:600">$${current.toFixed(2)}</td></tr>
|
|
1036
|
+
<tr><td>Optimized Projection</td><td style="text-align:right;font-weight:600;color:var(--green)">$${optimized.toFixed(2)}</td></tr>
|
|
1037
|
+
<tr><td>Potential Savings</td><td style="text-align:right;font-weight:600;color:var(--accent)">$${(current - optimized).toFixed(2)}</td></tr>
|
|
1038
|
+
</table>`;
|
|
1039
|
+
} else if (n === 2) {
|
|
1040
|
+
const panel = document.getElementById('gsPanel2');
|
|
1041
|
+
if (!panel) return;
|
|
1042
|
+
const models = d ? (d.model_breakdown || []) : [];
|
|
1043
|
+
if (models.length === 0) {
|
|
1044
|
+
panel.innerHTML = '<p class="panel-empty">No model data available yet. Run an analysis first.</p>';
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const totalCost = models.reduce((s, m) => s + m.cost, 0);
|
|
1048
|
+
panel.innerHTML = `
|
|
1049
|
+
<h4>Model Cost Breakdown</h4>
|
|
1050
|
+
<table>
|
|
1051
|
+
<thead><tr>
|
|
1052
|
+
<th>Model</th><th>Tasks</th><th>Cost</th><th>Avg/Task</th><th>Share</th>
|
|
1053
|
+
</tr></thead>
|
|
1054
|
+
<tbody>
|
|
1055
|
+
${models.map(m => {
|
|
1056
|
+
const share = totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) : '0.0';
|
|
1057
|
+
return `<tr>
|
|
1058
|
+
<td>${escHtml(m.model)}</td>
|
|
1059
|
+
<td>${m.tasks}</td>
|
|
1060
|
+
<td>$${m.cost.toFixed(2)}</td>
|
|
1061
|
+
<td>$${m.avg_cost_per_task.toFixed(3)}</td>
|
|
1062
|
+
<td>${share}%</td>
|
|
1063
|
+
</tr>`;
|
|
1064
|
+
}).join('')}
|
|
1065
|
+
</tbody>
|
|
1066
|
+
</table>`;
|
|
1067
|
+
} else if (n === 3) {
|
|
1068
|
+
const panel = document.getElementById('gsPanel3');
|
|
1069
|
+
if (!panel) return;
|
|
1070
|
+
const recs = d ? (d.recommendations || []) : [];
|
|
1071
|
+
if (recs.length === 0) {
|
|
1072
|
+
panel.innerHTML = '<p class="panel-empty">No recommendations available yet. Run an analysis first.</p>';
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
panel.innerHTML = `
|
|
1076
|
+
<h4>Recommendations</h4>
|
|
1077
|
+
<p>Toggle on the optimizations you want to activate, then hit Apply.</p>
|
|
1078
|
+
<div class="gs-rec-list">
|
|
1079
|
+
${recs.map((r, i) => {
|
|
1080
|
+
const impactClass = (r.impact || '').toLowerCase().includes('high') ? 'high'
|
|
1081
|
+
: (r.impact || '').toLowerCase().includes('medium') ? 'medium' : 'low';
|
|
1082
|
+
const checked = editedRecommendations[i] && editedRecommendations[i].selected ? 'checked' : '';
|
|
1083
|
+
return `<label class="gs-rec-row" data-index="${i}">
|
|
1084
|
+
<input type="checkbox" class="gs-rec-toggle" ${checked} onchange="toggleGuideRec(${i}, this.checked)">
|
|
1085
|
+
<div class="gs-rec-body">
|
|
1086
|
+
<div class="gs-rec-header">
|
|
1087
|
+
<span class="rec-title">${escHtml(r.title)}</span>
|
|
1088
|
+
${r.impact ? `<span class="rec-impact ${impactClass}">${escHtml(r.impact)}</span>` : ''}
|
|
1089
|
+
</div>
|
|
1090
|
+
<div class="rec-desc">${escHtml(r.description)}</div>
|
|
1091
|
+
</div>
|
|
1092
|
+
</label>`;
|
|
1093
|
+
}).join('')}
|
|
1094
|
+
</div>
|
|
1095
|
+
<div class="gs-rec-actions">
|
|
1096
|
+
<button class="btn btn-primary" onclick="applyGuideRecommendations()">Apply Selected</button>
|
|
1097
|
+
<span class="gs-rec-count" id="gsRecCount">${Object.values(editedRecommendations).filter(v => v.selected).length} selected</span>
|
|
1098
|
+
</div>`;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function updateContextualBanner(n) {
|
|
1103
|
+
const notice = document.getElementById('costDataNotice');
|
|
1104
|
+
const content = document.getElementById('costDataNoticeContent');
|
|
1105
|
+
if (!notice || !content) return;
|
|
1106
|
+
|
|
1107
|
+
const messages = {
|
|
1108
|
+
1: {
|
|
1109
|
+
title: 'About Cost Tracking',
|
|
1110
|
+
text: 'Your OpenRouter API responses may not include cost data. SmartMeter still optimizes based on token usage.'
|
|
1111
|
+
},
|
|
1112
|
+
2: {
|
|
1113
|
+
title: 'Understanding Model Costs',
|
|
1114
|
+
text: 'Costs are based on token usage reported by OpenRouter. Compare models to find cheaper alternatives for similar tasks.'
|
|
1115
|
+
},
|
|
1116
|
+
3: {
|
|
1117
|
+
title: 'Applying Recommendations',
|
|
1118
|
+
text: 'Select recommendations below to apply them. Changes update your OpenClaw configuration for optimized model routing.'
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const msg = messages[n] || messages[1];
|
|
1123
|
+
const d = analysisData;
|
|
1124
|
+
const hasCostData = d && (d.monthly_projected_current || 0) > 0;
|
|
1125
|
+
|
|
1126
|
+
// Show banner contextually: always for step 2/3 if data exists, or step 1 if no cost data
|
|
1127
|
+
if (n === 1 && hasCostData) {
|
|
1128
|
+
notice.style.display = 'none';
|
|
1129
|
+
} else {
|
|
1130
|
+
content.innerHTML = `<strong>${msg.title}</strong><p>${msg.text}</p>`;
|
|
1131
|
+
notice.style.display = 'flex';
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/* ─── Guide Panel Actions ─── */
|
|
1136
|
+
function toggleGuideRec(i, on) {
|
|
1137
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
1138
|
+
editedRecommendations[i].selected = on;
|
|
1139
|
+
// Update count
|
|
1140
|
+
const count = Object.values(editedRecommendations).filter(v => v.selected).length;
|
|
1141
|
+
const el = document.getElementById('gsRecCount');
|
|
1142
|
+
if (el) el.textContent = count + ' selected';
|
|
1143
|
+
// Sync main recommendation cards if they exist
|
|
1144
|
+
const card = document.querySelector(`.rec-card[data-index="${i}"]`);
|
|
1145
|
+
if (card) card.classList.toggle('selected', on);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function applyGuideRecommendations() {
|
|
1149
|
+
const selected = Object.entries(editedRecommendations)
|
|
1150
|
+
.filter(([_, v]) => v.selected)
|
|
1151
|
+
.map(([i]) => parseInt(i));
|
|
1152
|
+
|
|
1153
|
+
if (selected.length === 0) {
|
|
1154
|
+
showToast('Toggle at least one recommendation to apply');
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Build summary list for the modal
|
|
1159
|
+
const recs = analysisData ? (analysisData.recommendations || []) : [];
|
|
1160
|
+
const listEl = document.getElementById('applyModalList');
|
|
1161
|
+
if (listEl) {
|
|
1162
|
+
listEl.innerHTML = selected.map(i => {
|
|
1163
|
+
const r = recs[i];
|
|
1164
|
+
if (!r) return '';
|
|
1165
|
+
const impactClass = (r.impact || '').toLowerCase().includes('high') ? 'high'
|
|
1166
|
+
: (r.impact || '').toLowerCase().includes('medium') ? 'medium' : 'low';
|
|
1167
|
+
return `<div class="apply-modal-item">
|
|
1168
|
+
<span class="apply-modal-dot ${impactClass}"></span>
|
|
1169
|
+
<span>${escHtml(r.title)}</span>
|
|
1170
|
+
${r.impact ? `<span class="rec-impact ${impactClass}">${escHtml(r.impact)}</span>` : ''}
|
|
1171
|
+
</div>`;
|
|
1172
|
+
}).join('');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Show modal
|
|
1176
|
+
const modal = document.getElementById('applyModal');
|
|
1177
|
+
if (modal) modal.style.display = 'flex';
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function closeApplyModal() {
|
|
1181
|
+
const modal = document.getElementById('applyModal');
|
|
1182
|
+
if (modal) modal.style.display = 'none';
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function confirmApplyRecommendations() {
|
|
1186
|
+
const selected = Object.entries(editedRecommendations)
|
|
1187
|
+
.filter(([_, v]) => v.selected)
|
|
1188
|
+
.map(([i]) => parseInt(i));
|
|
1189
|
+
|
|
1190
|
+
const btn = document.getElementById('applyModalConfirmBtn');
|
|
1191
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Applying…'; }
|
|
1192
|
+
|
|
1193
|
+
try {
|
|
1194
|
+
const res = await fetch(`${API_BASE_URL}/api/apply`, {
|
|
1195
|
+
method: 'POST',
|
|
1196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1197
|
+
body: JSON.stringify({ confirm: true })
|
|
1198
|
+
});
|
|
1199
|
+
const json = await res.json();
|
|
1200
|
+
if (json.success) {
|
|
1201
|
+
closeApplyModal();
|
|
1202
|
+
showToast(`✅ ${selected.length} optimization(s) applied!`);
|
|
1203
|
+
selected.forEach(i => {
|
|
1204
|
+
if (editedRecommendations[i]) editedRecommendations[i].selected = false;
|
|
1205
|
+
});
|
|
1206
|
+
populateStepPanel(3);
|
|
1207
|
+
updateRecommendations();
|
|
1208
|
+
} else {
|
|
1209
|
+
showToast(`Error: ${json.error || 'Apply failed'}`);
|
|
1210
|
+
}
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
showToast(`Network error: ${err.message}`);
|
|
1213
|
+
} finally {
|
|
1214
|
+
if (btn) { btn.disabled = false; btn.textContent = 'Apply Now'; }
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
994
1218
|
/* ─── OpenRouter Integration ─── */
|
|
995
1219
|
async function checkOpenRouterConfig() {
|
|
996
1220
|
try {
|
|
@@ -1051,7 +1275,7 @@ async function fetchOpenRouterUsage() {
|
|
|
1051
1275
|
function openConfigModal() {
|
|
1052
1276
|
document.getElementById('configModal').style.display = 'flex';
|
|
1053
1277
|
const input = document.getElementById('apiKeyInput');
|
|
1054
|
-
const stored = localStorage.getItem('
|
|
1278
|
+
const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
|
|
1055
1279
|
if (stored && !input.value) input.value = stored;
|
|
1056
1280
|
input.focus();
|
|
1057
1281
|
const status = document.getElementById('configStatus');
|
|
@@ -1121,7 +1345,7 @@ async function saveApiKey() {
|
|
|
1121
1345
|
}
|
|
1122
1346
|
|
|
1123
1347
|
if (validated) {
|
|
1124
|
-
localStorage.setItem('
|
|
1348
|
+
localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
|
|
1125
1349
|
showStatus('✅ API key saved and validated!', 'success');
|
|
1126
1350
|
setTimeout(() => {
|
|
1127
1351
|
closeConfigModal();
|
|
@@ -1270,16 +1494,8 @@ async function refreshDashboard() {
|
|
|
1270
1494
|
|
|
1271
1495
|
/* ─── Cost Data Notice ─── */
|
|
1272
1496
|
function checkCostDataNotice() {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
const hasCostData = (d.monthly_projected_current || 0) > 0;
|
|
1276
|
-
const notice = document.getElementById('costDataNotice');
|
|
1277
|
-
if (notice) {
|
|
1278
|
-
// Only show when cost data is truly missing — keep hidden otherwise
|
|
1279
|
-
if (!hasCostData) {
|
|
1280
|
-
notice.style.display = 'flex';
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1497
|
+
// Delegate to contextual banner system
|
|
1498
|
+
updateContextualBanner(activeStep);
|
|
1283
1499
|
}
|
|
1284
1500
|
|
|
1285
1501
|
/* ─── Helpers ─── */
|
|
@@ -119,21 +119,21 @@
|
|
|
119
119
|
|
|
120
120
|
<!-- 3-Step Flow -->
|
|
121
121
|
<div class="gs-steps">
|
|
122
|
-
<div class="gs-step" id="gsStep1">
|
|
122
|
+
<div class="gs-step gs-step-active" id="gsStep1" onclick="activateStep(1)">
|
|
123
123
|
<div class="gs-step-num">1</div>
|
|
124
124
|
<div>
|
|
125
125
|
<div class="gs-step-title">Analyze</div>
|
|
126
126
|
<div class="gs-step-desc">Parse session logs to classify tasks, track costs, and break down model usage.</div>
|
|
127
127
|
</div>
|
|
128
128
|
</div>
|
|
129
|
-
<div class="gs-step" id="gsStep2">
|
|
129
|
+
<div class="gs-step" id="gsStep2" onclick="activateStep(2)">
|
|
130
130
|
<div class="gs-step-num">2</div>
|
|
131
131
|
<div>
|
|
132
132
|
<div class="gs-step-title">Evaluate</div>
|
|
133
133
|
<div class="gs-step-desc">Compare models side-by-side — see which cost the most and where cheaper alternatives save money.</div>
|
|
134
134
|
</div>
|
|
135
135
|
</div>
|
|
136
|
-
<div class="gs-step" id="gsStep3">
|
|
136
|
+
<div class="gs-step" id="gsStep3" onclick="activateStep(3)">
|
|
137
137
|
<div class="gs-step-num">3</div>
|
|
138
138
|
<div>
|
|
139
139
|
<div class="gs-step-title">Guide</div>
|
|
@@ -141,12 +141,23 @@
|
|
|
141
141
|
</div>
|
|
142
142
|
</div>
|
|
143
143
|
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Step Detail Panels -->
|
|
146
|
+
<div class="gs-step-panel" id="gsPanel1">
|
|
147
|
+
<!-- Analyze: shown by default with analysis summary -->
|
|
148
|
+
</div>
|
|
149
|
+
<div class="gs-step-panel" id="gsPanel2" style="display:none">
|
|
150
|
+
<!-- Evaluate: model cost breakdown table -->
|
|
151
|
+
</div>
|
|
152
|
+
<div class="gs-step-panel" id="gsPanel3" style="display:none">
|
|
153
|
+
<!-- Guide: top recommendations -->
|
|
154
|
+
</div>
|
|
144
155
|
</div>
|
|
145
156
|
|
|
146
|
-
<!-- Info Banner -->
|
|
157
|
+
<!-- Contextual Info Banner -->
|
|
147
158
|
<div class="alert alert-info" id="costDataNotice" style="display:none;">
|
|
148
159
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
|
149
|
-
<div>
|
|
160
|
+
<div id="costDataNoticeContent">
|
|
150
161
|
<strong>About Cost Tracking</strong>
|
|
151
162
|
<p>Your OpenRouter API responses may not include cost data. SmartMeter still optimizes based on token usage.</p>
|
|
152
163
|
</div>
|
|
@@ -160,7 +171,7 @@
|
|
|
160
171
|
<div class="kpi-sub" id="savingsPercentage">--</div>
|
|
161
172
|
</div>
|
|
162
173
|
<div class="kpi-card">
|
|
163
|
-
<div class="kpi-label">
|
|
174
|
+
<div class="kpi-label">Projected Monthly Cost</div>
|
|
164
175
|
<div class="kpi-value" id="currentCost">$0.00<small>/mo</small></div>
|
|
165
176
|
</div>
|
|
166
177
|
<div class="kpi-card kpi-success">
|
|
@@ -402,6 +413,31 @@
|
|
|
402
413
|
</div>
|
|
403
414
|
</div>
|
|
404
415
|
|
|
416
|
+
<!-- Apply Recommendations Modal -->
|
|
417
|
+
<div id="applyModal" class="modal-overlay" style="display:none">
|
|
418
|
+
<div class="modal">
|
|
419
|
+
<div class="modal-header">
|
|
420
|
+
<h3>Apply Optimizations</h3>
|
|
421
|
+
<button class="btn-icon modal-close" onclick="closeApplyModal()">✕</button>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="modal-body">
|
|
424
|
+
<p class="modal-desc">The following recommendations will be applied to your OpenClaw configuration at <code>~/.openclaw/openclaw.json</code>.</p>
|
|
425
|
+
<div id="applyModalList" class="apply-modal-list"></div>
|
|
426
|
+
<div class="apply-revert-info">
|
|
427
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
|
428
|
+
<div>
|
|
429
|
+
<strong>Easy to undo</strong>
|
|
430
|
+
<p>To revert, run <code>smartmeter reset</code> in your terminal, or edit <code>~/.openclaw/openclaw.json</code> directly. You can also toggle individual recommendations off here and re-apply.</p>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="modal-footer">
|
|
435
|
+
<button class="btn btn-ghost" onclick="closeApplyModal()">Cancel</button>
|
|
436
|
+
<button class="btn btn-primary" id="applyModalConfirmBtn" onclick="confirmApplyRecommendations()">Apply Now</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
405
441
|
<!-- Preview Modal -->
|
|
406
442
|
<div id="previewModal" class="modal-overlay" style="display:none">
|
|
407
443
|
<div class="modal modal-lg">
|
|
@@ -399,7 +399,12 @@ a:hover { color: var(--accent-hover); }
|
|
|
399
399
|
background: var(--bg-surface);
|
|
400
400
|
border-radius: var(--radius);
|
|
401
401
|
border: 1px solid var(--border);
|
|
402
|
-
transition: border-color .2s;
|
|
402
|
+
transition: border-color .2s, background .2s;
|
|
403
|
+
cursor: pointer;
|
|
404
|
+
}
|
|
405
|
+
.gs-step:hover {
|
|
406
|
+
border-color: var(--border-light);
|
|
407
|
+
background: var(--bg-card-hover);
|
|
403
408
|
}
|
|
404
409
|
.gs-step.gs-step-active {
|
|
405
410
|
border-color: var(--accent);
|
|
@@ -437,6 +442,167 @@ a:hover { color: var(--accent-hover); }
|
|
|
437
442
|
line-height: 1.5;
|
|
438
443
|
}
|
|
439
444
|
|
|
445
|
+
/* Step detail panels */
|
|
446
|
+
.gs-step-panel {
|
|
447
|
+
margin-top: 16px;
|
|
448
|
+
background: var(--bg-surface);
|
|
449
|
+
border: 1px solid var(--border);
|
|
450
|
+
border-radius: var(--radius);
|
|
451
|
+
padding: 18px;
|
|
452
|
+
animation: fadeIn .25s ease;
|
|
453
|
+
}
|
|
454
|
+
.gs-step-panel h4 {
|
|
455
|
+
font-size: 14px;
|
|
456
|
+
font-weight: 700;
|
|
457
|
+
color: var(--text-primary);
|
|
458
|
+
margin: 0 0 12px 0;
|
|
459
|
+
}
|
|
460
|
+
.gs-step-panel p {
|
|
461
|
+
font-size: 13px;
|
|
462
|
+
color: var(--text-secondary);
|
|
463
|
+
margin: 0 0 12px 0;
|
|
464
|
+
line-height: 1.5;
|
|
465
|
+
}
|
|
466
|
+
.gs-step-panel table {
|
|
467
|
+
width: 100%;
|
|
468
|
+
border-collapse: collapse;
|
|
469
|
+
font-size: 13px;
|
|
470
|
+
}
|
|
471
|
+
.gs-step-panel th {
|
|
472
|
+
text-align: left;
|
|
473
|
+
font-weight: 600;
|
|
474
|
+
color: var(--text-muted);
|
|
475
|
+
padding: 6px 10px;
|
|
476
|
+
border-bottom: 1px solid var(--border);
|
|
477
|
+
font-size: 11px;
|
|
478
|
+
text-transform: uppercase;
|
|
479
|
+
letter-spacing: .5px;
|
|
480
|
+
}
|
|
481
|
+
.gs-step-panel td {
|
|
482
|
+
padding: 8px 10px;
|
|
483
|
+
color: var(--text-primary);
|
|
484
|
+
border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04));
|
|
485
|
+
}
|
|
486
|
+
.gs-step-panel tr:last-child td {
|
|
487
|
+
border-bottom: none;
|
|
488
|
+
}
|
|
489
|
+
.gs-step-panel .panel-empty {
|
|
490
|
+
color: var(--text-muted);
|
|
491
|
+
font-style: italic;
|
|
492
|
+
font-size: 13px;
|
|
493
|
+
}
|
|
494
|
+
.gs-step-panel .rec-item {
|
|
495
|
+
padding: 10px 0;
|
|
496
|
+
border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04));
|
|
497
|
+
}
|
|
498
|
+
.gs-step-panel .rec-item:last-child {
|
|
499
|
+
border-bottom: none;
|
|
500
|
+
}
|
|
501
|
+
.gs-step-panel .rec-title {
|
|
502
|
+
font-weight: 600;
|
|
503
|
+
color: var(--text-primary);
|
|
504
|
+
font-size: 13px;
|
|
505
|
+
margin-bottom: 4px;
|
|
506
|
+
}
|
|
507
|
+
.gs-step-panel .rec-desc {
|
|
508
|
+
font-size: 12px;
|
|
509
|
+
color: var(--text-muted);
|
|
510
|
+
line-height: 1.5;
|
|
511
|
+
}
|
|
512
|
+
.gs-step-panel .rec-impact {
|
|
513
|
+
display: inline-block;
|
|
514
|
+
font-size: 11px;
|
|
515
|
+
font-weight: 600;
|
|
516
|
+
padding: 2px 8px;
|
|
517
|
+
border-radius: 10px;
|
|
518
|
+
margin-top: 4px;
|
|
519
|
+
}
|
|
520
|
+
.gs-step-panel .rec-impact.high {
|
|
521
|
+
background: var(--green-subtle);
|
|
522
|
+
color: var(--green);
|
|
523
|
+
}
|
|
524
|
+
.gs-step-panel .rec-impact.medium {
|
|
525
|
+
background: var(--accent-subtle);
|
|
526
|
+
color: var(--accent);
|
|
527
|
+
}
|
|
528
|
+
.gs-step-panel .rec-impact.low {
|
|
529
|
+
background: rgba(255,255,255,0.06);
|
|
530
|
+
color: var(--text-muted);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* Guide panel – recommendation list */
|
|
534
|
+
.gs-rec-list {
|
|
535
|
+
display: flex;
|
|
536
|
+
flex-direction: column;
|
|
537
|
+
gap: 2px;
|
|
538
|
+
}
|
|
539
|
+
.gs-rec-row {
|
|
540
|
+
display: flex;
|
|
541
|
+
align-items: flex-start;
|
|
542
|
+
gap: 12px;
|
|
543
|
+
padding: 12px 14px;
|
|
544
|
+
border-radius: 8px;
|
|
545
|
+
cursor: pointer;
|
|
546
|
+
transition: background .15s;
|
|
547
|
+
}
|
|
548
|
+
.gs-rec-row:hover {
|
|
549
|
+
background: rgba(255,255,255,0.03);
|
|
550
|
+
}
|
|
551
|
+
.gs-rec-toggle {
|
|
552
|
+
appearance: none;
|
|
553
|
+
-webkit-appearance: none;
|
|
554
|
+
width: 38px;
|
|
555
|
+
min-width: 38px;
|
|
556
|
+
height: 22px;
|
|
557
|
+
border-radius: 11px;
|
|
558
|
+
background: var(--border);
|
|
559
|
+
position: relative;
|
|
560
|
+
cursor: pointer;
|
|
561
|
+
transition: background .2s;
|
|
562
|
+
margin-top: 2px;
|
|
563
|
+
}
|
|
564
|
+
.gs-rec-toggle::after {
|
|
565
|
+
content: '';
|
|
566
|
+
position: absolute;
|
|
567
|
+
top: 3px;
|
|
568
|
+
left: 3px;
|
|
569
|
+
width: 16px;
|
|
570
|
+
height: 16px;
|
|
571
|
+
border-radius: 50%;
|
|
572
|
+
background: var(--text-muted);
|
|
573
|
+
transition: transform .2s, background .2s;
|
|
574
|
+
}
|
|
575
|
+
.gs-rec-toggle:checked {
|
|
576
|
+
background: var(--accent);
|
|
577
|
+
}
|
|
578
|
+
.gs-rec-toggle:checked::after {
|
|
579
|
+
transform: translateX(16px);
|
|
580
|
+
background: #fff;
|
|
581
|
+
}
|
|
582
|
+
.gs-rec-body {
|
|
583
|
+
flex: 1;
|
|
584
|
+
min-width: 0;
|
|
585
|
+
}
|
|
586
|
+
.gs-rec-header {
|
|
587
|
+
display: flex;
|
|
588
|
+
align-items: center;
|
|
589
|
+
gap: 8px;
|
|
590
|
+
flex-wrap: wrap;
|
|
591
|
+
margin-bottom: 4px;
|
|
592
|
+
}
|
|
593
|
+
.gs-rec-actions {
|
|
594
|
+
display: flex;
|
|
595
|
+
align-items: center;
|
|
596
|
+
gap: 14px;
|
|
597
|
+
margin-top: 16px;
|
|
598
|
+
padding-top: 14px;
|
|
599
|
+
border-top: 1px solid var(--border);
|
|
600
|
+
}
|
|
601
|
+
.gs-rec-count {
|
|
602
|
+
font-size: 12px;
|
|
603
|
+
color: var(--text-muted);
|
|
604
|
+
}
|
|
605
|
+
|
|
440
606
|
@media (max-width: 768px) {
|
|
441
607
|
.gs-steps {
|
|
442
608
|
grid-template-columns: 1fr;
|
|
@@ -924,6 +1090,67 @@ a:hover { color: var(--accent-hover); }
|
|
|
924
1090
|
padding: 12px 24px 20px;
|
|
925
1091
|
}
|
|
926
1092
|
|
|
1093
|
+
/* Apply Modal */
|
|
1094
|
+
.apply-modal-list {
|
|
1095
|
+
display: flex;
|
|
1096
|
+
flex-direction: column;
|
|
1097
|
+
gap: 8px;
|
|
1098
|
+
margin-bottom: 16px;
|
|
1099
|
+
max-height: 200px;
|
|
1100
|
+
overflow-y: auto;
|
|
1101
|
+
}
|
|
1102
|
+
.apply-modal-item {
|
|
1103
|
+
display: flex;
|
|
1104
|
+
align-items: center;
|
|
1105
|
+
gap: 10px;
|
|
1106
|
+
padding: 10px 12px;
|
|
1107
|
+
background: var(--bg-card);
|
|
1108
|
+
border: 1px solid var(--border);
|
|
1109
|
+
border-radius: var(--radius-sm);
|
|
1110
|
+
font-size: 13px;
|
|
1111
|
+
color: var(--text-primary);
|
|
1112
|
+
}
|
|
1113
|
+
.apply-modal-dot {
|
|
1114
|
+
flex-shrink: 0;
|
|
1115
|
+
width: 8px;
|
|
1116
|
+
height: 8px;
|
|
1117
|
+
border-radius: 50%;
|
|
1118
|
+
}
|
|
1119
|
+
.apply-modal-dot.high { background: var(--green); }
|
|
1120
|
+
.apply-modal-dot.medium { background: var(--accent); }
|
|
1121
|
+
.apply-modal-dot.low { background: var(--text-muted); }
|
|
1122
|
+
.apply-revert-info {
|
|
1123
|
+
display: flex;
|
|
1124
|
+
gap: 10px;
|
|
1125
|
+
padding: 12px 14px;
|
|
1126
|
+
background: var(--accent-subtle);
|
|
1127
|
+
border: 1px solid rgba(99,102,241,.2);
|
|
1128
|
+
border-radius: var(--radius-sm);
|
|
1129
|
+
font-size: 12px;
|
|
1130
|
+
color: var(--text-secondary);
|
|
1131
|
+
line-height: 1.5;
|
|
1132
|
+
}
|
|
1133
|
+
.apply-revert-info svg {
|
|
1134
|
+
flex-shrink: 0;
|
|
1135
|
+
margin-top: 2px;
|
|
1136
|
+
}
|
|
1137
|
+
.apply-revert-info strong {
|
|
1138
|
+
display: block;
|
|
1139
|
+
color: var(--text-primary);
|
|
1140
|
+
font-size: 13px;
|
|
1141
|
+
margin-bottom: 4px;
|
|
1142
|
+
}
|
|
1143
|
+
.apply-revert-info p {
|
|
1144
|
+
margin: 0;
|
|
1145
|
+
}
|
|
1146
|
+
.apply-revert-info code {
|
|
1147
|
+
background: rgba(255,255,255,.06);
|
|
1148
|
+
padding: 1px 5px;
|
|
1149
|
+
border-radius: 3px;
|
|
1150
|
+
font-size: 11px;
|
|
1151
|
+
font-family: var(--font-mono);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
927
1154
|
/* Form */
|
|
928
1155
|
.form-label {
|
|
929
1156
|
display: block;
|
package/package.json
CHANGED
package/src/cli/commands.js
CHANGED
|
@@ -92,9 +92,9 @@ export async function cmdAnalyze(opts = {}) {
|
|
|
92
92
|
const apiServer = await startApiServer({ port: apiPort });
|
|
93
93
|
const actualApiPort = apiServer.server.address().port;
|
|
94
94
|
|
|
95
|
-
// Start static file server in background
|
|
95
|
+
// Start static file server in background (pass API port for injection)
|
|
96
96
|
console.log("✓ Starting dashboard server...");
|
|
97
|
-
const staticServer = await startStaticFileServer(deployer.canvasDir, port);
|
|
97
|
+
const staticServer = await startStaticFileServer(deployer.canvasDir, port, { apiPort: actualApiPort });
|
|
98
98
|
const actualPort = staticServer.port;
|
|
99
99
|
|
|
100
100
|
const url = `http://localhost:${actualPort}`;
|
|
@@ -885,10 +885,15 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
|
885
885
|
return null;
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
-
async function startStaticFileServer(directory, port) {
|
|
888
|
+
async function startStaticFileServer(directory, port, options = {}) {
|
|
889
889
|
const { createServer } = await import("node:http");
|
|
890
890
|
const { readFile, stat } = await import("node:fs/promises");
|
|
891
891
|
const { join, extname } = await import("node:path");
|
|
892
|
+
const { userInfo } = await import("node:os");
|
|
893
|
+
|
|
894
|
+
const apiPort = options.apiPort || 3001;
|
|
895
|
+
const osUser = (() => { try { return userInfo().username; } catch { return ''; } })();
|
|
896
|
+
const portScript = `<script>window.__SMARTMETER_API_PORT=${apiPort};window.__SMARTMETER_USER=${JSON.stringify(osUser)};</script>`;
|
|
892
897
|
|
|
893
898
|
const mimeTypes = {
|
|
894
899
|
'.html': 'text/html',
|
|
@@ -916,10 +921,20 @@ async function startStaticFileServer(directory, port) {
|
|
|
916
921
|
}
|
|
917
922
|
|
|
918
923
|
// Read and serve file
|
|
919
|
-
|
|
924
|
+
let content = await readFile(filePath);
|
|
920
925
|
const ext = extname(filePath);
|
|
921
926
|
const mimeType = mimeTypes[ext] || 'application/octet-stream';
|
|
922
927
|
|
|
928
|
+
// Inject API port and OS username into index.html so dashboard connects
|
|
929
|
+
// to the correct API server and scopes localStorage per user
|
|
930
|
+
if (ext === '.html') {
|
|
931
|
+
let html = content.toString('utf-8');
|
|
932
|
+
html = html.replace('</head>', `${portScript}\n</head>`);
|
|
933
|
+
res.writeHead(200, { 'Content-Type': mimeType });
|
|
934
|
+
res.end(html);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
923
938
|
res.writeHead(200, { 'Content-Type': mimeType });
|
|
924
939
|
res.end(content);
|
|
925
940
|
} catch (error) {
|