diff --git a/client/css/style.css b/client/css/style.css index cd362c2..a1fb1f8 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1,12 +1,28 @@ -body { - background: #eee; +body, html { + padding: 0; + margin: 0; } -#sensors-container { +body { + background: #eee; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +button, input { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.sensors { display: grid; grid-template-columns: 1fr 1fr; } +@media only screen and (max-width: 1200px) { + .sensors { + grid-template-columns: 1fr; + } +} + .sensor { background: #fff; border-radius: 0.5rem; @@ -23,4 +39,89 @@ body { .sensor .header .actions { margin-left: auto; +} + +.settings-modal { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: auto; + display: none; + justify-content: center; + align-items: flex-start; + background-color: rgba(0,0,0,0.2); + z-index: 5; +} + +.settings-modal.show { + display: flex; +} + +.settings-modal .inner { + background: #fff; + margin-top: 5%; + box-shadow: 0px 10px 15px -3px rgba(0,0,0,0.1); + border-radius: 0.5rem; +} + +.settings-modal .inner .body { + padding: 0.75rem 1rem; +} + +form { + display: flex; + flex-direction: column; +} + +form .input { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; +} + +form .actions { + text-align: right; +} + + +form.horizontal { + flex-direction: row; + align-items: center; +} + +form.horizontal .input { + flex-direction: row; + margin-bottom: 0; + margin-right: 0.5rem; + align-items: baseline; +} + +form.horizontal .input label { + margin-right: 0.25rem; +} + +.filters { + display: flex; + align-items: center; + padding: 0.5rem 0.5rem; + margin-bottom: 1rem; + background-color: #fff; + box-shadow: 0px 10px 15px -3px rgba(0,0,0,0.1); +} + +.filters .actions { + margin-left: auto; + display: flex; + align-items: center; +} + +.checkbox-label { + display: inline-flex; + align-items: center; +} + +.checkbox-label input[type=checkbox] { + margin-top: 6px; } \ No newline at end of file diff --git a/client/js/index.js b/client/js/index.js index b9e71a2..a2d0cc0 100644 --- a/client/js/index.js +++ b/client/js/index.js @@ -1,18 +1,222 @@ const { html, render } = lighterhtml; + const $container = document.getElementById("sensors-container"); +const $filters = document.createElement("div"); +$filters.className = "filters"; +$container.appendChild($filters); + +const $sensorsContainer = document.createElement("div"); +$sensorsContainer.className = "sensors"; +$container.appendChild($sensorsContainer); + +const $config = document.createElement("div"); +$config.className = "config"; +$container.appendChild($config); + +let sensorComponents = []; + function load() { fetch("/api/sensors") .then((r) => r.json()) .then((sensors) => { - const components = sensors.map((sensor) => createSensor(sensor)); + $sensorsContainer.innerHTML = '' - components.forEach((component) => - $container.appendChild(component.container) + sensorComponents = sensors.map((sensor) => createSensor(sensor)); + sensorComponents.forEach((component) => + $sensorsContainer.appendChild(component.container) ); }); } +function preventPropagation(e) { + e.stopPropagation(); +} + +function hideConfig() { + renderConfig({ sensor: { config: {} }, shown: false }); +} + +function showConfigOf(sensor, onSave) { + renderConfig({ sensor, onSave, shown: true }); +} + +function renderConfig({ sensor, onSave, shown }) { + const handleSave = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const data = Object.fromEntries(new FormData(e.target)); + + Promise.all( + Object.entries(data).map(([name, value]) => + fetch(`/api/sensors/${sensor.sensor}/config/${name}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value }), + }) + ) + ).then(() => onSave?.()); + + hideConfig(); + }; + + const config = sensor.config; + + render( + $config, + html` +
+
+
+
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+ ` + ); +} + +const CURRENT_FILTERS = { + interval: "week", + refresh: "1s", + customFrom: new Date().toISOString(), + customTo: new Date().toISOString(), +}; + +function refreshAll() { + sensorComponents.forEach(c => c.refreshValues()) +} + +let isCustomSelected = false +let lastIntervalSelected = 'week' + +function splitDateTime(v) { + const d = new Date(v) + + const date = d.getFullYear() + '-' + (d.getMonth() + 1).toString().padStart(2, '0') + '-' + d.getDate().toString().padStart(2, '0') + const time = d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + + return [date, time] +} + +function renderFilters() { + const handleApply = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const data = Object.fromEntries(new FormData(e.target)); + + CURRENT_FILTERS.interval = data.interval; + + if (data.interval !== "custom") { + const [from, to] = getDataInterval(CURRENT_FILTERS.interval); + CURRENT_FILTERS.customFrom = from.toISOString(); + CURRENT_FILTERS.customTo = to.toISOString(); + } else { + CURRENT_FILTERS.customFrom = new Date(`${data.fromDate} ${data.fromTime}`) + CURRENT_FILTERS.customTo = new Date(`${data.toDate} ${data.toTime}`) + } + + refreshAll() + renderFilters() + }; + + const handleIntervalChange = (e) => { + lastIntervalSelected = e.target.value + + renderFilters() + } + + const customFrom = splitDateTime(CURRENT_FILTERS.customFrom) + const customTo = splitDateTime(CURRENT_FILTERS.customTo) + const isCustomSelected = lastIntervalSelected === 'custom' + + render( + $filters, + html` +
+
+
+ + +
+ + ${isCustomSelected ? html` +
+ +
+ + +
+
+
+ +
+ + +
+
+ ` : undefined} + + +
+
+
+ + + +
+ ` + ); +} + +function getDataInterval() { + switch (CURRENT_FILTERS.interval) { + case "hour": { + return [new Date(Date.now() - 3600 * 1000), new Date()]; + } + case "day": { + return [new Date(Date.now() - 24 * 3600 * 1000), new Date()]; + } + case "week": { + return [new Date(Date.now() - 5 * 24 * 3600 * 1000), new Date()]; + } + case "month": { + return [new Date(Date.now() - 30 * 24 * 3600 * 1000), new Date()]; + } + case "year": { + return [new Date(Date.now() - 356 * 24 * 3600 * 1000), new Date()]; + } + case "custom": { + return [ + new Date(CURRENT_FILTERS.customFrom), + new Date(CURRENT_FILTERS.customTo), + ]; + } + } +} + function createSensor(sensor) { const container = document.createElement("div"); container.className = "sensor"; @@ -23,13 +227,17 @@ function createSensor(sensor) { const body = document.createElement("div"); body.className = "body"; + const showSettings = () => { + showConfigOf(sensor, () => refreshSensorConfig()); + }; + const renderHeader = () => { render( header, html`
${sensor.config?.name ?? sensor.sensor}
- +
` @@ -65,8 +273,7 @@ function createSensor(sensor) { }; const refreshValues = () => { - const from = new Date(Date.now() - 5 * 24 * 3600 * 1000); - const to = new Date(); + const [from, to] = getDataInterval(CURRENT_FILTERS.interval); fetch( `/api/sensors/${sensor.sensor}/values?from=${Math.round( @@ -74,7 +281,17 @@ function createSensor(sensor) { )}&to=${Math.round(to.getTime() / 1000)}` ) .then((r) => r.json()) - .then((values) => renderBody({ from, to }, values)); + .then((values) => renderBody({ from, to }, values ?? [])); + }; + + const refreshSensorConfig = () => { + fetch(`/api/sensors/${sensor.sensor}/config`) + .then((r) => r.json()) + .then((c) => (sensor.config = c)) + .then(() => { + renderHeader(); + refreshValues(); + }); }; renderHeader(); @@ -85,7 +302,9 @@ function createSensor(sensor) { return { container, + refreshValues }; } load(); +renderFilters();