Use compact state buttons beside reconcile history selectors
This commit is contained in:
1 parent
e10bddbaff
commit
acb8019b57
2 files changed
+49
-27
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user