main.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. // NOTE: API calls must start with 'api/' in order to serve the app at any URI
  2. 'use strict';
  3. import { h, render, useState, useEffect, useRef, html, Router } from './bundle.js';
  4. import { Icons, Login, Setting, Button, Stat, tipColors, Colored, Notification, Pagination, UploadFileButton } from './components.js';
  5. const Logo = props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12.87 12.85"><defs><style>.ll-cls-1{fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:0.5px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="ll-cls-1" d="M12.62,1.82V8.91A1.58,1.58,0,0,1,11,10.48H4a1.44,1.44,0,0,1-1-.37A.69.69,0,0,1,2.84,10l-.1-.12a.81.81,0,0,1-.15-.48V5.57a.87.87,0,0,1,.86-.86H4.73V7.28a.86.86,0,0,0,.86.85H9.42a.85.85,0,0,0,.85-.85V3.45A.86.86,0,0,0,10.13,3,.76.76,0,0,0,10,2.84a.29.29,0,0,0-.12-.1,1.49,1.49,0,0,0-1-.37H2.39V1.82A1.57,1.57,0,0,1,4,.25H11A1.57,1.57,0,0,1,12.62,1.82Z"/><path class="ll-cls-1" d="M10.48,10.48V11A1.58,1.58,0,0,1,8.9,12.6H1.82A1.57,1.57,0,0,1,.25,11V3.94A1.57,1.57,0,0,1,1.82,2.37H8.9a1.49,1.49,0,0,1,1,.37l.12.1a.76.76,0,0,1,.11.14.86.86,0,0,1,.14.47V7.28a.85.85,0,0,1-.85.85H8.13V5.57a.86.86,0,0,0-.85-.86H3.45a.87.87,0,0,0-.86.86V9.4a.81.81,0,0,0,.15.48l.1.12a.69.69,0,0,0,.13.11,1.44,1.44,0,0,0,1,.37Z"/></g></g></svg>`;
  6. function Header({logout, user, setShowSidebar, showSidebar}) {
  7. return html`
  8. <div class="bg-white sticky top-0 z-[48] xw-full border-b py-2 ${showSidebar && 'pl-72'} transition-all duration-300 transform">
  9. <div class="px-2 w-full py-0 my-0 flex items-center">
  10. <button type="button" onclick=${ev => setShowSidebar(v => !v)} class="text-slate-400">
  11. <${Icons.bars3} class="h-6" />
  12. <//>
  13. <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
  14. <div class="relative flex flex-1"><//>
  15. <div class="flex items-center gap-x-4 lg:gap-x-6">
  16. <span class="text-sm text-slate-400">logged in as: ${user}<//>
  17. <div class="hidden lg:block lg:h-4 lg:w-px lg:bg-gray-200" aria-hidden="true"><//>
  18. <${Button} title="Logout" icon=${Icons.logout} onclick=${logout} />
  19. <//>
  20. <//>
  21. <//>
  22. <//>`;
  23. };
  24. function Sidebar({url, show}) {
  25. const NavLink = ({title, icon, href, url}) => html`
  26. <div>
  27. <a href="#${href}" class="${href == url ? 'bg-slate-50 text-blue-600 group' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50 group'} flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
  28. <${icon} class="w-6 h-6"/>
  29. ${title}
  30. <///>
  31. <//>`;
  32. return html`
  33. <div class="bg-violet-100 hs-overlay hs-overlay-open:translate-x-0
  34. -translate-x-full transition-all duration-300 transform
  35. fixed top-0 left-0 bottom-0 z-[60] w-72 bg-white border-r
  36. border-gray-200 overflow-y-auto scrollbar-y
  37. ${show && 'translate-x-0'} right-auto bottom-0">
  38. <div class="flex flex-col m-4 gap-y-6">
  39. <div class="flex h-10 shrink-0 items-center gap-x-4 font-bold text-xl text-slate-500">
  40. <${Logo} class="h-full"/> Your Brand
  41. <//>
  42. <div class="flex flex-1 flex-col">
  43. <${NavLink} title="Dashboard" icon=${Icons.home} href="/" url=${url} />
  44. <${NavLink} title="Settings" icon=${Icons.cog} href="/settings" url=${url} />
  45. <${NavLink} title="Firmware Update" icon=${Icons.download} href="/update" url=${url} />
  46. <${NavLink} title="Events" icon=${Icons.alert} href="/events" url=${url} />
  47. <//>
  48. <//>
  49. <//>`;
  50. };
  51. function Events({}) {
  52. const [events, setEvents] = useState([]);
  53. const [page, setPage] = useState(1);
  54. const refresh = () =>
  55. fetch('api/events/get', {
  56. method: 'POST', body: JSON.stringify({page: page}),
  57. }).then(r => r.json())
  58. .then(r => setEvents(r));
  59. useEffect(refresh, [page]);
  60. useEffect(() => {
  61. setPage(JSON.parse(localStorage.getItem('page')));
  62. }, []);
  63. useEffect(() => {
  64. localStorage.setItem('page', page.toString());
  65. }, [page]);
  66. const Th = props => html`<th scope="col" class="sticky top-0 z-10 border-b border-slate-300 bg-white bg-opacity-75 py-1.5 px-4 text-left text-sm font-semibold text-slate-900 backdrop-blur backdrop-filter">${props.title}</th>`;
  67. const Td = props => html`<td class="whitespace-nowrap border-b border-slate-200 py-2 px-4 pr-3 text-sm text-slate-900">${props.text}</td>`;
  68. const Prio = ({prio}) => {
  69. const text = ['high', 'medium', 'low'][prio];
  70. const colors = [tipColors.red, tipColors.yellow, tipColors.green][prio];
  71. return html`<${Colored} colors=${colors} text=${text} />`;
  72. };
  73. const Event = ({e}) => html`
  74. <tr>
  75. <${Td} text=${['power', 'hardware', 'tier3', 'tier4'][e.type]} />
  76. <${Td} text=${html`<${Prio} prio=${e.prio}/>`} />
  77. <${Td} text=${e.time ? (new Date(e.time * 1000)).toLocaleString() : '1970-01-01'} />
  78. <${Td} text=${e.text} />
  79. <//>`;
  80. return html`
  81. <div class="m-4 divide-y divide-gray-200 overflow-auto rounded bg-white">
  82. <div class="font-semibold flex items-center text-gray-600 px-3 justify-between whitespace-nowrap">
  83. <div class="font-semibold flex items-center text-gray-600">
  84. <div class="mr-4">EVENT LOG</div>
  85. </div>
  86. <${Pagination} currentPage=${page} setPageFn=${setPage} totalItems=400 itemsPerPage=20 />
  87. <//>
  88. <div class="inline-block min-w-full align-middle" style="max-height: 82vh; overflow: auto;">
  89. <table class="min-w-full border-separate border-spacing-0">
  90. <thead>
  91. <tr>
  92. <${Th} title="Type" />
  93. <${Th} title="Prio" />
  94. <${Th} title="Time" />
  95. <${Th} title="Description" />
  96. </tr>
  97. </thead>
  98. <tbody>
  99. ${(events.arr ? events.arr : []).map(e => h(Event, {e}))}
  100. </tbody>
  101. </table>
  102. <//>
  103. <//>`;
  104. };
  105. function Chart({data}) {
  106. const n = data.length /* entries */, w = 20 /* entry width */, ls = 15/* left space */;
  107. const h = 100 /* graph height */, yticks = 5 /* Y axis ticks */, bs = 10 /* bottom space */;
  108. const ymax = 25;
  109. const yt = i => (h - bs) / yticks * (i + 1);
  110. const bh = p => (h - bs) * p / 100; // Bar height
  111. const by = p => (h - bs) - bh(p);
  112. const range = (start, size, step) => Array.from({length: size}, (_, i) => i * (step || 1) + start);
  113. // console.log(ds);
  114. return html`
  115. <div class="my-4 divide-y divide-gray-200 overflow-auto rounded bg-white">
  116. <div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
  117. Temperature, last 24h
  118. <//>
  119. <div class="relative">
  120. <svg class="bg-yellow-x50 w-full p-4" viewBox="0 0 ${n*w+ls} ${h}">
  121. ${range(0, yticks).map(i => html`
  122. <line x1=0 y1=${yt(i)} x2=${ls+n*w} y2=${yt(i)} stroke-width=0.3 class="stroke-slate-300" stroke-dasharray="1,1" />
  123. <text x=0 y=${yt(i)-2} class="text-[6px] fill-slate-400">${ymax-ymax/yticks*(i+1)}<//>
  124. `)}
  125. ${range(0, n).map(x => html`
  126. <rect x=${ls+x*w} y=${by(data[x]*100/ymax)} width=12 height=${bh(data[x]*100/ymax)} rx=2 class="fill-cyan-500" />
  127. <text x=${ls+x*w} y=100 class="text-[6px] fill-slate-400">${x*2}:00<//>
  128. `)}
  129. <//>
  130. <//>
  131. <//>`;
  132. };
  133. function DeveloperNote({text, children}) {
  134. return html`
  135. <div class="flex p-4 gap-2">
  136. <div class="text-sm text-slate-500">
  137. <div class="flex items-center">
  138. <${Icons.info} class="self-start basis-[30px] grow-0 shrink-0 text-green-600 mr-2" />
  139. <div class="font-semibold">Developer Note<//>
  140. <//>
  141. ${(text || '').split('.').map(v => html` <p class="my-2 ">${v}<//>`)}
  142. ${children}
  143. <//>
  144. <//>`;
  145. };
  146. function Main({}) {
  147. const [stats, setStats] = useState(null);
  148. const refresh = () => fetch('api/stats/get').then(r => r.json()).then(r => setStats(r));
  149. useEffect(refresh, []);
  150. if (!stats) return '';
  151. return html`
  152. <div class="p-2">
  153. <div class="p-4 sm:p-2 mx-auto grid grid-cols-2 lg:grid-cols-4 gap-4">
  154. <${Stat} title="Temperature" text="${stats.temperature} °C" tipText="good" tipIcon=${Icons.ok} tipColors=${tipColors.green} />
  155. <${Stat} title="Humidity" text="${stats.humidity} %" tipText="warn" tipIcon=${Icons.warn} tipColors=${tipColors.yellow} />
  156. <div class="bg-white col-span-2 border rounded-md shadow-lg" role="alert">
  157. <${DeveloperNote} text="Stats data is received from the Mongoose backend" />
  158. <//>
  159. <//>
  160. <div class="p-4 sm:p-2 mx-auto grid grid-cols-1 lg:grid-cols-2 gap-4">
  161. <${Chart} data=${stats.points} />
  162. <div class="my-4 hx-24 bg-white border rounded-md shadow-lg" role="alert">
  163. <${DeveloperNote}
  164. text="This chart is an SVG image, generated on the fly from the
  165. data returned by the api/stats/get API call" />
  166. <//>
  167. <//>
  168. <//>`;
  169. };
  170. function FirmwareStatus({title, info, children}) {
  171. const state = ['UNAVAILABLE', 'FIRST_BOOT', 'NOT_COMMITTED', 'COMMITTED'][(info.status || 0) % 4];
  172. const valid = info.status > 0;
  173. return html`
  174. <div class="bg-white py-1 divide-y border rounded">
  175. <div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
  176. ${title}
  177. <//>
  178. <div class="px-4 py-2 relative">
  179. <div class="my-1">Status: ${state}<//>
  180. <div class="my-1">CRC32: ${valid ? info.crc32.toString(16) : 'n/a'}<//>
  181. <div class="my-1">Size: ${valid ? info.size : 'n/a'}<//>
  182. <div class="my-1">Flashed at: ${valid ? new Date(info.timestamp * 1000).toLocaleString() : 'n/a'}<//>
  183. ${children}
  184. <//>
  185. <//>`;
  186. };
  187. function FirmwareUpdate({}) {
  188. const [info, setInfo] = useState([{}, {}]);
  189. const refresh = () => fetch('api/firmware/status').then(r => r.json()).then(r => setInfo(r));
  190. useEffect(refresh, []);
  191. const oncommit = ev => fetch('api/firmware/commit')
  192. .then(r => r.json())
  193. .then(refresh);
  194. const onreboot = ev => fetch('api/device/reset')
  195. .then(r => r.json())
  196. .then(r => new Promise(r => setTimeout(ev => { refresh(); r(); }, 3000)));
  197. const onrollback = ev => fetch('api/firmware/rollback')
  198. .then(onreboot);
  199. const onerase = ev => fetch('api/device/eraselast').then(refresh);
  200. const onupload = function(ok, name, size) {
  201. if (!ok) return false;
  202. return new Promise(r => setTimeout(ev => { refresh(); r(); }, 3000));
  203. };
  204. return html`
  205. <div class="m-4 gap-4 grid grid-cols-1 lg:grid-cols-3">
  206. <${FirmwareStatus} title="Current firmware image" info=${info[0]}>
  207. <div class="flex flex-wrap gap-2">
  208. <${Button} title="Commit this firmware" onclick=${oncommit}
  209. icon=${Icons.thumbUp} disabled=${info[0].status == 3} cls="w-full" />
  210. <//>
  211. <//>
  212. <${FirmwareStatus} title="Previous firmware image" info=${info[1]}>
  213. <${Button} title="Rollback to this firmware" onclick=${onrollback}
  214. icon=${Icons.backward} disabled=${info[1].status == 0} cls="w-full" />
  215. <//>
  216. <div class="bg-white xm-4 divide-y border rounded flex flex-col">
  217. <div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
  218. Device control
  219. <//>
  220. <div class="px-4 py-3 flex flex-col gap-2 grow">
  221. <${UploadFileButton}
  222. title="Upload new firmware .bin file" onupload=${onupload}
  223. url="api/firmware/upload" accept=".bin,.uf2" />
  224. <div class="grow"><//>
  225. <${Button} title="Reboot device" onclick=${onreboot} icon=${Icons.refresh} cls="w-full" />
  226. <${Button} title="Erase last sector" onclick=${onerase} icon=${Icons.doc} cls="w-full hidden" />
  227. <//>
  228. <//>
  229. <//>
  230. <div class="m-4 gap-4 grid grid-cols-1 lg:grid-cols-2">
  231. <div class="bg-white border shadow-lg">
  232. <${DeveloperNote}>
  233. <div class="my-2">
  234. Firmware status and other information is stored in the last sector
  235. of flash
  236. <//>
  237. <div class="my-2">
  238. Firmware status can be FIRST_BOOT, UNCOMMITTED or COMMITTED. If no
  239. information is available, it is UNAVAILABLE.
  240. <//>
  241. <div class="my-2">
  242. This GUI loads a firmware file and sends it chunk by chunk to the
  243. device, passing current chunk offset, total firmware size and a file name:
  244. api/firmware/upload?offset=X&total=Y&name=Z
  245. <//>
  246. <//>
  247. <//>
  248. <div class="bg-white border shadow-lg">
  249. <${DeveloperNote}>
  250. <div>
  251. Firmware update mechanism defines 3 API functions that the target
  252. device must implement: mg_ota_begin(), mg_ota_write() and mg_ota_end()
  253. <//>
  254. <div class="my-2">
  255. RESTful API handlers use ota_xxx() API to save firmware to flash.
  256. The last 0-length chunk triggers ota_end() which performs firmware
  257. update using saved firmware image
  258. <//>
  259. <div class="my-2">
  260. <a class="link text-blue-600 underline"
  261. href="https://mongoose.ws/webinars/">Join our free webinar</a> to
  262. get detailed explanations about possible firmware updates strategies
  263. and implementation demo
  264. <//>
  265. <//>
  266. <//>
  267. <//>`;
  268. };
  269. function Settings({}) {
  270. const [settings, setSettings] = useState(null);
  271. const [saveResult, setSaveResult] = useState(null);
  272. const refresh = () => fetch('api/settings/get')
  273. .then(r => r.json())
  274. .then(r => setSettings(r));
  275. useEffect(refresh, []);
  276. const mksetfn = k => (v => setSettings(x => Object.assign({}, x, {[k]: v})));
  277. const onsave = ev => fetch('api/settings/set', {
  278. method: 'post', body: JSON.stringify(settings)
  279. }).then(r => r.json())
  280. .then(r => setSaveResult(r))
  281. .then(refresh);
  282. if (!settings) return '';
  283. const logOptions = [[0, 'Disable'], [1, 'Error'], [2, 'Info'], [3, 'Debug']];
  284. return html`
  285. <div class="m-4 grid grid-cols-1 gap-4 md:grid-cols-2">
  286. <div class="py-1 divide-y border rounded bg-white flex flex-col">
  287. <div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
  288. Device Settings
  289. <//>
  290. <div class="py-2 px-5 flex-1 flex flex-col relative">
  291. ${saveResult && html`<${Notification} ok=${saveResult.status}
  292. text=${saveResult.message} close=${() => setSaveResult(null)} />`}
  293. <${Setting} title="Enable Logs" value=${settings.log_enabled} setfn=${mksetfn('log_enabled')} type="switch" />
  294. <${Setting} title="Log Level" value=${settings.log_level} setfn=${mksetfn('log_level')} type="select" addonLeft="0-3" disabled=${!settings.log_enabled} options=${logOptions}/>
  295. <${Setting} title="Brightness" value=${settings.brightness} setfn=${mksetfn('brightness')} type="number" addonRight="%" />
  296. <${Setting} title="Device Name" value=${settings.device_name} setfn=${mksetfn('device_name')} type="" />
  297. <div class="mb-1 mt-3 flex place-content-end"><${Button} icon=${Icons.save} onclick=${onsave} title="Save Settings" /><//>
  298. <//>
  299. <//>
  300. <div class="bg-white border rounded-md text-ellipsis overflow-auto" role="alert">
  301. <${DeveloperNote}
  302. text="A variety of controls are pre-defined to ease the development:
  303. toggle button, dropdown select, input field with left and right
  304. addons. Device settings are received by calling
  305. api/settings/get API call, which returns settings JSON object.
  306. Clicking on the save button calls api/settings/set
  307. API call" />
  308. <//>
  309. <//>`;
  310. };
  311. const App = function({}) {
  312. const [loading, setLoading] = useState(true);
  313. const [url, setUrl] = useState('/');
  314. const [user, setUser] = useState('');
  315. const [showSidebar, setShowSidebar] = useState(true);
  316. const logout = () => fetch('api/logout').then(r => setUser(''));
  317. const login = r => !r.ok ? setLoading(false) && setUser(null) : r.json()
  318. .then(r => setUser(r.user))
  319. .finally(r => setLoading(false));
  320. useEffect(() => fetch('api/login').then(login), []);
  321. if (loading) return ''; // Show blank page on initial load
  322. if (!user) return html`<${Login} loginFn=${login} logoIcon=${Logo}
  323. title="Device Dashboard Login"
  324. tipText="To login, use: admin/admin, user1/user1, user2/user2" />`; // If not logged in, show login screen
  325. return html`
  326. <div class="min-h-screen bg-slate-100">
  327. <${Sidebar} url=${url} show=${showSidebar} />
  328. <${Header} logout=${logout} user=${user} showSidebar=${showSidebar} setShowSidebar=${setShowSidebar} />
  329. <div class="${showSidebar && 'pl-72'} transition-all duration-300 transform">
  330. <${Router} onChange=${ev => setUrl(ev.url)} history=${History.createHashHistory()} >
  331. <${Main} default=${true} />
  332. <${Settings} path="settings" />
  333. <${FirmwareUpdate} path="update" />
  334. <${Events} path="events" />
  335. <//>
  336. <//>
  337. <//>`;
  338. };
  339. window.onload = () => render(h(App), document.body);