v0.11.4-devices
This commit is contained in:
@@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
7414e94d61d2dfd9734ee92a2a1423cae67e91e8733c213098b9bcb6a2885a22 ./lang/en.json
|
||||
1ff6dab25d8a29d971cd10de8d0df1b6173670ba1e6f65c0c669bf6b134bcf23 ./lang/pl.json
|
||||
d366f7a5bd58457f5033ddffca783be04c1b50dff38b8d4feb9675776feeabe8 ./lang/en.json
|
||||
bde25cbd02bb46068feb344d4cfc16298760dbfd6a855cad4d4345c7d5bf73bb ./lang/pl.json
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html
|
||||
007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js
|
||||
@@ -90,7 +90,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||
63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs
|
||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
||||
7ffb7ef7d3cc3a908e2b0a7b3799cd1dcc94808f3083ae1b1a0a6e26ec6b88fb ./src/api/devices.rs
|
||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
|
||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||
@@ -176,24 +176,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
4f273e189008df110cd3cad1d323e5f07e2651f805419b427811d89fdd04245f ./web/index.html
|
||||
9b94c56a402396a63f35630d91106a2f2655f03cbda555c330cd059067d488bd ./web/index.html
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
||||
dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js
|
||||
87064e7d504dc2afbc1aa3a1926fcec0cc80b31e2a6a6d931d7af7fc4f519b20 ./web/js/core.js
|
||||
11fcd1571b9d823af614a75380855c0bd2c287475b25ec6d85c73067994a8448 ./web/js/core.js
|
||||
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
||||
3f5b9a4d62c994edded0ce24fb9ea45141d935a1928d4f68fb67f8ca287bafe1 ./web/js/entities.js
|
||||
cdd11ab0fac33b7b1749b8db4e30486e9d0258b65fe54fd56c3a520b2b72b2d7 ./web/js/events.js
|
||||
1c9b42c1537963f703a1d8a53a15d260957df3494e19209442a9fedb6e6b81ed ./web/js/entities.js
|
||||
57218050f3e7c8481f9d6f11f395d225dae78e32ce09c9e42fb79c1e81417ded ./web/js/events.js
|
||||
d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js
|
||||
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||
3dcfc24f238ad1310155707268b60c5a415f2a8977ceba48c7e695b7135d5705 ./web/js/navigation.js
|
||||
cfa4e876d20b32cc26b8af615138deb99dd1360ef2be3ca0118c750ff44094c7 ./web/js/navigation.js
|
||||
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
|
||||
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
||||
bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js
|
||||
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||
11034c405898fe345b308cfb7da094c1dd995576c138241e19b29b063b51e6ba ./web/styles.css
|
||||
14dd66007e17ebb96ab1d3cc5936801ddbc07e0ea4c3662072f3ddd344c2b16f ./web/styles.css
|
||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
+29
-1
@@ -75,7 +75,35 @@
|
||||
"devices.readDone": "Device state updated",
|
||||
"devices.bound": "Device bound",
|
||||
"devices.added": "Device added",
|
||||
"devices.disabledZoneTechnicalOnly": "This zone is disabled. Manual control of its unit is available only from the Devices view.",
|
||||
"dashboard.manualControlHint": "Direct GREE control: power, temperature, mode, fan and supported unit functions.",
|
||||
"devices.technicalDescription": "Technical configuration and diagnostics only. Current operating settings are available in Manual control.",
|
||||
"devices.technicalUnit": "Technical unit",
|
||||
"devices.address": "Address",
|
||||
"devices.modelFirmware": "Model / firmware",
|
||||
"devices.lastSeen": "Last response",
|
||||
"devices.communicationFailures": "Communication failures",
|
||||
"devices.lastError": "Last error",
|
||||
"devices.readStatus": "Read status",
|
||||
"devices.technicalConfig": "Technical configuration",
|
||||
"devices.diagnostics": "Diagnostics",
|
||||
"devices.ping": "Ping",
|
||||
"devices.pingOpen": "Open live ping view",
|
||||
"devices.pingLive": "Live ping",
|
||||
"devices.pingHint": "The measurement shows GREE controller round-trip time while reading device status. The chart refreshes while monitoring is enabled.",
|
||||
"devices.pingAll": "Ping all units",
|
||||
"devices.pingStart": "Start",
|
||||
"devices.pingStop": "Stop",
|
||||
"devices.pingCurrent": "Current",
|
||||
"devices.pingAverage": "Average",
|
||||
"devices.pingMin": "Min",
|
||||
"devices.pingMax": "Max",
|
||||
"devices.pingSamples": "Samples",
|
||||
"devices.pingNoSamples": "No samples yet. Start monitoring.",
|
||||
"devices.pingFailed": "No response",
|
||||
"devices.manualDisabledZoneTitle": "Warning: zone is disabled",
|
||||
"devices.manualDisabledZoneWarning": "This unit belongs to disabled zone “{zone}”. Manual control is still available, but the command is sent directly outside that zone automation.",
|
||||
"devices.manualDisabledZoneConfirm": "Zone “{zone}” is disabled. This command will be sent directly to the unit, outside zone automation. Automation will stay disabled until it is enabled / resumed again. Send the command?",
|
||||
"devices.disabledZoneTechnicalOnly": "This zone is disabled. Explicit direct operation requires confirmation in Manual control.",
|
||||
"zones.automation": "Automation",
|
||||
"zones.new": "New zone",
|
||||
"zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.",
|
||||
|
||||
+29
-1
@@ -75,7 +75,35 @@
|
||||
"devices.readDone": "Odczyt zakończony",
|
||||
"devices.bound": "Urządzenie powiązane",
|
||||
"devices.added": "Urządzenie dodane",
|
||||
"devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Ręczne sterowanie tą jednostką jest dostępne tylko w zakładce Urządzenia.",
|
||||
"dashboard.manualControlHint": "Bezpośrednie sterowanie GREE: zasilanie, temperatura, tryb, nawiew i obsługiwane funkcje jednostki.",
|
||||
"devices.technicalDescription": "Wyłącznie konfiguracja techniczna i diagnostyka jednostek. Bieżące nastawy znajdują się w Sterowaniu ręcznym.",
|
||||
"devices.technicalUnit": "Jednostka techniczna",
|
||||
"devices.address": "Adres",
|
||||
"devices.modelFirmware": "Model / firmware",
|
||||
"devices.lastSeen": "Ostatnia odpowiedź",
|
||||
"devices.communicationFailures": "Błędy komunikacji",
|
||||
"devices.lastError": "Ostatni błąd",
|
||||
"devices.readStatus": "Odczytaj status",
|
||||
"devices.technicalConfig": "Konfiguracja techniczna",
|
||||
"devices.diagnostics": "Diagnostyka",
|
||||
"devices.ping": "Ping",
|
||||
"devices.pingOpen": "Otwórz podgląd pingu",
|
||||
"devices.pingLive": "Ping na żywo",
|
||||
"devices.pingHint": "Pomiar pokazuje czas odpowiedzi kontrolera GREE podczas odczytu statusu urządzenia. Wykres odświeża się, gdy monitoring jest włączony.",
|
||||
"devices.pingAll": "Pinguj wszystkie jednostki",
|
||||
"devices.pingStart": "Uruchom",
|
||||
"devices.pingStop": "Zatrzymaj",
|
||||
"devices.pingCurrent": "Teraz",
|
||||
"devices.pingAverage": "Średnio",
|
||||
"devices.pingMin": "Min.",
|
||||
"devices.pingMax": "Maks.",
|
||||
"devices.pingSamples": "Próbki",
|
||||
"devices.pingNoSamples": "Brak próbek. Uruchom monitoring.",
|
||||
"devices.pingFailed": "Brak odpowiedzi",
|
||||
"devices.manualDisabledZoneTitle": "Uwaga: strefa jest wyłączona",
|
||||
"devices.manualDisabledZoneWarning": "Jednostka należy do wyłączonej strefy „{zone}”. Sterowanie ręczne nadal jest dostępne, ale polecenie zostanie wysłane bezpośrednio poza automatyką tej strefy.",
|
||||
"devices.manualDisabledZoneConfirm": "Strefa „{zone}” jest wyłączona. Ta komenda zostanie wysłana bezpośrednio do jednostki, poza automatyką strefy. Automatyka pozostanie wyłączona do jej ponownego włączenia / wznowienia. Wysłać polecenie?",
|
||||
"devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Bezpośrednie sterowanie wymaga świadomego potwierdzenia w Sterowaniu ręcznym.",
|
||||
"zones.automation": "Automatyka",
|
||||
"zones.new": "Nowa strefa",
|
||||
"zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.",
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ async fn command_device(State(state): State<AppState>, Path(id): Path<String>, J
|
||||
|
||||
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
|
||||
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
|
||||
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use Manual control in the web UI for explicit direct operation".into()));
|
||||
}
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
|
||||
}
|
||||
|
||||
+20
-1
@@ -130,6 +130,7 @@
|
||||
<div class="dashboard-section-head compact-head">
|
||||
<div><span class="eyebrow" data-i18n="nav.devices">Devices</span>
|
||||
<h2 data-i18n="dashboard.quickControl">Manual control</h2>
|
||||
<p data-i18n="dashboard.manualControlHint">Direct GREE control: power, temperature, mode, fan and supported unit functions.</p>
|
||||
</div>
|
||||
<span class="badge" id="dashboardDeviceCount">0</span>
|
||||
</div>
|
||||
@@ -147,6 +148,7 @@
|
||||
data-i18n="actions.discover">Discover</button><button class="secondary" data-open="deviceDialog"
|
||||
data-i18n="devices.addManual">Add manually</button></div>
|
||||
</div>
|
||||
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current operating settings are available in Manual control.</p>
|
||||
<div class="device-grid" id="deviceList"></div>
|
||||
</section>
|
||||
|
||||
@@ -918,9 +920,10 @@
|
||||
<form method="dialog" id="renameDeviceForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
<div class="dialog-head">
|
||||
<h2 data-i18n="devices.nameProtocol">Name / protocol</h2><button type="button" data-close>×</button>
|
||||
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close>×</button>
|
||||
</div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
|
||||
<div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required></label><label><span>Port</span><input type="number" name="port" min="1" max="65535" required></label></div>
|
||||
<label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version">
|
||||
<option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option>
|
||||
<option value="1">V1 AES-ECB</option>
|
||||
@@ -934,6 +937,22 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="pingDialog" class="dialog-wide ping-dialog">
|
||||
<div class="dialog-form">
|
||||
<div class="dialog-head">
|
||||
<div><span class="eyebrow" data-i18n="devices.diagnostics">Diagnostics</span><h2 data-i18n="devices.pingLive">Live ping</h2></div>
|
||||
<button type="button" data-close>×</button>
|
||||
</div>
|
||||
<p class="field-note" data-i18n="devices.pingHint">Measures the GREE controller round-trip by reading device status. The chart refreshes while monitoring is enabled.</p>
|
||||
<div class="ping-toolbar">
|
||||
<label><span data-i18n="common.device">Device</span><select id="pingDeviceSelect"></select></label>
|
||||
<label class="check ping-all-toggle"><input type="checkbox" id="pingAllDevices"> <span data-i18n="devices.pingAll">Ping all units</span></label>
|
||||
<button type="button" class="primary" id="pingToggleButton" data-action="ping-toggle" data-i18n="devices.pingStop">Stop</button>
|
||||
</div>
|
||||
<div class="ping-live-grid" id="pingLiveGrid"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="zoneDialog">
|
||||
<form method="dialog" id="zoneForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
|
||||
@@ -34,6 +34,7 @@ const app = {
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
|
||||
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
+64
-25
@@ -18,49 +18,88 @@ function zoneForDevice(deviceId) {
|
||||
return app.zones.find(zone => zone.device_id === deviceId) || null;
|
||||
}
|
||||
|
||||
function deviceCard(device, detailed = false) {
|
||||
function disabledZoneForDevice(deviceId) {
|
||||
return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null;
|
||||
}
|
||||
|
||||
function deviceProtocolLabel(device) {
|
||||
return device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 AES-GCM' : device.protocol_version === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
|
||||
}
|
||||
|
||||
function deviceResponseTimeLabel(device) {
|
||||
const value = Number(device.response_time_ms);
|
||||
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
|
||||
}
|
||||
|
||||
function manualDeviceCard(device) {
|
||||
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
const fans = [0, 1, 3, 5];
|
||||
const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
|
||||
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
|
||||
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
|
||||
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
|
||||
const disabledZone = disabledZoneForDevice(device.id);
|
||||
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
|
||||
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
|
||||
const locked = zoneLocked ? ' disabled' : '';
|
||||
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
|
||||
const quickRow = detailed ? '' : ' quick-control-row';
|
||||
return `<article class="device-card${quickClass} ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
||||
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
|
||||
return `<article class="device-card quick-control-card quick-device-control ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
|
||||
<div class="device-head">
|
||||
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
|
||||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}>⏻</button>
|
||||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
|
||||
</div>
|
||||
${warning}
|
||||
<div class="temperature-control">
|
||||
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}>−</button>
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="${zoneLocked ? 'false' : 'true'}" tabindex="${zoneLocked ? '-1' : '0'}" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
|
||||
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}">−</button>
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
|
||||
${compressorQueuePanel(managedZone)}
|
||||
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
|
||||
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||||
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
|
||||
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.swing'))}</button>
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
|
||||
<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||
<div class="fan-row quick-control-row quick-control-row-4">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||||
<div class="device-toggles quick-control-row quick-control-row-3">
|
||||
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||
</div>
|
||||
${deviceFeaturePanel(device)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function technicalDeviceCard(device) {
|
||||
const protocol = deviceProtocolLabel(device);
|
||||
const responseTime = deviceResponseTimeLabel(device);
|
||||
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('common.unavailable');
|
||||
const model = device.model || tr('common.unavailable');
|
||||
const firmware = device.firmware || tr('common.unavailable');
|
||||
const cid = device.cid || tr('common.unavailable');
|
||||
const error = device.last_error ? `<div class="technical-device-error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
|
||||
<div class="technical-device-head">
|
||||
<div class="device-title"><span class="eyebrow">${esc(tr('devices.technicalUnit'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
|
||||
<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>
|
||||
</div>
|
||||
<div class="technical-device-grid">
|
||||
<div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip)}:${esc(device.port)}</strong></div>
|
||||
<div><span>MAC</span><strong>${esc(device.mac)}</strong></div>
|
||||
<div><span>CID</span><strong>${esc(cid)}</strong></div>
|
||||
<div><span>${esc(tr('devices.protocol'))}</span><strong>${esc(protocol)}</strong></div>
|
||||
<div><span>${esc(tr('devices.modelFirmware'))}</span><strong>${esc(model)}</strong><small>${esc(firmware)}</small></div>
|
||||
<div><span>${esc(tr('devices.lastSeen'))}</span><strong>${esc(lastSeen)}</strong></div>
|
||||
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
|
||||
</div>
|
||||
${error}
|
||||
<div class="technical-device-actions">
|
||||
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
|
||||
<button type="button" data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.technicalConfig'))}</button>
|
||||
${device.simulated ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
|
||||
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
|
||||
</div>
|
||||
${detailed ? deviceFeaturePanel(device) : ''}
|
||||
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
|
||||
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
|
||||
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty;
|
||||
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty;
|
||||
const dashboardCount = $('#dashboardDeviceCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
|
||||
if ($('#pingDialog')?.open) renderPingDialog();
|
||||
}
|
||||
|
||||
function zoneStrategyLabel(zone) {
|
||||
|
||||
+15
-4
@@ -74,9 +74,8 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
const action = button.dataset.action; if (!action) return;
|
||||
const device = app.devices.find(v => v.id === button.dataset.device);
|
||||
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power', 'temperature', 'mode', 'fan', 'toggle'].includes(action)) {
|
||||
return toast(tr('devices.disabledZoneTechnicalOnly'), true);
|
||||
}
|
||||
if (action === 'open-ping' && device) { openDevicePing(device.id); return; }
|
||||
if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; }
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({ power: !current?.power }));
|
||||
if (action === 'temperature' && device) return sendDeviceCommand(device.id, current => ({ target_temperature: clamp(Number(current?.target_temperature ?? device.target_temperature) + Number(button.dataset.delta), 8, 30) }));
|
||||
if (action === 'mode' && device) return sendDeviceCommand(device.id, { mode: button.dataset.value, power: true });
|
||||
@@ -324,11 +323,23 @@ $('#discoveryNamesForm').addEventListener('submit', async event => {
|
||||
$('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
await runFormTask(form, async () => {
|
||||
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), protocol_version: Number(raw.protocol_version) } });
|
||||
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) } });
|
||||
updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#pingDeviceSelect')?.addEventListener('change', event => {
|
||||
app.pingMonitor.targetId = event.target.value;
|
||||
renderPingDialog();
|
||||
if (app.pingMonitor.running) schedulePingCycle(0);
|
||||
});
|
||||
$('#pingAllDevices')?.addEventListener('change', event => {
|
||||
app.pingMonitor.all = event.target.checked;
|
||||
renderPingDialog();
|
||||
if (app.pingMonitor.running) schedulePingCycle(0);
|
||||
});
|
||||
$('#pingDialog')?.addEventListener('close', () => stopPingMonitor());
|
||||
|
||||
$('#deviceForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, data = Object.fromEntries(new FormData(form));
|
||||
data.port = Number(data.port); data.protocol_version = Number(data.protocol_version); data.simulated = form.simulated.checked;
|
||||
|
||||
+140
-1
@@ -35,7 +35,146 @@ function showHistoryTab(tab, { push = true, load = true } = {}) {
|
||||
if (load) loadHistory();
|
||||
}
|
||||
|
||||
function confirmManualCommandForDisabledZone(id) {
|
||||
const zone = disabledZoneForDevice(id);
|
||||
if (!zone) return true;
|
||||
return confirm(tr('devices.manualDisabledZoneConfirm', { zone: zone.name }));
|
||||
}
|
||||
|
||||
function pingSamples(id) {
|
||||
if (!app.pingMonitor.samples[id]) app.pingMonitor.samples[id] = [];
|
||||
return app.pingMonitor.samples[id];
|
||||
}
|
||||
|
||||
function addPingSample(id, value, error = '') {
|
||||
const samples = pingSamples(id);
|
||||
samples.push({ at: Date.now(), value: Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : null, error: String(error || '') });
|
||||
if (samples.length > 36) samples.splice(0, samples.length - 36);
|
||||
}
|
||||
|
||||
function pingSparkline(samples) {
|
||||
const width = 360, height = 92, padX = 8, padY = 9;
|
||||
const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value));
|
||||
if (!valid.length) return `<div class="ping-empty">${esc(tr('devices.pingNoSamples'))}</div>`;
|
||||
const values = valid.map(item => item.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const ceiling = Math.max(max, 10);
|
||||
const floor = Math.min(min, 0);
|
||||
const range = Math.max(1, ceiling - floor);
|
||||
const lastIndex = Math.max(1, samples.length - 1);
|
||||
const points = valid.map(item => {
|
||||
const x = padX + (item.index / lastIndex) * (width - padX * 2);
|
||||
const y = height - padY - ((item.value - floor) / range) * (height - padY * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
const guide = [0.25, 0.5, 0.75].map(ratio => `<line x1="${padX}" y1="${(height * ratio).toFixed(1)}" x2="${width - padX}" y2="${(height * ratio).toFixed(1)}"></line>`).join('');
|
||||
return `<svg class="ping-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${esc(tr('devices.pingLive'))}"><g class="ping-grid-lines">${guide}</g><polyline points="${points}"></polyline></svg>`;
|
||||
}
|
||||
|
||||
function pingStats(samples) {
|
||||
const values = samples.map(sample => sample.value == null ? NaN : Number(sample.value)).filter(Number.isFinite);
|
||||
if (!values.length) return { current: null, average: null, min: null, max: null };
|
||||
const current = [...samples].reverse().find(sample => sample.value != null && Number.isFinite(Number(sample.value)))?.value ?? null;
|
||||
return {
|
||||
current,
|
||||
average: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
|
||||
function pingValue(value) {
|
||||
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} ms` : '—';
|
||||
}
|
||||
|
||||
function renderPingDialog() {
|
||||
const dialog = $('#pingDialog');
|
||||
const select = $('#pingDeviceSelect');
|
||||
const all = $('#pingAllDevices');
|
||||
const toggle = $('#pingToggleButton');
|
||||
const grid = $('#pingLiveGrid');
|
||||
if (!dialog || !select || !all || !toggle || !grid) return;
|
||||
if (!app.pingMonitor.targetId || !app.devices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = app.devices[0]?.id || '';
|
||||
select.innerHTML = app.devices.map(device => `<option value="${esc(device.id)}" ${device.id === app.pingMonitor.targetId ? 'selected' : ''}>${esc(device.name)}</option>`).join('');
|
||||
select.disabled = app.pingMonitor.all;
|
||||
all.checked = app.pingMonitor.all;
|
||||
toggle.textContent = tr(app.pingMonitor.running ? 'devices.pingStop' : 'devices.pingStart');
|
||||
toggle.classList.toggle('primary', app.pingMonitor.running);
|
||||
toggle.classList.toggle('secondary', !app.pingMonitor.running);
|
||||
const devices = app.pingMonitor.all ? app.devices : app.devices.filter(device => device.id === app.pingMonitor.targetId);
|
||||
grid.innerHTML = devices.length ? devices.map(device => {
|
||||
const samples = pingSamples(device.id);
|
||||
const stats = pingStats(samples);
|
||||
const last = samples.length ? samples[samples.length - 1] : null;
|
||||
const state = last?.error ? `<span class="ping-state error">${esc(tr('devices.pingFailed'))}</span>` : `<span class="ping-state ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span>`;
|
||||
return `<article class="ping-live-card">
|
||||
<div class="ping-live-head"><div><strong>${esc(device.name)}</strong><small>${esc(device.ip)}</small></div>${state}</div>
|
||||
${pingSparkline(samples)}
|
||||
<div class="ping-stat-grid">
|
||||
<div><span>${esc(tr('devices.pingCurrent'))}</span><strong>${esc(pingValue(stats.current))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingAverage'))}</span><strong>${esc(pingValue(stats.average))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingMin'))}</span><strong>${esc(pingValue(stats.min))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingMax'))}</span><strong>${esc(pingValue(stats.max))}</strong></div>
|
||||
</div>
|
||||
<small class="ping-sample-count">${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''}</small>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
function schedulePingCycle(delay = 1800) {
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
|
||||
app.pingMonitor.timer = setTimeout(runPingCycle, delay);
|
||||
}
|
||||
|
||||
async function runPingCycle() {
|
||||
if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return;
|
||||
const targets = app.pingMonitor.all ? [...app.devices] : app.devices.filter(device => device.id === app.pingMonitor.targetId);
|
||||
if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; }
|
||||
app.pingMonitor.inFlight = true;
|
||||
await Promise.allSettled(targets.map(async device => {
|
||||
try {
|
||||
const updated = await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' });
|
||||
updateDevice(updated);
|
||||
addPingSample(device.id, updated.response_time_ms);
|
||||
} catch (error) {
|
||||
addPingSample(device.id, null, error.message || String(error));
|
||||
}
|
||||
}));
|
||||
app.pingMonitor.inFlight = false;
|
||||
renderDevices();
|
||||
renderPingDialog();
|
||||
schedulePingCycle();
|
||||
}
|
||||
|
||||
function startPingMonitor() {
|
||||
if (!app.devices.length) return;
|
||||
app.pingMonitor.running = true;
|
||||
renderPingDialog();
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
runPingCycle();
|
||||
}
|
||||
|
||||
function stopPingMonitor() {
|
||||
app.pingMonitor.running = false;
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
renderPingDialog();
|
||||
}
|
||||
|
||||
function openDevicePing(id) {
|
||||
app.pingMonitor.targetId = id || app.devices[0]?.id || '';
|
||||
app.pingMonitor.all = false;
|
||||
openDialog('pingDialog');
|
||||
renderPingDialog();
|
||||
startPingMonitor();
|
||||
}
|
||||
|
||||
async function sendDeviceCommand(id, commandOrFactory) {
|
||||
if (!confirmManualCommandForDisabledZone(id)) return;
|
||||
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => { }).then(() => {
|
||||
const current = app.devices.find(device => device.id === id);
|
||||
@@ -204,7 +343,7 @@ function showDiscoveryNames(ids) {
|
||||
function populateDeviceRename(id) {
|
||||
const device = app.devices.find(item => item.id === id); if (!device) return;
|
||||
const form = $('#renameDeviceForm'); form.reset();
|
||||
form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0);
|
||||
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip; form.port.value = String(device.port ?? 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
|
||||
openDialog('renameDeviceDialog');
|
||||
}
|
||||
|
||||
|
||||
+327
@@ -6784,3 +6784,330 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde
|
||||
.chart-title-actions { gap:5px; }
|
||||
.chart-zoom-controls button { height:36px; }
|
||||
}
|
||||
|
||||
/* Manual unit control and technical device diagnostics. */
|
||||
.manual-zone-blocked {
|
||||
border-color: color-mix(in srgb, var(--warning) 62%, var(--line));
|
||||
}
|
||||
|
||||
.manual-zone-warning {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 10px 0 2px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--line));
|
||||
border-radius: 11px;
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.manual-zone-warning strong {
|
||||
color: var(--warning);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.manual-zone-warning p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.technical-device-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.technical-device-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.technical-device-head .device-title h3 {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.device-ping-button {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 2px;
|
||||
min-width: 88px;
|
||||
min-height: 50px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.device-ping-button span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.device-ping-button strong {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.device-ping-button:hover {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.technical-device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.technical-device-grid > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.technical-device-grid span,
|
||||
.technical-device-error span {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.technical-device-grid strong,
|
||||
.technical-device-grid small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.technical-device-grid strong {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.technical-device-grid small {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.technical-device-error {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line));
|
||||
border-radius: 10px;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.technical-device-error strong {
|
||||
overflow: hidden;
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.technical-device-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.technical-device-actions > button {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ping-dialog .dialog-form {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ping-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) auto auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ping-toolbar > label:first-child {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ping-all-toggle {
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#pingToggleButton {
|
||||
min-width: 96px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.ping-live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ping-live-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.ping-live-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ping-live-head > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ping-live-head strong,
|
||||
.ping-live-head small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ping-live-head strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ping-live-head small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ping-state {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 7px;
|
||||
border-radius: 99px;
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ping-state.online {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.ping-state.error {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.ping-sparkline {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ping-sparkline .ping-grid-lines line {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.ping-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.ping-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 92px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ping-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ping-stat-grid > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ping-stat-grid span {
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ping-stat-grid strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ping-sample-count {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.technical-device-grid { grid-template-columns: 1fr; }
|
||||
.technical-device-head { align-items: center; }
|
||||
.device-ping-button { min-width: 76px; }
|
||||
.ping-toolbar { grid-template-columns: 1fr auto; }
|
||||
.ping-toolbar > label:first-child { grid-column: 1 / -1; }
|
||||
.ping-all-toggle { justify-self: start; }
|
||||
#pingToggleButton { justify-self: end; }
|
||||
.ping-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user