index.html 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. {% extends "base.html" %}
  2. {% set active_page = "sensors" %}
  3. {% block title %} Sensor data {% endblock %}
  4. {% block divs %}
  5. {% block breadcrumbs %} {{ super() }} {% endblock %}
  6. <div class="sensor-data charts text-center">
  7. <div class="row">
  8. <div class="alert alert-info" id="tzwarn" style="display:none;"></div>
  9. <div class="alert alert-info" id="dstwarn" style="display:none;"></div>
  10. <div class="alert alert-info" id="sourcewarn" style="display:none;"></div>
  11. </div>
  12. <div class="row on-top-md">
  13. <div class="col-md-2">
  14. <div class="header-action-button">
  15. {% if user_can_delete_sensor %}
  16. <div>
  17. <button id="delete-asset-button" class="btn btn-sm btn-responsive btn-danger" onclick="deleteSensor()">
  18. Delete this sensor
  19. </button>
  20. <script>
  21. $("#delete-asset-button").click(function () {
  22. if (confirm("Are you sure you want to delete this sensor and all time series data associated with it?")) {
  23. return true;
  24. }
  25. else {
  26. return false;
  27. }
  28. });
  29. </script>
  30. </div>
  31. {% endif %}
  32. </div>
  33. <div class="sidepanel-container">
  34. <div class="left-sidepanel-label">Select dates</div>
  35. <div class="sidepanel left-sidepanel">
  36. <div id="datepicker"></div>
  37. </div>
  38. </div>
  39. <div id="chart-type-picker" class="leftside-dropdown dropdown">
  40. <button class="btn dropdown-toggle" type="button" id="chartTypeDropdown" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  41. Select chart
  42. </button>
  43. <ul class="dropdown-menu center-aligned" aria-labelledby="chartTypeDropdown">
  44. <li><a class="dropdown-item" href="#" data-chart-type="bar_chart">Bar chart</a></li>
  45. <li><a class="dropdown-item" href="#" data-chart-type="histogram">Histogram</a></li>
  46. <li><a class="dropdown-item" href="#" data-chart-type="daily_heatmap">Daily heatmap</a></li>
  47. <li><a class="dropdown-item" href="#" data-chart-type="weekly_heatmap">Weekly heatmap</a></li>
  48. </ul>
  49. </div>
  50. {% if user_can_update_sensor %}
  51. <div class="sidepanel-container">
  52. <div class="left-sidepanel-label">Edit sensor</div>
  53. <div class="sidepanel left-sidepanel" style="text-align: left;">
  54. <fieldset>
  55. <h3>Edit {{ sensor.name }}</h3>
  56. <small>Parent asset: {{ sensor.generic_asset.name }} (ID: {{ sensor.generic_asset.id }} )</small>
  57. <form class="form-horizontal" method="POST">
  58. <div class="form-group">
  59. <label for="name" class="form-label">Name</label>
  60. <input type="text" class="form-control" id="name" name="name" placeholder="e.g power" value="{{sensor.name}}" required />
  61. </div>
  62. <button class="btn btn-sm btn-responsive btn-success create-button" type="submit" onclick="updateSensor(event)"
  63. style="margin-top: 20px; float: right; border: 1px solid var(--light-gray);">
  64. Save
  65. </button>
  66. </form>
  67. </fieldset>
  68. </div>
  69. </div>
  70. {% endif %}
  71. </div>
  72. <div class="col-sm-8">
  73. <div id="sensorchart" class="card" style="width: 100%;"></div>
  74. <div id="spinner" hidden="hidden">
  75. <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
  76. <span class="sr-only">Loading...</span>
  77. </div>
  78. <div class="row">
  79. <div class="card col-lg-5">
  80. <h5>Properties</h5>
  81. <table class="table table-striped">
  82. <tr>
  83. <th>Name</th>
  84. <td>{{ sensor.name }}</td>
  85. </tr>
  86. <tr>
  87. <th>Unit</th>
  88. <td>{{ sensor.unit }}</td>
  89. </tr>
  90. <tr>
  91. <th>Event resolution</th>
  92. <td>{{ sensor.event_resolution }}</td>
  93. </tr>
  94. <tr>
  95. <th>Timezone</th>
  96. <td>{{ sensor.timezone }}</td>
  97. </tr>
  98. <tr>
  99. <th>Knowledge horizon type</th>
  100. <td>{{ sensor.knowledge_horizon_fnc }}</td>
  101. </tr>
  102. </table>
  103. </div>
  104. <div class="card col-lg-5" id="statsContainer">
  105. <span id="spinner-run-simulation" class="spinner-border spinner-border-sm d-none" role="status"></span>
  106. <h5 id="statsHeader">Statistics</h5>
  107. <table id="statsTable" class="table table-striped">
  108. <tbody id="statsTableBody">
  109. </tbody>
  110. </table>
  111. <!-- Dropdown for sourceKey -->
  112. <div class="dropdown mb-3 d-none" id="sourceKeyDropdownContainer">
  113. <small class="text-muted">Select source for statistics</small>
  114. <button class="btn btn-secondary dropdown-toggle" type="button" id="sourceKeyDropdown" data-bs-toggle="dropdown" aria-expanded="false">
  115. Select Source
  116. </button>
  117. <ul class="dropdown-menu" aria-labelledby="sourceKeyDropdown" id="sourceKeyDropdownMenu">
  118. </ul>
  119. </div>
  120. <!-- Alert for no data -->
  121. <div class="alert alert-warning d-none" id="noDataWarning">
  122. There is no data for this sensor yet.
  123. </div>
  124. <!-- Alert for errors -->
  125. <div class="alert alert-danger d-none" id="fetchError">
  126. There was a problem fetching statistics for this sensor's data.
  127. </div>
  128. <script>
  129. document.addEventListener('DOMContentLoaded', function() {
  130. const spinner = document.getElementById('spinner-run-simulation');
  131. const tableBody = document.getElementById('statsTableBody');
  132. const dropdownMenu = document.getElementById('sourceKeyDropdownMenu');
  133. const dropdownButton = document.getElementById('sourceKeyDropdown');
  134. const dropdownContainer = document.getElementById('sourceKeyDropdownContainer');
  135. const propertiesContainer = document.getElementById('propertiesContainer');
  136. const statsContainer = document.getElementById('statsContainer');
  137. const noDataWarning = document.getElementById('noDataWarning');
  138. const fetchError = document.getElementById('fetchError');
  139. // Show the spinner
  140. spinner.classList.remove('d-none');
  141. fetch('/api/v3_0/sensors/' + {{ sensor.id }} + '/stats?sort=false')
  142. .then(response => response.json())
  143. .then(data => {
  144. // Remove 'status' sourceKey
  145. delete data['status'];
  146. data = unpackData(data);
  147. if (Object.keys(data).length > 0) {
  148. // Show the header and dropdown container
  149. dropdownContainer.classList.remove('d-none');
  150. // Populate the dropdown menu with sourceKeys
  151. Object.keys(data).forEach(sourceKey => {
  152. const dropdownItem = document.createElement('li');
  153. const dropdownLink = document.createElement('a');
  154. dropdownLink.className = 'dropdown-item';
  155. dropdownLink.href = '#';
  156. dropdownLink.textContent = sourceKey;
  157. dropdownLink.dataset.sourceKey = sourceKey;
  158. dropdownLink.addEventListener('click', function(event) {
  159. event.preventDefault();
  160. const selectedSourceKey = event.target.dataset.sourceKey;
  161. dropdownButton.textContent = selectedSourceKey;
  162. updateTable(data[selectedSourceKey]);
  163. });
  164. dropdownItem.appendChild(dropdownLink);
  165. dropdownMenu.appendChild(dropdownItem);
  166. });
  167. // Update the table with the first sourceKey's data by default
  168. const firstSourceKey = getLatestBeliefName(data);
  169. dropdownButton.textContent = firstSourceKey;
  170. updateTable(data[firstSourceKey]);
  171. } else {
  172. // If the stats table is empty, make the properties table full width
  173. noDataWarning.classList.remove('d-none');
  174. }
  175. })
  176. .catch(error => {
  177. console.error('Error:', error);
  178. dropdownContainer.classList.add('d-none');
  179. fetchError.textContent = 'There was a problem fetching statistics for this sensor\'s data: ' + error.message;
  180. fetchError.classList.remove('d-none');
  181. })
  182. .finally(() => {
  183. // Hide the spinner
  184. spinner.classList.add('d-none');
  185. });
  186. function updateTable(stats) {
  187. tableBody.innerHTML = ''; // Clear the table body
  188. Object.entries(stats).forEach(([key, val]) => {
  189. const row = document.createElement('tr');
  190. const keyCell = document.createElement('th');
  191. const valueCell = document.createElement('td');
  192. keyCell.textContent = key;
  193. // Round value to 2 decimal points if it's a number
  194. if (typeof val === 'number' & key != 'Number of values') {
  195. valueCell.textContent = val.toFixed(4);
  196. } else {
  197. valueCell.textContent = val;
  198. }
  199. row.appendChild(keyCell);
  200. row.appendChild(valueCell);
  201. tableBody.appendChild(row);
  202. });
  203. }
  204. function getLatestBeliefName(data) {
  205. return Object.keys(data).reduce((latest, name) => {
  206. const currentBeliefTime = new Date(data[name]["Last recorded"]);
  207. const latestBeliefTime = latest ? new Date(data[latest]["Last recorded"]) : new Date(0);
  208. return currentBeliefTime > latestBeliefTime ? name : latest;
  209. }, null);
  210. }
  211. function unpackData(data) {
  212. return Object.fromEntries(
  213. Object.entries(data).map(([key, value]) => {
  214. if (Array.isArray(value) && value.every(item => Array.isArray(item) && item.length === 2)) {
  215. return [key, Object.fromEntries(value)];
  216. }
  217. console.error(`Invalid entry for key: ${key}`, value);
  218. return [key, value];
  219. })
  220. );
  221. }
  222. });
  223. </script>
  224. </div>
  225. </div>
  226. </div>
  227. <div class="col-sm-2">
  228. <div class="replay-container">
  229. <div id="replay" title="Press 'p' to play/pause/resume or 's' to stop." class="stopped"></div>
  230. <div id="replay-time"></div>
  231. </div>
  232. </div>
  233. </div>
  234. <div class="row justify-content-center">
  235. <div class="col-md-8 offset-md-1">
  236. <div class="copy-url" title="Click to copy the URL to the current time range to clipboard.">
  237. <script>
  238. function toIsoString(date) {
  239. var tzo = -date.getTimezoneOffset(),
  240. dif = tzo >= 0 ? '+' : '-',
  241. pad = function(num) {
  242. return (num < 10 ? '0' : '') + num;
  243. };
  244. return date.getFullYear() +
  245. '-' + pad(date.getMonth() + 1) +
  246. '-' + pad(date.getDate()) +
  247. 'T' + pad(date.getHours()) +
  248. ':' + pad(date.getMinutes()) +
  249. ':' + pad(date.getSeconds()) +
  250. dif + pad(Math.floor(Math.abs(tzo) / 60)) +
  251. ':' + pad(Math.abs(tzo) % 60);
  252. }
  253. $(window).ready(() => {
  254. picker.on('selected', (startDate, endDate) => {
  255. startDate = encodeURIComponent(toIsoString(startDate.toJSDate()));
  256. endDate = encodeURIComponent(toIsoString(endDate.toJSDate()));
  257. var base_url = window.location.href.split("?")[0];
  258. var new_url = `${base_url}?start_time=${startDate}&end_time=${endDate}`;
  259. // change current url without reloading the page
  260. window.history.pushState({}, null, new_url);
  261. });
  262. });
  263. function copyUrl(event) {
  264. event.preventDefault();
  265. if (!window.getSelection) {
  266. alert('Please copy the URL from the location bar.');
  267. return;
  268. }
  269. const dummy = document.createElement('p');
  270. var startDate = encodeURIComponent(toIsoString(picker.getStartDate().toJSDate()));
  271. // add 1 day to end date as datepicker does not include the end date day
  272. var endDate = picker.getEndDate();
  273. endDate.setDate(endDate.getDate() + 1);
  274. endDate = encodeURIComponent(toIsoString(endDate.toJSDate()));
  275. var base_url = window.location.href.split("?")[0];
  276. dummy.textContent = `${base_url}?start_time=${startDate}&end_time=${endDate}`
  277. document.body.appendChild(dummy);
  278. const range = document.createRange();
  279. range.setStartBefore(dummy);
  280. range.setEndAfter(dummy);
  281. const selection = window.getSelection();
  282. // First clear, in case the user already selected some other text
  283. selection.removeAllRanges();
  284. selection.addRange(range);
  285. document.execCommand('copy');
  286. document.body.removeChild(dummy);
  287. $("#message").show().delay(1000).fadeOut();
  288. }
  289. </script>
  290. <a href="#" onclick="copyUrl(event)" style="display: block; text-align: center;">
  291. <i class="fa fa-link"></i>
  292. </a>
  293. <div id="message" style="display: none; text-align: center;">The URL to the time range currently shown has been copied to your clipboard.</div>
  294. </div>
  295. </div>
  296. </div>
  297. <hr>
  298. </div>
  299. <script src="https://cdnjs.cloudflare.com/ajax/libs/jstimezonedetect/1.0.7/jstz.js"></script>
  300. <script src="https://cdn.jsdelivr.net/npm/litepicker/dist/litepicker.js"></script>
  301. <script src="https://cdn.jsdelivr.net/npm/litepicker/dist/plugins/ranges.js"></script>
  302. <script src="https://cdn.jsdelivr.net/npm/litepicker/dist/plugins/keyboardnav.js"></script>
  303. <script>
  304. const apiBasePath = window.location.origin;
  305. const parentAssetId = "{{sensor.generic_asset.id}}"
  306. async function updateSensor(event) {
  307. event.preventDefault();
  308. const name = document.getElementById("name").value;
  309. if (!name) {
  310. showToast("Please fill in all fields.", "error");
  311. return;
  312. }
  313. const data = { name: name }
  314. const response = await fetch(apiBasePath + "/api/v3_0/sensors/{{ sensor.id }}", {
  315. method: "PATCH",
  316. headers: {
  317. "Content-Type": "application/json",
  318. },
  319. body: JSON.stringify(data),
  320. });
  321. if (response.ok) {
  322. const responseData = await response.json();
  323. const sensorId = responseData.id;
  324. document.getElementById("name").value = "";
  325. showToast("Sensor updated successfully.", "success");
  326. await new Promise(resolve => setTimeout(resolve, 500));
  327. showToast("Reloading page...", "info");
  328. await new Promise(resolve => setTimeout(resolve, 2000));
  329. window.location.reload();
  330. } else {
  331. const errorData = await response.json();
  332. let errorMessage = response.statusText; // Default to statusText
  333. const messageUnitError = errorData?.message?.json?.unit?.[0];
  334. if (messageUnitError) {
  335. errorMessage = messageUnitError;
  336. } else if (errorData?.message) {
  337. errorMessage = errorData.message;
  338. } else if (errorData?.error) {
  339. errorMessage = errorData.error;
  340. }
  341. showToast("Error: " + errorMessage, "error");
  342. }
  343. }
  344. async function deleteSensor() {
  345. const response = await fetch(apiBasePath + "/api/v3_0/sensors/{{ sensor.id }}", {
  346. method: "DELETE",
  347. headers: {
  348. "Content-Type": "application/json",
  349. },
  350. });
  351. if (response.status === 204) {
  352. showToast("Sensor deleted successfully.", "success");
  353. // delay for one second to show toast message
  354. await new Promise(resolve => setTimeout(resolve, 1000));
  355. window.location.href = apiBasePath + "/assets/" + parentAssetId;
  356. } else {
  357. const errorData = await response.json();
  358. let errorMessage = response.statusText; // Default to statusText
  359. const messageUnitError = errorData?.message?.json?.unit?.[0];
  360. if (messageUnitError) {
  361. errorMessage = messageUnitError;
  362. } else if (errorData?.message) {
  363. errorMessage = errorData.message;
  364. } else if (errorData?.error) {
  365. errorMessage = errorData.error;
  366. }
  367. showToast("Error: " + errorMessage, "error");
  368. }
  369. }
  370. </script>
  371. {% block leftsidepanel %} {{ super() }} {% endblock %}
  372. {% block sensorChartSetup %} {{ super() }} {% endblock %}
  373. {% endblock %}