224 files
This commit is contained in:
@@ -109,8 +109,6 @@ const _SETTINGS_SECTIONS = [
|
||||
doc: 'Energy (power x time) the appliance must consume before RUNNING. A brief high-power spike has very low energy and is ignored, preventing false starts.' },
|
||||
{ key: 'completion_min_seconds', label: 'Min Cycle Duration', unit: 's', type: 'number', min: 0, def: 600, basic: true,
|
||||
doc: 'Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock).' },
|
||||
{ key: 'running_dead_zone', label: 'Running Dead Zone', unit: 's', type: 'number', min: 0, def: 3,
|
||||
doc: 'After a cycle starts, power dips within this window are ignored. Washing machines fill with cold water (dropping near 0 W before heating) - without this protection that fill phase looks like a cycle end. This does NOT skip data: the full power trace is recorded from T=0. The suggestion engine measures your machine\'s actual startup pattern and sizes this automatically.' },
|
||||
] },
|
||||
{ sub: 'Cycle End', fields: [
|
||||
{ key: 'end_energy_threshold', label: 'End Energy', unit: 'Wh', type: 'number', step: 0.001, min: 0, def: 0.05,
|
||||
@@ -190,6 +188,8 @@ const _SETTINGS_SECTIONS = [
|
||||
doc: 'Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble.' },
|
||||
{ key: 'anti_wrinkle_exit_power', label: 'Exit Power Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0.8,
|
||||
doc: 'Power must fall below this between pulses for anti-wrinkle mode to stay active.' },
|
||||
{ key: 'anti_wrinkle_idle_timeout', label: 'Max Pulse Gap', unit: 's', type: 'number', step: 30, min: 0, def: 120,
|
||||
doc: 'How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start.' },
|
||||
] },
|
||||
{ id: 'delay', label: 'Delay Start', intro: 'Delayed-start detection identifies when an appliance is powered but has not yet begun its cycle.', fields: [
|
||||
{ key: 'delay_start_detect_enabled', label: 'Enable Delay-Start Detection', type: 'checkbox',
|
||||
@@ -1509,6 +1509,7 @@ const _DIAGRAM_BY_KEY = {
|
||||
no_update_active_timeout: 'watchdog_timeout',
|
||||
anti_wrinkle_enabled: 'anti_wrinkle', anti_wrinkle_max_power: 'anti_wrinkle',
|
||||
anti_wrinkle_max_duration: 'anti_wrinkle', anti_wrinkle_exit_power: 'anti_wrinkle',
|
||||
anti_wrinkle_idle_timeout: 'anti_wrinkle',
|
||||
sampling_interval: 'sampling',
|
||||
};
|
||||
|
||||
@@ -1694,7 +1695,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
this._hoverRafId = null; // rAF handle for chart-hover coalescing
|
||||
this._hoverPending = null; // last pending hover coords {px, py, id}
|
||||
// Data
|
||||
this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} };
|
||||
this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {}, version: '', iconUrl: '' };
|
||||
this._constantsLoaded = false;
|
||||
this._devices = [];
|
||||
this._cycles = [];
|
||||
@@ -1810,6 +1811,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
this._pgView = null; // {min,max} time-axis zoom window (seconds); null = full
|
||||
this._pgHoverT = null; // hovered time (seconds) for cursor readout; null = none
|
||||
this._pgMap = null; // current time<->x mapping, set by _pgDrawCanvas
|
||||
this._pgStressTail = false; // idle termination test toggle
|
||||
this._pgStressIdleW = null; // null = auto-derive; number = manual override (W)
|
||||
this._pgPanStart = null; // pan drag anchor {clientX, vMin, vMax, totalDur}
|
||||
this._pgHoverEvent = null; // {t,type} of the event pin under the cursor (tooltip)
|
||||
this._pgBatchProgress = null; // {done,total} for history/sweep chunked runs (determinate bar)
|
||||
@@ -2354,7 +2357,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
if (!this._constantsLoaded) {
|
||||
try {
|
||||
const c = await this._ws({ type: `${_DOMAIN}/get_constants` });
|
||||
this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {}, pgMatchDefaults: c.pg_match_defaults || {}, PROFILE_MIN_WARMUP_CYCLES: c.PROFILE_MIN_WARMUP_CYCLES };
|
||||
this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {}, pgMatchDefaults: c.pg_match_defaults || {}, PROFILE_MIN_WARMUP_CYCLES: c.PROFILE_MIN_WARMUP_CYCLES, version: c.version || '', iconUrl: c.icon_url || '' };
|
||||
} catch (_) { /* fall back to humanized labels */ }
|
||||
try {
|
||||
this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` });
|
||||
@@ -2858,6 +2861,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
this._busy.delete('pg-history'); this._busy.delete('pg-sweep'); this._pgBatchProgress = null;
|
||||
// Cancel any pending detail re-run so it can't repopulate the outgoing device.
|
||||
if (this._pgDetailDebounceTimer) { clearTimeout(this._pgDetailDebounceTimer); this._pgDetailDebounceTimer = null; }
|
||||
this._pgStressTail = false; this._pgStressIdleW = null;
|
||||
// Reset the Community Store browse on device change (status re-fetched per-tab).
|
||||
this._storeView = 'brands'; this._storeDevice = null; this._storeProfile = null; this._storeQuery = '';
|
||||
this._storeDevices = []; this._storeProfiles = []; this._storeCycles = [];
|
||||
@@ -3467,12 +3471,16 @@ class HaWashdataPanel extends HTMLElement {
|
||||
const working = nonTaskBusy
|
||||
? `<span class="wd-badge" style="margin:0 0 0 12px;color:var(--app-header-text-color,#fff);background:rgba(255,255,255,.15)">${this._t('status.working', {}, 'Working…')}</span>`
|
||||
: '';
|
||||
const logo = `<svg class="wd-logo" viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
|
||||
const iconUrl = this._constants.iconUrl;
|
||||
const logo = iconUrl
|
||||
? `<img class="wd-logo" src="${_esc(iconUrl)}" width="30" height="30" alt="WashData" aria-hidden="true" style="border-radius:6px;object-fit:contain">`
|
||||
: `<svg class="wd-logo" viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
|
||||
<rect x="4" y="2.5" width="16" height="19" rx="2.5"/>
|
||||
<line x1="7" y1="6" x2="9.5" y2="6"/>
|
||||
<circle cx="12" cy="14" r="5"/>
|
||||
<circle cx="12" cy="14" r="2"/>
|
||||
</svg>`;
|
||||
const ver = this._constants.version;
|
||||
const burger = `<button class="wd-burger" id="wd-burger" aria-label="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}" title="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/></svg>
|
||||
</button>`;
|
||||
@@ -3480,7 +3488,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
<div class="wd-header">
|
||||
${burger}
|
||||
${logo}
|
||||
<div><h1>WashData</h1><div class="wd-sub">${this._t('msg.appliance_monitor', {}, 'Appliance monitor')}</div></div>
|
||||
<div><h1>WashData</h1><div class="wd-sub">${ver ? `v${_esc(ver)} · ` : ''}${this._t('msg.appliance_monitor', {}, 'Appliance monitor')}</div></div>
|
||||
${working}
|
||||
<span class="wd-task-pills" id="wd-task-pills">${this._htmlTaskPills()}</span>
|
||||
<span style="flex:1"></span>
|
||||
@@ -5219,6 +5227,11 @@ class HaWashdataPanel extends HTMLElement {
|
||||
['start_duration_threshold','Start Duration', 's', 'Seconds above threshold to confirm start', 'timing'],
|
||||
['end_repeat_count', 'End Repeat Count', '', 'Low readings in a row before ending', 'advanced'],
|
||||
['interrupted_min_seconds', 'Interrupted Min', 's', 'Short cycles flagged as interrupted', 'advanced'],
|
||||
['anti_wrinkle_enabled', 'Enable Anti-Wrinkle Detection', '', 'Absorb the tumble pulses after the main phase instead of reading them as new cycles', 'advanced', 'bool'],
|
||||
['anti_wrinkle_max_power', 'Max Anti-Wrinkle Power','W', 'A pulse above this ends anti-wrinkle and opens a new cycle', 'advanced'],
|
||||
['anti_wrinkle_max_duration','Max Anti-Wrinkle Duration','s', 'A pulse longer than this ends anti-wrinkle and opens a new cycle', 'advanced'],
|
||||
['anti_wrinkle_exit_power', 'Anti-Wrinkle Exit Power','W', 'Power must fall below this between pulses for anti-wrinkle to stay active', 'advanced'],
|
||||
['anti_wrinkle_idle_timeout','Max Pulse Gap', 's', 'Quiet time allowed between two tumble pulses before anti-wrinkle ends', 'advanced'],
|
||||
['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'],
|
||||
['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'],
|
||||
['corr_weight', 'Correlation Weight', '', 'Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45', 'matching'],
|
||||
@@ -5358,13 +5371,14 @@ class HaWashdataPanel extends HTMLElement {
|
||||
matching: this._t('lbl.pg_group_matching', {}, 'Program matching'),
|
||||
};
|
||||
let lastGroup = '';
|
||||
const paramRows = fields.map(([key, fb, unit, desc, group]) => {
|
||||
const paramRows = fields.map(([key, fb, unit, desc, group, type]) => {
|
||||
const lbl = this._t('setting.' + key + '.label', {}, fb);
|
||||
const liveVal = this._pgFieldVal(key, {});
|
||||
let curVal;
|
||||
if (key === 'start_threshold_w') curVal = this._pgThreshStart ?? liveVal;
|
||||
else if (key === 'stop_threshold_w') curVal = this._pgThreshStop ?? liveVal;
|
||||
else curVal = this._pgParamOverrides[key] ?? liveVal;
|
||||
const isBool = type === 'bool';
|
||||
const isDrag = threshFields.has(key);
|
||||
const unitTxt = unit ? unit : '';
|
||||
const gc = groupColors[group] || '#2a78d6';
|
||||
@@ -5383,7 +5397,9 @@ class HaWashdataPanel extends HTMLElement {
|
||||
${desc ? `<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('pg_desc.' + key, {}, desc))}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<input class="wd-pg-param-inp" type="number" data-pgkey="${_esc(key)}" value="${curVal !== '' ? _esc(String(curVal)) : ''}" placeholder="${liveVal !== '' ? _esc(String(liveVal)) : ''}" style="width:72px">
|
||||
${isBool
|
||||
? `<input class="wd-pg-param-chk" type="checkbox" data-pgkey="${_esc(key)}" data-pgtype="bool" aria-label="${_esc(lbl)}" ${curVal ? 'checked' : ''} style="width:18px;height:18px;margin:1px 27px 0 27px">`
|
||||
: `<input class="wd-pg-param-inp" type="text" inputmode="decimal" data-pgkey="${_esc(key)}" value="${curVal !== '' ? _esc(String(curVal)) : ''}" placeholder="${liveVal !== '' ? _esc(String(liveVal)) : ''}" aria-label="${_esc(lbl)}" style="width:72px">`}
|
||||
${unitTxt ? `<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">${_esc(unitTxt)}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -5393,9 +5409,34 @@ class HaWashdataPanel extends HTMLElement {
|
||||
const applyBtn = (this._canEdit() && hasOverrides)
|
||||
? `<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-apply-settings" title="${_esc(this._t('btn.pg_apply_to_settings_tip', {}, 'Copy the values you edited here into this device\'s live settings'))}">${this._t('btn.pg_apply_to_settings', {}, 'Save to settings')}</button>`
|
||||
: '';
|
||||
const stressGc = '#c0392b';
|
||||
const stressHeader = `<div style="display:flex;align-items:center;gap:8px;margin:12px 0 5px">
|
||||
<div style="width:3px;height:14px;border-radius:2px;background:${stressGc};flex-shrink:0"></div>
|
||||
<span style="font-size:.7em;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--secondary-text-color)">${_esc(this._t('lbl.pg_stress_group', {}, 'Idle termination test'))}</span>
|
||||
</div>`;
|
||||
const stressToggle = `<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${_esc(this._t('lbl.pg_stress_toggle', {}, 'Test idle termination'))}</div>
|
||||
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('lbl.pg_stress_toggle_desc', {}, 'Simulates the appliance staying at its standby draw after recording ends — shows if and when WashData stops the cycle.'))}</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:4px;flex-shrink:0;cursor:pointer">
|
||||
<input type="checkbox" id="wd-pg-stress-toggle" ${this._pgStressTail ? 'checked' : ''} aria-label="${_esc(this._t('lbl.pg_stress_toggle', {}, 'Test idle termination'))}">
|
||||
</label>
|
||||
</div>`;
|
||||
const stressIdleField = this._pgStressTail ? `<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${_esc(this._t('lbl.pg_stress_idle_w', {}, 'Idle level (W)'))}</div>
|
||||
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('lbl.pg_stress_idle_w_desc', {}, 'Override the auto-detected standby floor (leave blank for auto).'))}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<input class="wd-pg-param-inp" type="text" inputmode="decimal" id="wd-pg-stress-idle-w" value="${this._pgStressIdleW != null ? _esc(String(this._pgStressIdleW)) : ''}" placeholder="${_esc(this._t('lbl.pg_stress_idle_auto', {}, 'Auto'))}" aria-label="${_esc(this._t('lbl.pg_stress_idle_w', {}, 'Idle level (W)'))}" style="width:72px">
|
||||
<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">W</span>
|
||||
</div>
|
||||
</div>` : '';
|
||||
const stressGroup = `${stressHeader}${stressToggle}${stressIdleField}`;
|
||||
return `<div class="wd-pg-params">
|
||||
<div class="wd-subhead" style="margin:0 0 6px">${this._t('hdr.pg_detection_params', {}, 'Detection settings')}</div>
|
||||
${paramRows}
|
||||
${paramRows}${stressGroup}
|
||||
<div style="display:flex;gap:6px;margin:8px 0 4px;align-items:center;flex-wrap:wrap">
|
||||
${applyBtn}
|
||||
<button class="wd-btn wd-btn-sm" data-action="pg-reset-params">${this._t('btn.reset', {}, 'Reset')}</button>
|
||||
@@ -5417,7 +5458,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
const alertRows = alerts.length
|
||||
? alerts.map(a => `<div class="wd-pg-alert" style="border-left-color:${sevColor[a.severity] || sevColor.info}">
|
||||
<span style="font-weight:600">${_esc(this._pgAlertLabel(a.code))}</span>
|
||||
<div style="font-size:.78em;color:var(--secondary-text-color)">${_esc(a.detail || '')}</div>
|
||||
<div style="font-size:.78em;color:var(--secondary-text-color)">${_esc(a.detail_key ? this._t(a.detail_key, a.detail_params || {}, a.detail || '') : (a.detail || ''))}</div>
|
||||
</div>`).join('')
|
||||
: `<div style="font-size:.82em;color:var(--success-color,#4caf50)">✓ ${this._t('msg.pg_no_alerts', {}, 'No issues detected in this run.')}</div>`;
|
||||
const term = o.termination_reason ? String(o.termination_reason) : '—';
|
||||
@@ -5448,6 +5489,9 @@ class HaWashdataPanel extends HTMLElement {
|
||||
energy_anomaly: this._t('lbl.pg_alert_energy', {}, 'Energy anomaly'),
|
||||
timeout_end: this._t('lbl.pg_alert_timeout_end', {}, 'Ended by timeout, not prediction'),
|
||||
would_run_indefinitely: this._t('lbl.pg_alert_indefinite', {}, 'Would run indefinitely'),
|
||||
stress_terminated: this._t('lbl.pg_alert_stress_ok', {}, 'Idle termination: cycle stopped'),
|
||||
stress_above_threshold: this._t('lbl.pg_alert_stress_warn', {}, 'Idle draw above stop threshold'),
|
||||
stress_hit_cap: this._t('lbl.pg_alert_stress_cap', {}, 'Hit safety cap'),
|
||||
};
|
||||
return map[code] || code;
|
||||
}
|
||||
@@ -5587,7 +5631,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
|
||||
_htmlPgSweepMode() {
|
||||
const busy = this._busy.has('pg-sweep');
|
||||
const fields = this._pgOverrideFields();
|
||||
// Sweeping walks a numeric range, so on/off fields are not offered here.
|
||||
const fields = this._pgOverrideFields().filter(([, , , , , type]) => type !== 'bool');
|
||||
const paramOpts = fields.map(([k, fb]) => `<option value="${k}" ${this._pgSweepParam === k ? 'selected' : ''}>${_esc(this._t('setting.' + k + '.label', {}, fb))}</option>`).join('');
|
||||
const objOpts = this._pgSweepObjectives().map(([k, lbl]) => `<option value="${k}" ${this._pgSweepObjective === k ? 'selected' : ''}>${_esc(lbl)}</option>`).join('');
|
||||
const intro = `<p class="wd-sec-intro" style="margin:0 0 8px">${this._t('msg.pg_sweep_intro2', {}, 'Find the setting that best meets an objective across your recent cycles. Nothing changes until you apply it.')}</p>`;
|
||||
@@ -5689,7 +5734,6 @@ class HaWashdataPanel extends HTMLElement {
|
||||
}
|
||||
});
|
||||
this._render();
|
||||
this._pgRerunDetail();
|
||||
}
|
||||
|
||||
async _pgApplySweepValue(val) {
|
||||
@@ -5709,7 +5753,6 @@ class HaWashdataPanel extends HTMLElement {
|
||||
else this._pgParamOverrides[paramKey] = +val;
|
||||
this._showToast(this._t('toast.settings_saved', {}, 'Settings saved; integration reloading'));
|
||||
this._render();
|
||||
this._pgRerunDetail();
|
||||
} catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); }
|
||||
});
|
||||
}
|
||||
@@ -5919,7 +5962,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
// completion here (the caller drives the busy spinner + redraw), but the
|
||||
// backend yields the event loop between chunks. A header pill shows progress.
|
||||
try {
|
||||
const r = await this._ws({ type: `${_DOMAIN}/start_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override });
|
||||
const r = await this._ws({ type: `${_DOMAIN}/start_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override, stress_tail: this._pgStressTail, stress_idle_w: this._pgStressIdleW != null ? parseFloat(this._pgStressIdleW) : null });
|
||||
const tid = r && r.task_id;
|
||||
if (!tid) throw new Error('no task id');
|
||||
this._addProvisionalTask(tid, 'pg_detail', entryId, 0);
|
||||
@@ -5967,6 +6010,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
if (st === 'running' || st === 'paused') return 'running';
|
||||
if (st === 'ending') return 'ending';
|
||||
if (st === 'starting') return 'detecting';
|
||||
if (st === 'anti_wrinkle') return 'anti_wrinkle';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
@@ -6032,8 +6076,19 @@ class HaWashdataPanel extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDur = (this._cycles || []).find(c => c.id === this._pgCycleId)?._pg_duration || pts[pts.length-1].t || 1;
|
||||
const maxW = Math.max(...pts.map(p => p.w), 1);
|
||||
// Stress tail — extract outcome early so totalDur and maxW can include it.
|
||||
const stressOut = this._pgDetail && this._pgDetail.outcome && this._pgDetail.outcome.stress;
|
||||
const stressFrom = stressOut && stressOut.enabled ? stressOut.synthetic_from_s : null;
|
||||
const stressSeries = (stressFrom != null && this._pgDetail && this._pgDetail.series)
|
||||
? this._pgDetail.series.filter(pt => pt.t >= stressFrom)
|
||||
: [];
|
||||
let totalDur = (this._cycles || []).find(c => c.id === this._pgCycleId)?._pg_duration || pts[pts.length-1].t || 1;
|
||||
if (stressOut && stressOut.terminated && stressOut.terminated_after_s != null && stressFrom != null) {
|
||||
totalDur = Math.max(totalDur, stressFrom + stressOut.terminated_after_s);
|
||||
} else if (stressSeries.length) {
|
||||
totalDur = Math.max(totalDur, stressSeries[stressSeries.length - 1].t);
|
||||
}
|
||||
const maxW = Math.max(...pts.map(p => p.w), ...stressSeries.map(p => p.power || 0), 1);
|
||||
const threshStart = this._pgThreshStart ?? this._pgFieldVal('start_threshold_w', {}) ?? 50;
|
||||
const threshStop = this._pgThreshStop ?? this._pgFieldVal('stop_threshold_w', {}) ?? 5;
|
||||
|
||||
@@ -6139,6 +6194,37 @@ class HaWashdataPanel extends HTMLElement {
|
||||
pts.forEach((p, i) => i ? ctx.lineTo(toX(p.t), toY(p.w)) : ctx.moveTo(toX(p.t), toY(p.w)));
|
||||
ctx.stroke();
|
||||
|
||||
// Stress-tail synthetic power trace — dashed continuation of the real curve.
|
||||
if (stressSeries.length) {
|
||||
const lastReal = pts[pts.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(lastReal.t), toY(lastReal.w));
|
||||
stressSeries.forEach(p => ctx.lineTo(toX(p.t), toY(p.power)));
|
||||
ctx.lineTo(toX(stressSeries[stressSeries.length - 1].t), toY(0));
|
||||
ctx.lineTo(toX(lastReal.t), toY(0));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = _withAlpha(primary, 0.06); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = primary; ctx.lineWidth = 1.5 * dpr;
|
||||
ctx.setLineDash([4 * dpr, 4 * dpr]);
|
||||
ctx.moveTo(toX(lastReal.t), toY(lastReal.w));
|
||||
stressSeries.forEach(p => ctx.lineTo(toX(p.t), toY(p.power)));
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Stress-tail synthetic region: tinted overlay from synthetic_from_s onward
|
||||
if (stressFrom != null && stressFrom < vMax) {
|
||||
const xFrom = toX(stressFrom);
|
||||
ctx.fillStyle = 'rgba(192,57,43,0.07)';
|
||||
ctx.fillRect(xFrom, padT, Math.max(0, padL + plotW - xFrom), powerH);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(192,57,43,0.5)';
|
||||
ctx.lineWidth = dpr;
|
||||
ctx.setLineDash([4*dpr, 4*dpr]);
|
||||
ctx.beginPath(); ctx.moveTo(xFrom, padT); ctx.lineTo(xFrom, padT + powerH); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore(); // end plot clip
|
||||
|
||||
// Threshold lines
|
||||
@@ -6158,8 +6244,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
drawThrLine(+threshStop, '#e34948', this._t('btn.stop', {}, 'Stop'));
|
||||
|
||||
// State band
|
||||
const stateColors = { idle: bgCol, detecting: '#42a5f566', running: '#66bb6a66', ending: '#ef535066' };
|
||||
const stateLabels = { idle: this._t('lbl.pg_idle', {}, 'Idle'), detecting: this._t('lbl.pg_detecting', {}, 'Detecting'), running: this._t('lbl.pg_ev_running', {}, 'Running'), ending: this._t('lbl.pg_ev_ending', {}, 'Ending') };
|
||||
const stateColors = { idle: bgCol, detecting: '#42a5f566', running: '#66bb6a66', ending: '#ef535066', anti_wrinkle: '#ab47bc66' };
|
||||
const stateLabels = { idle: this._t('lbl.pg_idle', {}, 'Idle'), detecting: this._t('lbl.pg_detecting', {}, 'Detecting'), running: this._t('lbl.pg_ev_running', {}, 'Running'), ending: this._t('lbl.pg_ev_ending', {}, 'Ending'), anti_wrinkle: this._t('lbl.pg_anti_wrinkle', {}, 'Anti-wrinkle') };
|
||||
const stateY = ch - stateBandH - phaseBandH;
|
||||
ctx.fillStyle = bgCol; ctx.fillRect(padL, stateY, cw - padL - padR, stateBandH);
|
||||
// Real detector state band from the backend simulation (no client-side copy).
|
||||
@@ -6377,7 +6463,9 @@ class HaWashdataPanel extends HTMLElement {
|
||||
_pgUpdateParamInput(key, val) {
|
||||
const sr = this.shadowRoot;
|
||||
const inp = sr && sr.querySelector(`[data-pgkey="${key}"]`);
|
||||
if (inp) inp.value = typeof val === 'number' ? Math.round(val) : val;
|
||||
if (!inp) return;
|
||||
if (inp.dataset.pgtype === 'bool') inp.checked = !!val;
|
||||
else inp.value = typeof val === 'number' ? Math.round(val) : val;
|
||||
}
|
||||
|
||||
// Update the readout strip for a hovered time (seconds), or the final state
|
||||
@@ -6399,6 +6487,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
detecting: [this._t('lbl.pg_detecting', {}, 'Detecting'), '#42a5f5'],
|
||||
running: [this._t('lbl.pg_ev_running', {}, 'Running'), '#66bb6a'],
|
||||
ending: [this._t('lbl.pg_ev_ending', {}, 'Ending'), '#ef5350'],
|
||||
anti_wrinkle: [this._t('lbl.pg_anti_wrinkle', {}, 'Anti-wrinkle'), '#ab47bc'],
|
||||
};
|
||||
const [stateText, stateColor] = stripStateMap[stateKey] || stripStateMap.idle;
|
||||
const pct = sp && sp.progress != null ? Math.round(sp.progress) : null;
|
||||
@@ -6530,10 +6619,12 @@ class HaWashdataPanel extends HTMLElement {
|
||||
</div>
|
||||
<div class="wd-card">
|
||||
<div class="wd-card-title">${this._t('hdr.export_import', {}, 'Export / Import')}</div>
|
||||
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.export_description', {}, 'Export all profiles and cycles to JSON, or restore from a previous export.')}</p>
|
||||
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.export_description', {}, 'Choose exactly which profiles, cycles, settings and more to export to JSON, or analyze a file and import only the parts you want.')}</p>
|
||||
<div class="wd-card-actions">
|
||||
<button class="wd-btn wd-btn-secondary" data-action="export-config">${this._t('btn.export_json', {}, 'Export to JSON')}</button>
|
||||
<button class="wd-btn wd-btn-secondary" data-action="import-config-open">${this._t('btn.import_json', {}, 'Import from JSON')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-action="export-select-open">${this._t('btn.export_selected', {}, 'Export (choose data)')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-action="import-config-open">${this._t('btn.import_json', {}, 'Import from JSON')}</button>
|
||||
<button class="wd-btn wd-btn-secondary" data-action="export-config">${this._t('btn.export_all', {}, 'Quick export everything')}</button>
|
||||
<button class="wd-btn wd-btn-secondary" data-action="import-config-raw">${this._t('btn.import_raw', {}, 'Advanced: replace all from JSON')}</button>
|
||||
</div>
|
||||
</div>` : `<div class="wd-card"><p class="wd-info">${this._t('msg.maintenance_requires_access', {}, 'Maintenance and export/import require full access.')}</p></div>`}`;
|
||||
}
|
||||
@@ -7401,6 +7492,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
if (m.type === 'profile-group') return `<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlProfileGroupModal(m)}</div></div>`;
|
||||
if (m.type === 'compare-cycles') return `<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlCompareModal(m)}</div></div>`;
|
||||
if (m.type === 'gear-settings') return `<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlGearModal(m)}</div></div>`;
|
||||
if (m.type === 'export-select') return `<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlExportSelectModal(m)}</div></div>`;
|
||||
if (m.type === 'import-wizard') return `<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlImportWizardModal(m)}</div></div>`;
|
||||
|
||||
let body = '';
|
||||
if (m.type === 'confirm') {
|
||||
@@ -7611,6 +7704,248 @@ class HaWashdataPanel extends HTMLElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Selective export/import wizard ──────────────────────────────────────────
|
||||
// Fixed display order for the category tree (mirrors _EXPORT_CATEGORIES on the
|
||||
// backend). Enumerable categories (profiles / cycles) come first.
|
||||
_wizCatOrder() {
|
||||
return ['profiles', 'real_cycles', 'reference_cycles', 'custom_phases',
|
||||
'profile_groups', 'settings', 'matching_config', 'ml_models', 'feedback',
|
||||
'suggestions', 'maintenance_log', 'history_logs', 'lifetime_stats'];
|
||||
}
|
||||
|
||||
_wizCatLabel(catId) {
|
||||
const map = {
|
||||
profiles: this._t('lbl.cat_profiles', {}, 'Profiles (programs)'),
|
||||
real_cycles: this._t('lbl.cat_real_cycles', {}, 'Cycles (run history)'),
|
||||
reference_cycles: this._t('lbl.cat_reference_cycles', {}, 'Reference cycles (imported)'),
|
||||
custom_phases: this._t('lbl.cat_custom_phases', {}, 'Custom phases'),
|
||||
profile_groups: this._t('lbl.cat_profile_groups', {}, 'Profile groups'),
|
||||
settings: this._t('lbl.cat_settings', {}, 'Detection & matching settings'),
|
||||
matching_config: this._t('lbl.cat_matching_config', {}, 'Matcher tuning'),
|
||||
ml_models: this._t('lbl.cat_ml_models', {}, 'ML models'),
|
||||
feedback: this._t('lbl.cat_feedback', {}, 'Feedback & review labels'),
|
||||
suggestions: this._t('lbl.cat_suggestions', {}, 'Suggestions'),
|
||||
maintenance_log: this._t('lbl.cat_maintenance_log', {}, 'Maintenance log'),
|
||||
history_logs: this._t('lbl.cat_history_logs', {}, 'History & change logs'),
|
||||
lifetime_stats: this._t('lbl.cat_lifetime_stats', {}, 'Lifetime totals'),
|
||||
};
|
||||
return map[catId] || catId;
|
||||
}
|
||||
|
||||
// Build a fresh selection model from a manifest. When importableOnly is set,
|
||||
// categories/items the backend marked not-importable are left unselected.
|
||||
_wizInitSel(manifest, importableOnly) {
|
||||
const cats = new Set(), profiles = new Set(), realIds = new Set(), refIds = new Set();
|
||||
const c = (manifest && manifest.categories) || {};
|
||||
const ok = (info) => !!(info && info.present && (!importableOnly || info.importable !== false));
|
||||
if (ok(c.profiles)) (c.profiles.items || []).forEach(i => profiles.add(i.name));
|
||||
if (ok(c.real_cycles)) (c.real_cycles.groups || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) realIds.add(String(cy.id)); }));
|
||||
if (ok(c.reference_cycles)) (c.reference_cycles.groups || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) refIds.add(String(cy.id)); }));
|
||||
Object.keys(c).forEach(cid => {
|
||||
if (['profiles', 'real_cycles', 'reference_cycles'].includes(cid)) return;
|
||||
if (ok(c[cid])) cats.add(cid);
|
||||
});
|
||||
return { cats, profiles, realIds, refIds };
|
||||
}
|
||||
|
||||
// The selection payload sent to the backend (categories + item subsets).
|
||||
_wizSelectionPayload(m) {
|
||||
const s = m.sel;
|
||||
const categories = [];
|
||||
if (s.profiles.size) categories.push('profiles');
|
||||
if (s.realIds.size) categories.push('real_cycles');
|
||||
if (s.refIds.size) categories.push('reference_cycles');
|
||||
s.cats.forEach(cid => categories.push(cid));
|
||||
const out = { categories };
|
||||
if (s.profiles.size) out.profiles = Array.from(s.profiles);
|
||||
if (s.realIds.size) out.real_cycle_ids = Array.from(s.realIds);
|
||||
if (s.refIds.size) out.reference_cycle_ids = Array.from(s.refIds);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ids of the cycles in one manifest group (real_cycles / reference_cycles).
|
||||
_wizGroupIds(manifest, catId, prof) {
|
||||
const cat = (manifest.categories || {})[catId] || {};
|
||||
const g = (cat.groups || []).find(gr => gr.profile === prof);
|
||||
return g ? g.cycles.map(cy => String(cy.id)).filter(id => id !== 'null') : [];
|
||||
}
|
||||
|
||||
// tri-state {sel, total, state} for a category, for the checkbox rendering.
|
||||
_wizCatState(m, catId, manifest) {
|
||||
const s = m.sel;
|
||||
const cat = (manifest.categories || {})[catId] || {};
|
||||
if (catId === 'profiles') {
|
||||
const items = cat.items || [];
|
||||
const total = items.length;
|
||||
const sel = items.filter(i => s.profiles.has(i.name)).length;
|
||||
return { sel, total, state: sel === 0 ? 'none' : (sel === total ? 'all' : 'some') };
|
||||
}
|
||||
if (catId === 'real_cycles' || catId === 'reference_cycles') {
|
||||
const set = catId === 'real_cycles' ? s.realIds : s.refIds;
|
||||
const total = cat.count || 0;
|
||||
let sel = 0;
|
||||
(cat.groups || []).forEach(g => g.cycles.forEach(cy => { if (set.has(String(cy.id))) sel++; }));
|
||||
return { sel, total, state: sel === 0 ? 'none' : (sel === total ? 'all' : 'some') };
|
||||
}
|
||||
const on = s.cats.has(catId);
|
||||
return { sel: on ? 1 : 0, total: 1, state: on ? 'all' : 'none' };
|
||||
}
|
||||
|
||||
// Render the shared tri-state category tree from a manifest/inventory.
|
||||
// opts = { importableOnly, conflicts } — conflicts marks profiles that clash.
|
||||
_htmlSelectionTree(m, manifest, opts = {}) {
|
||||
const cats = (manifest && manifest.categories) || {};
|
||||
const conflictNames = new Set();
|
||||
if (opts.conflicts) (cats.profiles && cats.profiles.items || []).forEach(i => { if (i.conflict) conflictNames.add(i.name); });
|
||||
const order = this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present);
|
||||
// Master select-all state across every currently-selectable category.
|
||||
let anySel = false, allSel = true;
|
||||
order.forEach(cid => {
|
||||
if (opts.importableOnly && cats[cid].importable === false) return;
|
||||
const st = this._wizCatState(m, cid, manifest);
|
||||
if (st.state !== 'none') anySel = true;
|
||||
if (st.state !== 'all') allSel = false;
|
||||
});
|
||||
const master = `<label class="wd-sd-prof" style="border-bottom:1px solid var(--divider-color, #444)">
|
||||
<input type="checkbox" data-maction="wiz-toggle-all" ${allSel ? 'checked' : ''} ${!allSel && anySel ? 'data-indeterminate="1"' : ''}>
|
||||
<span class="wd-sd-prof-name"><strong>${this._t('btn.select_all', {}, 'Select all')}</strong></span>
|
||||
</label>`;
|
||||
const rows = order.map(cid => {
|
||||
const info = cats[cid];
|
||||
const blocked = opts.importableOnly && info.importable === false;
|
||||
const st = this._wizCatState(m, cid, manifest);
|
||||
const enumerable = ['profiles', 'real_cycles', 'reference_cycles'].includes(cid);
|
||||
const countLbl = enumerable ? `${st.sel}/${info.count}` : '';
|
||||
const header = `<label class="wd-sd-prof">
|
||||
<input type="checkbox" data-maction="wiz-toggle-cat" data-cat="${cid}" ${st.state === 'all' ? 'checked' : ''} ${st.state === 'some' ? 'data-indeterminate="1"' : ''} ${blocked ? 'disabled' : ''}>
|
||||
<span class="wd-sd-prof-name">${_esc(this._wizCatLabel(cid))}${blocked ? ` <span class="wd-tag">${this._t('badge.not_importable', {}, 'n/a here')}</span>` : ''}</span>
|
||||
<span class="wd-sd-count">${countLbl}</span>
|
||||
</label>`;
|
||||
let items = '';
|
||||
if (!blocked && cid === 'profiles') {
|
||||
items = `<div class="wd-sd-cycles">${(info.items || []).map(i => `<label class="wd-sd-cyc">
|
||||
<input type="checkbox" data-maction="wiz-toggle-profile" data-name="${_esc(i.name)}" ${m.sel.profiles.has(i.name) ? 'checked' : ''}>
|
||||
<span class="wd-sd-cyc-meta">${_esc(i.name)} · ${(i.real_cycles || 0) + (i.reference_cycles || 0)} ${this._t('lbl.cycles_short', {}, 'cycles')}${conflictNames.has(i.name) ? ` <span class="wd-tag" style="color:var(--warning-color,#e6a700)">${this._t('badge.exists', {}, 'exists')}</span>` : ''}</span>
|
||||
</label>`).join('')}</div>`;
|
||||
} else if (!blocked && (cid === 'real_cycles' || cid === 'reference_cycles')) {
|
||||
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
|
||||
items = `<div class="wd-sd-cycles">${(info.groups || []).map(g => {
|
||||
const ids = g.cycles.map(cy => String(cy.id));
|
||||
const gsel = ids.filter(id => set.has(id)).length;
|
||||
const gall = gsel === ids.length && ids.length > 0;
|
||||
const gsome = gsel > 0 && !gall;
|
||||
const key = `${cid}:${g.profile}`;
|
||||
const expanded = m.expanded && m.expanded.has(key);
|
||||
const rows2 = expanded ? g.cycles.map(cy => `<label class="wd-sd-cyc" style="margin-left:18px">
|
||||
<input type="checkbox" data-maction="wiz-toggle-cyc" data-cat="${cid}" data-cid="${_esc(String(cy.id))}" ${set.has(String(cy.id)) ? 'checked' : ''}>
|
||||
<span class="wd-sd-cyc-meta">${_esc(cy.date ? _fmtDate(cy.date) : String(cy.id))}${cy.duration != null ? ' · ' + _fmtDuration(cy.duration) : ''}</span>
|
||||
</label>`).join('') : '';
|
||||
return `<div>
|
||||
<label class="wd-sd-cyc">
|
||||
<input type="checkbox" data-maction="wiz-toggle-cycgroup" data-cat="${cid}" data-prof="${_esc(g.profile)}" ${gall ? 'checked' : ''} ${gsome ? 'data-indeterminate="1"' : ''}>
|
||||
<span class="wd-sd-cyc-meta"><strong>${_esc(g.profile || this._t('lbl.unlabelled', {}, 'Unlabelled'))}</strong> (${gsel}/${g.count})</span>
|
||||
<button type="button" class="wd-linkbtn" data-maction="wiz-expand" data-key="${_esc(key)}" style="margin-left:auto;background:none;border:none;color:var(--primary-color);cursor:pointer">${expanded ? '▾' : '▸'}</button>
|
||||
</label>
|
||||
${rows2}
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
return `<div class="wd-sd-group">${header}${items}</div>`;
|
||||
}).join('');
|
||||
return `<div class="wd-sd-tree">${master}${rows}</div>`;
|
||||
}
|
||||
|
||||
_htmlExportSelectModal(m) {
|
||||
const busy = this._busy.has('export-select');
|
||||
let body;
|
||||
if (m.loading || !m.inventory) {
|
||||
body = `<div class="wd-empty" style="padding:24px"><div class="wd-icon">⏳</div>${this._t('msg.loading', {}, 'Loading…')}</div>`;
|
||||
} else {
|
||||
body = this._htmlSelectionTree(m, { categories: m.inventory }, { importableOnly: false });
|
||||
}
|
||||
const anything = m.sel && (m.sel.profiles.size || m.sel.realIds.size || m.sel.refIds.size || m.sel.cats.size);
|
||||
return `<h2 id="wd-modal-title">${this._t('modal.export_select', {}, 'Export - choose data')}</h2>
|
||||
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.export_select_intro', {}, 'Tick exactly what to include. Selecting profiles without their cycles still exports a matchable program (its learned shape travels along).')}</p>
|
||||
${body}
|
||||
<div class="wd-modal-actions">
|
||||
<button class="wd-btn wd-btn-secondary" data-maction="cancel" ${busy ? 'disabled' : ''}>${this._t('btn.cancel', {}, 'Cancel')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-maction="export-generate" ${busy || !anything ? 'disabled' : ''}>${busy ? '<span class="wd-spin"></span> ' : ''}${this._t('btn.download_export', {}, 'Download export')}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_htmlImportWizardModal(m) {
|
||||
const busy = this._busy.has('import-wizard');
|
||||
const title = `<h2 id="wd-modal-title">${this._t('modal.import_wizard', {}, 'Import - choose data')}</h2>`;
|
||||
if (m.step === 'input' || m.step === 'analyze') {
|
||||
const analyzing = m.step === 'analyze';
|
||||
return `${title}
|
||||
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.import_analyze_hint', {}, 'Load an exported file (or paste its JSON). WashData analyzes it and shows exactly what can be imported before anything changes.')}</p>
|
||||
<div class="wd-field"><label>${this._t('lbl.load_from_file', {}, 'Load from file')}</label><input type="file" id="wd-import-file" accept=".json,application/json" ${analyzing ? 'disabled' : ''}></div>
|
||||
<div class="wd-field"><label>${this._t('lbl.json_data', {}, 'JSON Data')}</label><textarea id="wd-import-json" style="min-height:120px;font-family:monospace;font-size:.78em" ${analyzing ? 'disabled' : ''}>${_esc(m.jsonText || '')}</textarea></div>
|
||||
${m.error ? `<p class="wd-info" style="color:var(--error-color)">${_esc(m.error)}</p>` : ''}
|
||||
<div class="wd-modal-actions">
|
||||
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t('btn.cancel', {}, 'Cancel')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-maction="import-analyze" ${analyzing ? 'disabled' : ''}>${analyzing ? '<span class="wd-spin"></span> ' + this._t('msg.analyzing', {}, 'Analyzing…') : this._t('btn.analyze_import', {}, 'Analyze file')}</button>
|
||||
</div>`;
|
||||
}
|
||||
// step === 'select'
|
||||
const man = m.manifest || {};
|
||||
const mismatch = man.device_type_match === false;
|
||||
// Escape: source_device_type comes verbatim from the imported/shared file's
|
||||
// device_fingerprint and is unvalidated; _t() does raw {var} substitution, so an
|
||||
// un-escaped value would inject markup into innerHTML (XSS via a crafted import).
|
||||
const srcDt = _esc(man.source_device_type || '?');
|
||||
const localDt = _esc(man.local_device_type || '?');
|
||||
const warnBanner = mismatch ? `<div class="wd-banner wd-banner-warn" style="margin-bottom:12px;padding:8px 12px;border-radius:8px;background:var(--warning-color,#e6a700);color:#111">
|
||||
${this._t('msg.device_type_mismatch_warn', { src: srcDt, local: localDt }, 'This export is from a different appliance type (' + srcDt + ' vs ' + localDt + '). Programs and cycles can still be imported as reference data, but device-specific settings and real-history import are disabled.')}
|
||||
</div>` : '';
|
||||
const tree = this._htmlSelectionTree(m, man, { importableOnly: true, conflicts: true });
|
||||
// Merge / replace mode toggle.
|
||||
const modeBar = `<div class="wd-field"><label>${this._t('lbl.import_mode', {}, 'How to combine')}</label>
|
||||
<div class="wd-mode-bar" style="display:flex;gap:8px">
|
||||
<button type="button" class="wd-btn ${m.mode === 'merge' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="imp-mode-merge">${this._t('lbl.mode_merge', {}, 'Merge (keep mine)')}</button>
|
||||
<button type="button" class="wd-btn ${m.mode === 'replace' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="imp-mode-replace">${this._t('lbl.mode_replace', {}, 'Replace selected')}</button>
|
||||
</div>
|
||||
<div class="wd-field-hint">${m.mode === 'replace' ? this._t('msg.replace_warn', {}, 'Each ticked category is wiped and replaced from the file. Unticked categories are left untouched.') : this._t('msg.merge_hint', {}, 'Imported items are added; nothing local is lost. Name clashes are resolved below.')}</div>
|
||||
</div>`;
|
||||
// Cycle destination toggle (only relevant when cycles are being imported).
|
||||
const realAllowed = man.real_history_allowed !== false;
|
||||
const destBar = `<div class="wd-field"><label>${this._t('lbl.cycle_destination', {}, 'Where should imported cycles go?')}</label>
|
||||
<div class="wd-mode-bar" style="display:flex;gap:8px">
|
||||
<button type="button" class="wd-btn ${m.cycleDest === 'reference' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="imp-dest-reference">${this._t('lbl.dest_reference', {}, 'Reference (shape only)')}</button>
|
||||
<button type="button" class="wd-btn ${m.cycleDest === 'real_history' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="imp-dest-real" ${realAllowed ? '' : 'disabled'}>${this._t('lbl.dest_real_history', {}, 'Real history (counts in stats)')}</button>
|
||||
</div>
|
||||
<div class="wd-field-hint">${m.cycleDest === 'real_history' ? this._t('msg.dest_real_history_hint', {}, 'Imported cycles count as this device\'s own history and feed energy/usage stats. Use for moving one appliance to a new install.') : this._t('msg.dest_reference_hint', {}, 'Imported cycles only improve program matching and never affect usage/energy statistics.')}</div>
|
||||
</div>`;
|
||||
// Per-conflict resolution (merge mode, selected conflicting profiles).
|
||||
let conflictBar = '';
|
||||
const profItems = (man.categories && man.categories.profiles && man.categories.profiles.items) || [];
|
||||
const selConflicts = profItems.filter(i => i.conflict && m.sel.profiles.has(i.name));
|
||||
if (m.mode === 'merge' && selConflicts.length) {
|
||||
conflictBar = `<div class="wd-field"><label>${this._t('lbl.conflict_resolution', {}, 'Name clashes')}</label>
|
||||
${selConflicts.map(i => `<div style="display:flex;align-items:center;gap:8px;margin:4px 0">
|
||||
<span style="flex:1">${_esc(i.name)}</span>
|
||||
<select data-maction="imp-conflict" data-prof="${_esc(i.name)}">
|
||||
<option value="import_as_copy" ${(m.conflicts[i.name] || 'import_as_copy') === 'import_as_copy' ? 'selected' : ''}>${this._t('lbl.conflict_import_copy', {}, 'Import as copy')}</option>
|
||||
<option value="keep_mine" ${m.conflicts[i.name] === 'keep_mine' ? 'selected' : ''}>${this._t('lbl.conflict_keep_mine', {}, 'Keep mine')}</option>
|
||||
<option value="overwrite" ${m.conflicts[i.name] === 'overwrite' ? 'selected' : ''}>${this._t('lbl.conflict_overwrite', {}, 'Overwrite')}</option>
|
||||
</select>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
const anything = m.sel && (m.sel.profiles.size || m.sel.realIds.size || m.sel.refIds.size || m.sel.cats.size);
|
||||
return `${title}
|
||||
${warnBanner}
|
||||
${tree}
|
||||
${modeBar}
|
||||
${destBar}
|
||||
${conflictBar}
|
||||
<div class="wd-modal-actions">
|
||||
<button class="wd-btn wd-btn-secondary" data-maction="import-back" ${busy ? 'disabled' : ''}>${this._t('btn.back', {}, 'Back')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-maction="import-apply-ok" ${busy || !anything ? 'disabled' : ''}>${busy ? '<span class="wd-spin"></span> ' : ''}${this._t('btn.import_selected', {}, 'Import selected')}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Interactive cycle inspector: view / trim / split.
|
||||
_htmlCycleModal(m) {
|
||||
if (!m.loaded) {
|
||||
@@ -7659,6 +7994,23 @@ class HaWashdataPanel extends HTMLElement {
|
||||
<button class="wd-btn wd-btn-sm ${m.mode === 'review' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="cyc-review" title="${needsReview ? this._t('hdr.automation_needs_review', {}, 'This cycle needs review') : this._t('hdr.automation_review_this_cycle', {}, 'Review this cycle')}">${this._t('btn.review', {}, 'Review')}${reviewDot}</button>
|
||||
</div>` : (isRef ? `<div class="wd-info" style="margin:0 0 8px"><span style="color:var(--info-color,#2196f3)">📥</span> ${this._t('msg.imported_readonly', {}, 'Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.')}</div>` : '');
|
||||
|
||||
// Pending-detection-feedback banner (Confirm / Correct… / Ignore). Built once
|
||||
// and shown in BOTH Inspect and Review modes, so a cycle in the "needs review"
|
||||
// queue exposes the resolve controls without the user having to discover Review
|
||||
// mode (#331). Editors only; imported reference cycles never carry feedback.
|
||||
const pendingFb = this._canEdit() ? (this._feedbacks || []).find(f => f.cycle_id === m.cycleId) : null;
|
||||
const fbProf = pendingFb ? (pendingFb.detected_profile || pendingFb.profile_name || this._t('lbl.unknown', {}, 'Unknown')) : '';
|
||||
const fbBanner = pendingFb ? `
|
||||
<div class="wd-card" style="background:var(--secondary-background-color);border-left:3px solid var(--warning-color,#ff9800);margin:0 0 12px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:4px">⚠ ${this._t('msg.pending_feedback', {}, 'Pending detection feedback')}</div>
|
||||
<p class="wd-info" style="margin:0 0 8px">${this._t('msg.unsure_detected_prefix', {}, 'WashData is unsure it detected')} <strong>${_esc(fbProf)}</strong>${pendingFb.confidence != null ? ` (${this._t('lbl.confidence', {}, 'confidence').toLowerCase()} ${(pendingFb.confidence * 100).toFixed(0)}%)` : ''}. ${this._t('msg.feedback_prompt', {}, 'Confirm it was right, correct the program, or ignore.')} ${this._t('msg.feedback_relabel_hint', {}, 'Re-labelling this cycle resolves it too.')}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="fb-confirm" data-cid="${_esc(m.cycleId)}">${this._t('btn.confirm', {}, 'Confirm')}</button>
|
||||
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-correct" data-cid="${_esc(m.cycleId)}" data-prof="${_esc(fbProf)}">${this._t('btn.correct', {}, 'Correct…')}</button>
|
||||
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-ignore" data-cid="${_esc(m.cycleId)}">${this._t('btn.ignore', {}, 'Ignore')}</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
let controls = '';
|
||||
if (m.mode === 'view') {
|
||||
// Share to community store: only for recorded/golden reference cycles, and
|
||||
@@ -7674,7 +8026,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
: isRef ? `<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t('btn.delete', {}, 'Delete')}</button>`
|
||||
: `<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t('btn.delete', {}, 'Delete')}</button>
|
||||
<button class="wd-btn wd-btn-primary" data-maction="cyc-label">${this._t('btn.label', {}, 'Label')}</button>`;
|
||||
controls = `<div class="wd-modal-actions">
|
||||
controls = `${fbBanner}<div class="wd-modal-actions">
|
||||
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t('btn.close', {}, 'Close')}</button>
|
||||
${shareBtn}
|
||||
${editBtns}</div>`;
|
||||
@@ -7737,21 +8089,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
];
|
||||
const tagChecks = TAGS.map(([v, l]) => `<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-rev-tag" value="${v}" ${(rv.tags || []).includes(v) ? 'checked' : ''}> ${l}</label>`).join('');
|
||||
const reviewedBadge = rv.reviewed_at ? `<span style="font-size:.75em;color:var(--secondary-text-color)">${this._t('lbl.reviewed_on', {date: new Date(rv.reviewed_at).toLocaleDateString()}, `reviewed ${new Date(rv.reviewed_at).toLocaleDateString()}`)}</span>` : '';
|
||||
// If this cycle has a pending detection feedback (the learning loop is
|
||||
// unsure of the program it matched), surface Confirm/Correct/Ignore right
|
||||
// here. This folds the old Feedbacks subtab into the unified review flow.
|
||||
const pendingFb = (this._feedbacks || []).find(f => f.cycle_id === m.cycleId);
|
||||
const fbProf = pendingFb ? (pendingFb.detected_profile || pendingFb.profile_name || this._t('lbl.unknown', {}, 'Unknown')) : '';
|
||||
const fbBanner = pendingFb ? `
|
||||
<div class="wd-card" style="background:var(--secondary-background-color);border-left:3px solid var(--warning-color,#ff9800);margin:0 0 12px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:4px">⚠ ${this._t('msg.pending_feedback', {}, 'Pending detection feedback')}</div>
|
||||
<p class="wd-info" style="margin:0 0 8px">${this._t('msg.unsure_detected_prefix', {}, 'WashData is unsure it detected')} <strong>${_esc(fbProf)}</strong>${pendingFb.confidence != null ? ` (${this._t('lbl.confidence', {}, 'confidence').toLowerCase()} ${(pendingFb.confidence * 100).toFixed(0)}%)` : ''}. ${this._t('msg.feedback_prompt', {}, 'Confirm it was right, correct the program, or ignore.')}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="fb-confirm" data-cid="${_esc(m.cycleId)}">${this._t('btn.confirm', {}, 'Confirm')}</button>
|
||||
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-correct" data-cid="${_esc(m.cycleId)}" data-prof="${_esc(fbProf)}">${this._t('btn.correct', {}, 'Correct…')}</button>
|
||||
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-ignore" data-cid="${_esc(m.cycleId)}">${this._t('btn.ignore', {}, 'Ignore')}</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
// Pending-feedback banner (Confirm/Correct/Ignore) is built in the shared
|
||||
// scope above and rendered here as well as in Inspect mode (#331).
|
||||
const tProfile = _tip(this._t('msg.review_profile_tip', {}, 'The program this cycle is labelled as. If the auto-detected program was wrong, correct it here - labelling teaches matching for future cycles.'));
|
||||
const tQuality = _tip(this._t('msg.review_quality_tip', {}, 'How clean this cycle is. Good = a textbook example of this program; Bad = detected but noisy or atypical; Unusable = mis-detected (merged, truncated or spurious). Drives the health score and which cycles are allowed to train the model.'));
|
||||
const tRecorded = _tip(this._t('msg.review_recorded_tip', {}, 'Mark this as a hand-picked reference cycle for its program - the same role as a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never dropped by cleanup. (This is the "golden"/recorded flag; both are the same thing.)'));
|
||||
@@ -8338,7 +8677,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
const wasThr = this._pgDragging === 'start_thr' || this._pgDragging === 'stop_thr';
|
||||
this._pgDragging = null; this._pgPanStart = null;
|
||||
pgCanvas.classList.remove('wd-pg-panning');
|
||||
if (wasThr) this._pgRerunDetail(); // re-run the sim under the new threshold
|
||||
if (wasThr) this._pgDrawCanvas();
|
||||
});
|
||||
pgCanvas.addEventListener('pointerleave', () => {
|
||||
if (this._pgDragging) return;
|
||||
@@ -8363,18 +8702,65 @@ class HaWashdataPanel extends HTMLElement {
|
||||
}
|
||||
|
||||
// F3: Param input fields → sync to threshold state + redraw
|
||||
sr.querySelectorAll('[data-pgkey]').forEach(inp => inp.addEventListener('input', () => {
|
||||
const key = inp.dataset.pgkey;
|
||||
const val = parseFloat(inp.value);
|
||||
if (isNaN(val)) return;
|
||||
if (key === 'start_threshold_w') this._pgThreshStart = val;
|
||||
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
|
||||
else this._pgParamOverrides[key] = val;
|
||||
this._pgDrawCanvas();
|
||||
// Re-run the faithful sim under the new setting so the state band, model
|
||||
// estimates, events and alerts reflect it (debounced).
|
||||
this._pgRerunDetail();
|
||||
}));
|
||||
sr.querySelectorAll('[data-pgkey]').forEach(inp => {
|
||||
inp.addEventListener('input', () => {
|
||||
const key = inp.dataset.pgkey;
|
||||
if (inp.dataset.pgtype === 'bool') {
|
||||
const val = inp.checked;
|
||||
if (key === 'start_threshold_w') this._pgThreshStart = val;
|
||||
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
|
||||
else this._pgParamOverrides[key] = val;
|
||||
this._render();
|
||||
const again = sr.querySelector(`[data-pgkey="${key}"]`);
|
||||
if (again) again.focus();
|
||||
requestAnimationFrame(() => this._pgDrawCanvas());
|
||||
return;
|
||||
}
|
||||
const raw = inp.value.trim();
|
||||
if (!raw) {
|
||||
if (key === 'start_threshold_w') this._pgThreshStart = null;
|
||||
else if (key === 'stop_threshold_w') this._pgThreshStop = null;
|
||||
else delete this._pgParamOverrides[key];
|
||||
} else {
|
||||
const val = parseFloat(raw);
|
||||
if (!isNaN(val)) {
|
||||
if (key === 'start_threshold_w') this._pgThreshStart = val;
|
||||
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
|
||||
else this._pgParamOverrides[key] = val;
|
||||
}
|
||||
// Partial value ("1.", ".", "-") — leave state unchanged until next keystroke.
|
||||
}
|
||||
// Save the raw text and caret before _render() destroys this input. With
|
||||
// type="text" (not number), selectionStart is a real index and setSelectionRange
|
||||
// works, so cursor restoration is reliable unlike the old type=number approach.
|
||||
const caret = inp.selectionStart;
|
||||
const rawVal = inp.value;
|
||||
this._render();
|
||||
const again = sr.querySelector(`[data-pgkey="${key}"]`);
|
||||
if (again) {
|
||||
again.value = rawVal; // restore raw text incl. trailing "." the browser strips
|
||||
again.focus();
|
||||
try { again.setSelectionRange(caret, caret); } catch (_) {}
|
||||
}
|
||||
requestAnimationFrame(() => this._pgDrawCanvas());
|
||||
});
|
||||
});
|
||||
|
||||
// F3: Idle termination test toggle
|
||||
const pgStressToggle = sr.getElementById('wd-pg-stress-toggle');
|
||||
if (pgStressToggle) pgStressToggle.addEventListener('change', () => {
|
||||
this._pgStressTail = pgStressToggle.checked;
|
||||
this._pgStressIdleW = null;
|
||||
this._render();
|
||||
});
|
||||
|
||||
// F3: Idle level override field (only present when toggle is on)
|
||||
const pgStressIdleW = sr.getElementById('wd-pg-stress-idle-w');
|
||||
if (pgStressIdleW) pgStressIdleW.addEventListener('input', () => {
|
||||
const v = parseFloat(pgStressIdleW.value);
|
||||
// Number.isFinite rejects Infinity (parseFloat("1e999")) which isNaN misses.
|
||||
this._pgStressIdleW = (Number.isFinite(v) && v >= 0) ? v : null;
|
||||
});
|
||||
|
||||
// F3: Cycle selector
|
||||
const pgCycSel = sr.getElementById('wd-pg-cyc-sel');
|
||||
@@ -8840,6 +9226,10 @@ class HaWashdataPanel extends HTMLElement {
|
||||
|
||||
sr.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', e => this._onAction(e.currentTarget)));
|
||||
sr.querySelectorAll('[data-maction]').forEach(btn => btn.addEventListener('click', e => this._onModalAction(e.currentTarget.dataset.maction, e.currentTarget)));
|
||||
// A <select data-maction> commits via `change` (not `click`, esp. on keyboard
|
||||
// selection), so bind change too — otherwise e.g. the import conflict-resolution
|
||||
// dropdown never records the user's pick and silently falls back to the default.
|
||||
sr.querySelectorAll('select[data-maction]').forEach(sel => sel.addEventListener('change', e => this._onModalAction(e.currentTarget.dataset.maction, e.currentTarget)));
|
||||
// indeterminate is a JS property (no HTML attribute); apply it after render for
|
||||
// the share-device tree's partially-selected profile checkboxes.
|
||||
sr.querySelectorAll('input[data-indeterminate]').forEach(cb => { cb.indeterminate = true; });
|
||||
@@ -9679,6 +10069,21 @@ class HaWashdataPanel extends HTMLElement {
|
||||
document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url);
|
||||
this._showToast(this._t('toast.export_downloaded', {}, 'Export downloaded'));
|
||||
}).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'));
|
||||
} else if (a === 'export-select-open') {
|
||||
// Open the export wizard: fetch this device's inventory, default everything on.
|
||||
this._modal = { type: 'export-select', inventory: null, loading: true, sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, expanded: new Set() };
|
||||
this._render();
|
||||
(async () => {
|
||||
let inv = null;
|
||||
try { const r = await this._ws({ type: `${_DOMAIN}/get_export_inventory`, entry_id: eid }); inv = (r && r.manifest) || null; }
|
||||
catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); }
|
||||
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'export-select') return;
|
||||
if (!inv) { this._modal = null; this._render(); return; }
|
||||
this._modal.inventory = inv;
|
||||
this._modal.sel = this._wizInitSel({ categories: inv }, false);
|
||||
this._modal.loading = false;
|
||||
this._render();
|
||||
})();
|
||||
} else if (a === 'cyc-select-toggle') {
|
||||
this._selectMode = !this._selectMode;
|
||||
if (!this._selectMode) this._cycleSel.clear();
|
||||
@@ -9752,6 +10157,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
this._pgCancelRun();
|
||||
} else if (a === 'pg-reset-params') {
|
||||
this._pgThreshStart = null; this._pgThreshStop = null; this._pgParamOverrides = {};
|
||||
this._pgStressTail = false; this._pgStressIdleW = null;
|
||||
this._render(); requestAnimationFrame(() => this._pgDrawCanvas());
|
||||
} else if (a === 'pg-apply-settings') {
|
||||
this._pgApplyToSettings();
|
||||
@@ -9802,7 +10208,7 @@ class HaWashdataPanel extends HTMLElement {
|
||||
const val = JSON.parse(btn.dataset.val);
|
||||
if (!key) return;
|
||||
const eid = dev.entry_id;
|
||||
this._ws({ type: `${_DOMAIN}/ws_set_options`, entry_id: eid, options: { [key]: val } })
|
||||
this._ws({ type: `${_DOMAIN}/set_options`, entry_id: eid, options: { [key]: val } })
|
||||
.then(() => this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }))
|
||||
.then(r => { this._opts = r.options || {}; return this._fetchSettingsChangelog(eid); })
|
||||
.then(() => {
|
||||
@@ -9845,6 +10251,13 @@ class HaWashdataPanel extends HTMLElement {
|
||||
this._showToast(this._t('toast.logs_exported', {}, 'Logs exported'));
|
||||
}).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'));
|
||||
} else if (a === 'import-config-open') {
|
||||
// Open the import wizard at the paste/upload step.
|
||||
this._modal = { type: 'import-wizard', step: 'input', jsonText: '', manifest: null, error: null,
|
||||
sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() },
|
||||
expanded: new Set(), mode: 'merge', cycleDest: 'reference', conflicts: {} };
|
||||
this._render();
|
||||
} else if (a === 'import-config-raw') {
|
||||
// Advanced fallback: the legacy raw-JSON whole-store replace.
|
||||
this._modal = { type: 'import-config' }; this._render();
|
||||
|
||||
} else if (a === 'save-prefs') {
|
||||
@@ -10105,6 +10518,146 @@ class HaWashdataPanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selective export / import wizard ----
|
||||
if (m && (m.type === 'export-select' || m.type === 'import-wizard')) {
|
||||
// The manifest that backs the tree: inventory for export, analyze result for import.
|
||||
const man = m.type === 'export-select' ? { categories: m.inventory || {} } : (m.manifest || { categories: {} });
|
||||
if (action === 'wiz-toggle-all') {
|
||||
const cats = man.categories || {};
|
||||
const importableOnly = m.type === 'import-wizard';
|
||||
// If everything is already selected, clear; otherwise select all selectable.
|
||||
let allSel = true;
|
||||
this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present).forEach(cid => {
|
||||
if (importableOnly && cats[cid].importable === false) return;
|
||||
if (this._wizCatState(m, cid, man).state !== 'all') allSel = false;
|
||||
});
|
||||
m.sel = allSel
|
||||
? { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }
|
||||
: this._wizInitSel(man, importableOnly);
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'wiz-toggle-cat') {
|
||||
const cid = btn.dataset.cat;
|
||||
const st = this._wizCatState(m, cid, man);
|
||||
const turnOn = st.state !== 'all';
|
||||
if (cid === 'profiles') {
|
||||
const items = (man.categories.profiles && man.categories.profiles.items) || [];
|
||||
m.sel.profiles = new Set(turnOn ? items.map(i => i.name) : []);
|
||||
} else if (cid === 'real_cycles' || cid === 'reference_cycles') {
|
||||
const set = new Set();
|
||||
if (turnOn) ((man.categories[cid] && man.categories[cid].groups) || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) set.add(String(cy.id)); }));
|
||||
if (cid === 'real_cycles') m.sel.realIds = set; else m.sel.refIds = set;
|
||||
} else if (turnOn) { m.sel.cats.add(cid); } else { m.sel.cats.delete(cid); }
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'wiz-toggle-profile') {
|
||||
const name = btn.dataset.name;
|
||||
if (m.sel.profiles.has(name)) m.sel.profiles.delete(name); else m.sel.profiles.add(name);
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'wiz-toggle-cycgroup') {
|
||||
const cid = btn.dataset.cat; const prof = btn.dataset.prof;
|
||||
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
|
||||
const ids = this._wizGroupIds(man, cid, prof);
|
||||
const all = ids.length > 0 && ids.every(id => set.has(id));
|
||||
ids.forEach(id => { if (all) set.delete(id); else set.add(id); });
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'wiz-toggle-cyc') {
|
||||
const cid = btn.dataset.cat; const id = btn.dataset.cid;
|
||||
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
|
||||
if (set.has(id)) set.delete(id); else set.add(id);
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'wiz-expand') {
|
||||
const key = btn.dataset.key;
|
||||
if (!m.expanded) m.expanded = new Set();
|
||||
if (m.expanded.has(key)) m.expanded.delete(key); else m.expanded.add(key);
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Export wizard: generate + download the filtered JSON.
|
||||
if (m && m.type === 'export-select' && action === 'export-generate' && eid) {
|
||||
const selection = this._wizSelectionPayload(m);
|
||||
await this._busyRun('export-select', async () => {
|
||||
try {
|
||||
const r = await this._ws({ type: `${_DOMAIN}/export_config_selective`, entry_id: eid, selection });
|
||||
const blob = new Blob([r.json_data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a2 = document.createElement('a');
|
||||
a2.href = url; a2.download = `washdata_export_${eid.slice(0, 8)}.json`;
|
||||
document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url);
|
||||
this._modal = null;
|
||||
this._showToast(this._t('toast.export_selective_done', {}, 'Export downloaded'));
|
||||
} catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Import wizard: step machine + toggles + apply.
|
||||
if (m && m.type === 'import-wizard') {
|
||||
if (action === 'import-back') {
|
||||
m.step = 'input'; m.error = null; this._render();
|
||||
return;
|
||||
}
|
||||
if (action === 'imp-mode-merge') { m.mode = 'merge'; this._render(); return; }
|
||||
if (action === 'imp-mode-replace') { m.mode = 'replace'; this._render(); return; }
|
||||
if (action === 'imp-dest-reference') { m.cycleDest = 'reference'; this._render(); return; }
|
||||
if (action === 'imp-dest-real') { if ((m.manifest || {}).real_history_allowed !== false) { m.cycleDest = 'real_history'; this._render(); } return; }
|
||||
if (action === 'imp-conflict') { m.conflicts[btn.dataset.prof] = btn.value; return; }
|
||||
if (action === 'import-analyze' && eid) {
|
||||
const ta = sr.getElementById('wd-import-json');
|
||||
const jsonText = ta ? ta.value : (m.jsonText || '');
|
||||
m.jsonText = jsonText;
|
||||
if (!jsonText.trim()) { this._showToast(this._t('toast.json_required', {}, 'JSON data is required'), 'error'); return; }
|
||||
m.step = 'analyze'; m.error = null; this._render();
|
||||
try {
|
||||
const r = await this._ws({ type: `${_DOMAIN}/analyze_import`, entry_id: eid, json_data: jsonText });
|
||||
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return;
|
||||
const manifest = (r && r.manifest) || {};
|
||||
if (manifest.error) { m.step = 'input'; m.error = manifest.error; this._render(); return; }
|
||||
m.manifest = manifest;
|
||||
m.sel = this._wizInitSel(manifest, true);
|
||||
// Default every conflicting profile to the safest resolution.
|
||||
m.conflicts = {};
|
||||
((manifest.categories && manifest.categories.profiles && manifest.categories.profiles.items) || [])
|
||||
.forEach(i => { if (i.conflict) m.conflicts[i.name] = 'import_as_copy'; });
|
||||
m.step = 'select';
|
||||
this._render();
|
||||
} catch (e) {
|
||||
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return;
|
||||
m.step = 'input'; m.error = (e && e.message) || String(e); this._render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'import-apply-ok' && eid) {
|
||||
const selection = this._wizSelectionPayload(m);
|
||||
await this._busyRun('import-wizard', async () => {
|
||||
try {
|
||||
const r = await this._ws({ type: `${_DOMAIN}/import_config_selective`, entry_id: eid,
|
||||
json_data: m.jsonText, selection, mode: m.mode,
|
||||
conflict_resolutions: m.conflicts, cycle_destination: m.cycleDest, apply_settings: true });
|
||||
const s = (r && r.summary) || {};
|
||||
this._modal = null;
|
||||
this._showToast(this._t('toast.import_selective_done', {
|
||||
profiles: s.profiles_imported || 0,
|
||||
cycles: (s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0),
|
||||
}, `Imported ${s.profiles_imported || 0} profile(s) and ${(s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0)} cycle(s)`));
|
||||
await this._fetchCycles(eid);
|
||||
await this._fetchProfiles(eid);
|
||||
} catch (e) { this._showToast(this._t('toast.import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- Cycle inspector ----
|
||||
if (m && m.type === 'cycle-detail') {
|
||||
@@ -10128,6 +10681,9 @@ class HaWashdataPanel extends HTMLElement {
|
||||
}
|
||||
this._showToast(this._t('toast.review_saved', {}, 'Review saved'));
|
||||
await this._fetchCycles(eid);
|
||||
// A label change in review now resolves the pending feedback backend-side
|
||||
// (#331), so refresh the queue rather than leaving a stale entry.
|
||||
if (newLabel !== curLabel) await this._fetchFeedbacks(eid);
|
||||
await this._loadMlIndex(eid);
|
||||
if (this._modal && this._modal.cycleId === cid) this._modal.ml = (this._mlById || {})[cid] || this._modal.ml;
|
||||
} catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); }
|
||||
@@ -10274,7 +10830,9 @@ class HaWashdataPanel extends HTMLElement {
|
||||
? (sr.getElementById('wd-new-profile-name')?.value?.trim() || null)
|
||||
: null;
|
||||
this._modal = null;
|
||||
try { await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: m.cycleId, profile_name: profileName || null, new_profile_name: newName }); this._showToast(this._t('toast.cycle_labelled', {}, 'Cycle labelled')); await this._fetchCycles(eid); await this._fetchProfiles(eid); }
|
||||
// Re-fetch feedbacks too: labelling a review cycle now resolves its pending
|
||||
// feedback backend-side (#331), so the "needs review" queue must refresh.
|
||||
try { await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: m.cycleId, profile_name: profileName || null, new_profile_name: newName }); this._showToast(this._t('toast.cycle_labelled', {}, 'Cycle labelled')); await this._fetchCycles(eid); await this._fetchProfiles(eid); await this._fetchFeedbacks(eid); }
|
||||
catch (e) { this._showToast(this._t('toast.label_failed', {error: e.message || e}, 'Label failed: ' + (e.message || e)), 'error'); }
|
||||
this._render();
|
||||
} else if (action === 'create-profile-ok' && eid) {
|
||||
@@ -10364,7 +10922,8 @@ class HaWashdataPanel extends HTMLElement {
|
||||
for (const cid of ids) await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: cid, profile_name: profileName || null });
|
||||
this._showToast(this._t('toast.relabel_done', { count: ids.length }, `Relabelled ${ids.length} cycle(s)`));
|
||||
this._cycleSel.clear(); this._selectMode = false;
|
||||
await this._fetchCycles(eid); await this._fetchProfiles(eid);
|
||||
// Bulk relabel resolves any pending feedback on those cycles (#331).
|
||||
await this._fetchCycles(eid); await this._fetchProfiles(eid); await this._fetchFeedbacks(eid);
|
||||
} catch (e) { this._showToast(this._t('toast.relabel_failed', { error: e.message || e }, 'Relabel failed: ' + (e.message || e)), 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
+47
@@ -12,6 +12,10 @@ export interface AddMaintenanceEventResponse {
|
||||
event: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AnalyzeImportResponse {
|
||||
manifest: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AnalyzeSplitResponse {
|
||||
segments: number[][];
|
||||
split_offsets: number[];
|
||||
@@ -142,6 +146,10 @@ export interface GetDtwDebugResponse {
|
||||
warp_path: number[][];
|
||||
}
|
||||
|
||||
export interface GetExportInventoryResponse {
|
||||
manifest: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetFeedbacksResponse {
|
||||
feedbacks: Record<string, unknown>[];
|
||||
}
|
||||
@@ -275,6 +283,11 @@ export interface GetSuggestionsResponse {
|
||||
suggestions: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface ImportConfigSelectiveResponse {
|
||||
success: boolean;
|
||||
summary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListTasksResponse {
|
||||
tasks: TaskSnapshot[];
|
||||
}
|
||||
@@ -671,6 +684,30 @@ export interface ImportConfigRequest {
|
||||
json_data: string;
|
||||
}
|
||||
|
||||
export interface GetExportInventoryRequest {
|
||||
entry_id: string;
|
||||
}
|
||||
|
||||
export interface AnalyzeImportRequest {
|
||||
entry_id: string;
|
||||
json_data: string;
|
||||
}
|
||||
|
||||
export interface ExportConfigSelectiveRequest {
|
||||
entry_id: string;
|
||||
selection: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ImportConfigSelectiveRequest {
|
||||
entry_id: string;
|
||||
json_data: string;
|
||||
selection: Record<string, unknown>;
|
||||
mode?: "merge" | "replace";
|
||||
conflict_resolutions?: Record<string, unknown>;
|
||||
cycle_destination?: "reference" | "real_history";
|
||||
apply_settings?: boolean;
|
||||
}
|
||||
|
||||
export interface GetConstantsRequest {
|
||||
}
|
||||
|
||||
@@ -871,6 +908,8 @@ export interface StartPlaygroundCycleDetailRequest {
|
||||
entry_id: string;
|
||||
cycle_id: string;
|
||||
settings_override?: Record<string, unknown>;
|
||||
stress_tail?: boolean;
|
||||
stress_idle_w?: number | null;
|
||||
}
|
||||
|
||||
export interface StoreStatusRequest {
|
||||
@@ -1020,6 +1059,10 @@ export interface WashDataWsRequests {
|
||||
"ha_washdata/wipe_history": WipeHistoryRequest;
|
||||
"ha_washdata/export_config": ExportConfigRequest;
|
||||
"ha_washdata/import_config": ImportConfigRequest;
|
||||
"ha_washdata/get_export_inventory": GetExportInventoryRequest;
|
||||
"ha_washdata/analyze_import": AnalyzeImportRequest;
|
||||
"ha_washdata/export_config_selective": ExportConfigSelectiveRequest;
|
||||
"ha_washdata/import_config_selective": ImportConfigSelectiveRequest;
|
||||
"ha_washdata/get_constants": GetConstantsRequest;
|
||||
"ha_washdata/get_suggestions": GetSuggestionsRequest;
|
||||
"ha_washdata/apply_suggestions": ApplySuggestionsRequest;
|
||||
@@ -1121,6 +1164,10 @@ export interface WashDataWsResponses {
|
||||
"ha_washdata/wipe_history": SuccessResponse;
|
||||
"ha_washdata/export_config": ExportConfigResponse;
|
||||
"ha_washdata/import_config": SuccessResponse;
|
||||
"ha_washdata/get_export_inventory": GetExportInventoryResponse;
|
||||
"ha_washdata/analyze_import": AnalyzeImportResponse;
|
||||
"ha_washdata/export_config_selective": ExportConfigResponse;
|
||||
"ha_washdata/import_config_selective": ImportConfigSelectiveResponse;
|
||||
"ha_washdata/get_constants": GetConstantsResponse;
|
||||
"ha_washdata/get_suggestions": GetSuggestionsResponse;
|
||||
"ha_washdata/apply_suggestions": ApplySuggestionsResponse;
|
||||
|
||||
Reference in New Issue
Block a user