Use compact state buttons beside reconcile history selectors

This commit is contained in:
feedc0de committed 2026-11-02 11:49:51 +01:00
1 parent e10bddbaff
commit acb8019b57
2 files changed
+49 -27

No files matched your search

+3 -2
View File
@@ -214,8 +214,9 @@ History begins when the sensors are installed; no InfluxDB data is imported.
already-installed ApexCharts Card for graphs and Home Assistant's authenticated
history WebSocket API for estimates. The host's existing
`/dashboard-bcachefs/0` dashboard includes this card with its Reconcile and Copy GC switches integrated
above the history controls. The embedded native Home Assistant tiles preserve
live state, unavailable handling, and the entity details dialog.
in the same row as the history controls. Compact buttons show On/Off text and
a colored background for On. They use confirmed entity state, disable themselves
when unavailable, and report service errors without changing their displayed state.
- One selector applies 3h, 6h, 12h, 24h, 2d or 7d to both graphs and calculations.
- Another selector chooses `target_data` or `erasure_code_data` for the estimate.
+46 -25
View File
@@ -49,23 +49,29 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) {
this._field = 'target_data';
this._request = 0;
this._graphs = [];
this._switches = [];
this._pendingSwitches = new Set();
}
setConfig(config) {
this._config = {entity_prefix: 'sensor.bcachefs_reconcile_', ...config};
this._hours = RANGES.some(([hours]) => hours === config.hours) ? config.hours : this._hours;
this.shadowRoot.innerHTML = `<style>
:host{display:block}ha-card{padding:16px}.controls{display:flex;gap:16px;flex-wrap:wrap}
.switches{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:20px}h2{margin:0 0 16px;font-size:20px;font-weight:500}label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--secondary-text-color)}
select{font:inherit;color:var(--primary-text-color);background:var(--card-background-color);border:1px solid var(--divider-color);border-radius:8px;padding:10px;min-width:160px}
:host{display:block}ha-card{padding:16px}.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
h2{margin:0 0 16px;font-size:20px;font-weight:500}label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--secondary-text-color)}
select{height:40px;box-sizing:border-box;font:inherit;color:var(--primary-text-color);background:var(--card-background-color);border:1px solid var(--divider-color);border-radius:8px;padding:10px;min-width:160px}
.switch-button{height:40px;padding:0 14px;border:1px solid var(--divider-color);border-radius:8px;font:500 14px system-ui;cursor:pointer;color:var(--primary-text-color);background:var(--secondary-background-color);white-space:nowrap}
.switch-button[aria-pressed="true"]{background:color-mix(in srgb,var(--success-color,#2e7d32) 20%,var(--card-background-color));border-color:var(--success-color,#2e7d32);color:var(--primary-text-color)}
.switch-button:focus-visible{outline:2px solid var(--primary-color);outline-offset:2px}.switch-button:disabled{cursor:default;opacity:.55}
#control-error{color:var(--error-color);font-size:13px}#control-error:empty{display:none}
.summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:20px 0 12px}.metric{background:var(--secondary-background-color);padding:12px;border-radius:8px}
.metric span{display:block;font-size:12px;color:var(--secondary-text-color)}.metric strong{display:block;margin-top:8px;font-size:22px;font-weight:500;overflow-wrap:anywhere}
.note{font-size:13px;color:var(--secondary-text-color);line-height:1.5;margin:6px 0}.graphs{display:grid;gap:16px;margin-top:16px}
@media(max-width:450px){.switches{grid-template-columns:1fr}.summary{grid-template-columns:1fr}.metric strong{font-size:20px}}
@media(max-width:450px){.summary{grid-template-columns:1fr}.metric strong{font-size:20px}}
</style><ha-card><h2>Pending reconcile work</h2>
<div class="switches" aria-label="Filesystem controls"></div>
<div class="controls"><label>History and calculation window<select id="range"></select></label>
<label>Estimate for<select id="field"><option value="target_data">Target data</option><option value="erasure_code_data">Erasure-code data</option></select></label></div>
<label>Estimate for<select id="field"><option value="target_data">Target data</option><option value="erasure_code_data">Erasure-code data</option></select></label>
<button class="switch-button" type="button" data-entity="switch.bcachefs_reconcile_enabled" data-name="Reconcile" aria-pressed="false" disabled>Reconcile · Unavailable</button>
<button class="switch-button" type="button" data-entity="switch.bcachefs_copygc_enabled" data-name="Copy GC" aria-pressed="false" disabled>Copy GC · Unavailable</button></div>
<p id="control-error" role="alert"></p>
<div class="summary"><div class="metric"><span>Remaining data</span><strong id="remaining">—</strong></div>
<div class="metric"><span>Average net progress</span><strong id="rate">—</strong></div>
<div class="metric"><span>Estimated time remaining</span><strong id="eta">—</strong></div></div>
@@ -78,14 +84,18 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) {
this.shadowRoot.querySelector('#field').value = this._field;
select.addEventListener('change', () => {this._hours = Number(select.value); this._buildGraphs(); this._load();});
this.shadowRoot.querySelector('#field').addEventListener('change', event => {this._field = event.target.value; this._load();});
this._buildSwitches();
this.shadowRoot.querySelectorAll('.switch-button').forEach(button => {
button.addEventListener('click', () => this._toggleSwitch(button.dataset.entity));
});
this._updateSwitches();
this._buildGraphs();
if (this._hass) this._load();
}
set hass(hass) {
const first = !this._hass;
this._hass = hass;
[...this._graphs, ...this._switches].forEach(card => {card.hass = hass;});
this._graphs.forEach(card => {card.hass = hass;});
this._updateSwitches();
const entity = hass.states[this._entity()];
if (entity && !Number.isFinite(Number(entity.state))) {
++this._request;
@@ -98,24 +108,35 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) {
if (this._hass && this._config) this._load();
}
disconnectedCallback() { clearInterval(this._timer); ++this._request; }
getCardSize() {return 16;}
getCardSize() {return 14;}
getGridOptions() {return {columns: 'full', rows: 'auto'};}
_entity() {return `${this._config?.entity_prefix || 'sensor.bcachefs_reconcile_'}${this._field}`;}
async _buildSwitches() {
const generation = this._switchGeneration = (this._switchGeneration || 0) + 1;
const helpers = await window.loadCardHelpers();
if (generation !== this._switchGeneration) return;
this._switches = [
['switch.bcachefs_reconcile_enabled', 'Reconcile', 'mdi:sync'],
['switch.bcachefs_copygc_enabled', 'Copy GC', 'mdi:recycle'],
].map(([entity, name, icon]) => helpers.createCardElement({
type: 'tile', entity, name, icon,
tap_action: {action: 'more-info'},
icon_tap_action: {action: 'toggle'},
features: [{type: 'toggle'}], features_position: 'inline',
}));
this._switches.forEach(card => {if (this._hass) card.hass = this._hass;});
this.shadowRoot.querySelector('.switches').replaceChildren(...this._switches);
_updateSwitches() {
this.shadowRoot.querySelectorAll('.switch-button').forEach(button => {
const state = this._hass?.states[button.dataset.entity]?.state;
const available = state === 'on' || state === 'off';
const pending = this._pendingSwitches.has(button.dataset.entity);
button.setAttribute('aria-pressed', String(state === 'on'));
button.setAttribute('aria-busy', String(pending));
button.disabled = !available || pending;
button.textContent = `${button.dataset.name} · ${pending ? 'Updating…' : !available ? 'Unavailable' : state === 'on' ? 'On' : 'Off'}`;
button.title = available ? `Turn ${state === 'on' ? 'off' : 'on'} ${button.dataset.name}` : `${button.dataset.name} is unavailable`;
});
}
async _toggleSwitch(entity) {
const state = this._hass?.states[entity]?.state;
if (!['on', 'off'].includes(state) || this._pendingSwitches.has(entity)) return;
this._pendingSwitches.add(entity);
this.shadowRoot.querySelector('#control-error').textContent = '';
this._updateSwitches();
try {
await this._hass.callService('switch', state === 'on' ? 'turn_off' : 'turn_on', {entity_id: entity});
} catch (error) {
this.shadowRoot.querySelector('#control-error').textContent = `Could not change switch: ${error.message || error}`;
} finally {
this._pendingSwitches.delete(entity);
this._updateSwitches();
}
}
async _buildGraphs() {
const generation = this._graphGeneration = (this._graphGeneration || 0) + 1;