signalk-automation-plugin 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -2,27 +2,75 @@ const PLUGIN = 'signalk-automation-plugin'
2
2
  const API_READ = '/signalk/v1/api/' + PLUGIN
3
3
  const API_WRITE = '/plugins/' + PLUGIN
4
4
 
5
+ let authToken = sessionStorage.getItem('skAuthToken') || ''
6
+ let loggedIn = false
7
+ let authRequired = true
8
+ let loginUser = ''
9
+
10
+ function authHeaders (extra) {
11
+ const headers = Object.assign({ 'Content-Type': 'application/json' }, extra || {})
12
+ if (authToken) headers.Authorization = 'Bearer ' + authToken
13
+ return headers
14
+ }
15
+
16
+ function httpError (res, data, url) {
17
+ if (res.status === 401) {
18
+ loggedIn = false
19
+ authToken = ''
20
+ sessionStorage.removeItem('skAuthToken')
21
+ renderLogin()
22
+ return new Error('Log in to Signal K to Run, Reload, or change automations')
23
+ }
24
+ return new Error((data && (data.error || data.message)) || res.status + ' ' + url)
25
+ }
26
+
5
27
  async function getJson (url) {
6
- const res = await fetch(url, { credentials: 'same-origin' })
28
+ const res = await fetch(url, { credentials: 'include', headers: authHeaders() })
7
29
  const text = await res.text()
8
30
  let data = {}
9
31
  try { data = text ? JSON.parse(text) : {} } catch (_) { data = { error: text } }
10
- if (!res.ok) throw new Error(data.error || res.status + ' ' + url)
32
+ if (!res.ok) throw httpError(res, data, url)
11
33
  return data
12
34
  }
13
35
 
14
36
  async function sendJson (url, method, body) {
15
37
  const res = await fetch(url, {
16
38
  method,
17
- credentials: 'same-origin',
18
- headers: { 'Content-Type': 'application/json' },
39
+ credentials: 'include',
40
+ headers: authHeaders(),
19
41
  body: JSON.stringify(body || {})
20
42
  })
21
43
  const data = await res.json().catch(() => ({}))
22
- if (!res.ok) throw new Error(data.error || res.status + ' ' + url)
44
+ if (!res.ok) throw httpError(res, data, url)
23
45
  return data
24
46
  }
25
47
 
48
+ function renderLogin () {
49
+ const form = el('loginForm')
50
+ const who = el('loginWho')
51
+ const need = authRequired && !loggedIn
52
+ form.hidden = !need
53
+ who.hidden = !loggedIn
54
+ if (loggedIn) who.textContent = loginUser ? 'Signed in as ' + loginUser : 'Signed in'
55
+ }
56
+
57
+ async function checkLogin () {
58
+ try {
59
+ const res = await fetch('/skServer/loginStatus', {
60
+ credentials: 'include',
61
+ headers: authHeaders()
62
+ })
63
+ const data = await res.json().catch(() => ({}))
64
+ authRequired = data.authenticationRequired !== false
65
+ loggedIn = data.status === 'loggedIn'
66
+ loginUser = data.username || ''
67
+ } catch (_) {
68
+ authRequired = true
69
+ loggedIn = false
70
+ }
71
+ renderLogin()
72
+ }
73
+
26
74
  function el (id) { return document.getElementById(id) }
27
75
 
28
76
  function badge (result) {
@@ -30,24 +78,172 @@ function badge (result) {
30
78
  return '<span class="badge ' + (result || '') + '">' + r + '</span>'
31
79
  }
32
80
 
81
+ function fmtVal (v) {
82
+ if (v === undefined) return 'unset'
83
+ try { return JSON.stringify(v) } catch (_) { return String(v) }
84
+ }
85
+
86
+ function fmtActions (t) {
87
+ return (t.actions || []).map((a) => {
88
+ if (a.type === 'put') {
89
+ const prev = a.previous !== undefined ? fmtVal(a.previous) + ' → ' : ''
90
+ return 'PUT ' + a.path + ' ' + prev + fmtVal(a.value)
91
+ }
92
+ if (a.type === 'helper') return 'helper ' + a.helper + ' = ' + fmtVal(a.value)
93
+ if (a.type === 'notify') return 'notify ' + (a.path || '') + ' ' + (a.state || '')
94
+ if (a.type === 'delay') return a.skipped ? 'delay skipped (' + a.ms + ' ms)' : 'delay ' + a.ms + ' ms'
95
+ if (a.type === 'run') return 'run ' + (a.file || '')
96
+ return a.type || 'action'
97
+ }).join('\n')
98
+ }
99
+
100
+ function fmtClause (c) {
101
+ if (!c || typeof c !== 'object') return ''
102
+ const label = c.label || c.path || c.expected || ''
103
+ const mark = c.error ? '✗' : c.pass ? '✓' : '✗'
104
+ return (
105
+ '<div class="clause ' + (c.pass && !c.error ? 'pass' : 'fail') + '">' +
106
+ mark + ' ' + escapeHtml(String(label)) +
107
+ (c.error ? ' (' + escapeHtml(c.error) + ')' : '') +
108
+ '</div>'
109
+ )
110
+ }
111
+
112
+ function fmtRunRecord (t) {
113
+ if (!t) return '<p class="hint">No run yet.</p>'
114
+ if (t.verboseLog) {
115
+ return (
116
+ '<div class="card run-card">' +
117
+ badge(t.result) + ' <span class="muted">' + fmtTime(t.ts) + (t.manual ? ' · manual' : '') + '</span>' +
118
+ '<pre class="run-path">' + escapeHtml(t.verboseLog) + '</pre>' +
119
+ (fmtActions(t) ? '<pre class="run-actions">' + escapeHtml(fmtActions(t)) + '</pre>' : '') +
120
+ '</div>'
121
+ )
122
+ }
123
+ const clauses = [].concat(t.triggerResults || [], t.conditions || []).map(fmtClause).join('')
124
+ return (
125
+ '<div class="card run-card">' +
126
+ badge(t.result) + ' <span class="muted">' + fmtTime(t.ts) + (t.manual ? ' · manual' : '') + '</span>' +
127
+ (t.firedBy ? '<div class="clause">trigger: ' + escapeHtml(t.firedBy) + '</div>' : '') +
128
+ (t.choose ? '<div>choose: ' + escapeHtml(t.choose) + '</div>' : '') +
129
+ (t.reason ? '<div class="muted">' + escapeHtml(t.reason) + '</div>' : '') +
130
+ clauses +
131
+ (fmtActions(t) ? '<pre class="run-actions">' + escapeHtml(fmtActions(t)) + '</pre>' : '') +
132
+ '</div>'
133
+ )
134
+ }
135
+
33
136
  function fmtTime (iso) {
34
137
  if (!iso) return ''
35
138
  try { return new Date(iso).toLocaleString() } catch (_) { return iso }
36
139
  }
37
140
 
141
+ function escapeHtml (s) {
142
+ return String(s == null ? '' : s)
143
+ .replace(/&/g, '&amp;')
144
+ .replace(/</g, '&lt;')
145
+ .replace(/>/g, '&gt;')
146
+ .replace(/"/g, '&quot;')
147
+ }
148
+
149
+ function highlightYaml (text) {
150
+ return String(text || '').split('\n').map((line) => {
151
+ if (/^\s*#/.test(line) || /^\s*$/.test(line)) {
152
+ return '<span class="tok-cmt">' + escapeHtml(line) + '</span>'
153
+ }
154
+ const m = line.match(/^(\s*)(- )?(("[^"]+"|'[^']+'|[A-Za-z0-9_.-]+):)(\s*)(.*)$/)
155
+ if (!m) return escapeHtml(line)
156
+ const value = m[6]
157
+ let lit = escapeHtml(value)
158
+ if (/^(true|false|null)$/.test(value)) lit = '<span class="tok-bool">' + lit + '</span>'
159
+ else if (/^-?\d+(\.\d+)?$/.test(value)) lit = '<span class="tok-num">' + lit + '</span>'
160
+ else if (value) lit = '<span class="tok-str">' + lit + '</span>'
161
+ return (
162
+ escapeHtml(m[1]) +
163
+ escapeHtml(m[2] || '') +
164
+ '<span class="tok-key">' + escapeHtml(m[3].replace(/:$/, '')) + '</span>:' +
165
+ escapeHtml(m[5]) +
166
+ lit
167
+ )
168
+ }).join('\n')
169
+ }
170
+
171
+ function highlightDiff (text) {
172
+ if (!text) return '<span class="tok-cmt">(no differences)</span>'
173
+ return text.split('\n').map((line) => {
174
+ const esc = escapeHtml(line)
175
+ if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
176
+ return '<span class="diff-meta">' + esc + '</span>'
177
+ }
178
+ if (line.startsWith('@@')) return '<span class="diff-hunk">' + esc + '</span>'
179
+ if (line.startsWith('+')) return '<span class="diff-add">' + esc + '</span>'
180
+ if (line.startsWith('-')) return '<span class="diff-del">' + esc + '</span>'
181
+ return esc
182
+ }).join('\n')
183
+ }
184
+
38
185
  let status = null
186
+ let selected = null
187
+ let pickedSha = ''
188
+ let lastSeenRuns = ''
189
+ let writesInFlight = 0
190
+ const POLL_MS = 1500
191
+
192
+ function runFingerprint (snap) {
193
+ return (snap.automations || []).map((a) => {
194
+ const last = a.lastRun || {}
195
+ return a.id + '\t' + (last.ts || '') + '\t' + (last.result || '')
196
+ }).join('\n')
197
+ }
198
+
199
+ function liveSha () {
200
+ return status.activeSha || status.headSha || ''
201
+ }
39
202
 
40
203
  async function load () {
41
204
  status = await getJson(API_READ + '/status')
205
+ lastSeenRuns = runFingerprint(status)
206
+ if (!selected) {
207
+ const first = (status.automations || [])[0]
208
+ if (first) selected = { kind: 'automation', id: first.id }
209
+ }
42
210
  render()
211
+ if (selected && selected.kind === 'automation') {
212
+ const a = (status.automations || []).find((x) => x.id === selected.id)
213
+ await showRunLog(selected.id, a ? a.alias : selected.id).catch(() => {})
214
+ } else {
215
+ el('runLog').hidden = true
216
+ }
217
+ }
218
+
219
+ async function refreshIfRan () {
220
+ if (writesInFlight || document.hidden || !status) return
221
+ try {
222
+ const next = await getJson(API_READ + '/status')
223
+ const fp = runFingerprint(next)
224
+ if (fp === lastSeenRuns) return
225
+ const prevTs =
226
+ selected && selected.kind === 'automation'
227
+ ? (((status.automations || []).find((a) => a.id === selected.id) || {}).lastRun || {}).ts
228
+ : null
229
+ lastSeenRuns = fp
230
+ status = next
231
+ render()
232
+ if (selected && selected.kind === 'automation') {
233
+ const a = (status.automations || []).find((x) => x.id === selected.id)
234
+ await showRunLog(selected.id, a ? a.alias : selected.id).catch(() => {})
235
+ const ts = a && a.lastRun && a.lastRun.ts
236
+ if (ts && ts !== prevTs) el('runLog').scrollIntoView({ block: 'nearest' })
237
+ }
238
+ } catch (_) {}
43
239
  }
44
240
 
45
241
  function render () {
46
- const sha = status.activeSha ? status.activeSha.slice(0, 8) : 'working tree / empty'
242
+ const sha = liveSha()
47
243
  el('shaLine').textContent =
48
244
  (status.started ? 'Running' : 'Not started') +
49
- ' · live ' + sha +
50
- (status.automationsDir ? ' · repo ' + status.automationsDir : '')
245
+ ' · live ' + (sha ? sha.slice(0, 8) : 'working tree') +
246
+ (status.automationsDir ? ' · ' + status.automationsDir.split('/').pop() : '')
51
247
  if (status.errors && status.errors.length) {
52
248
  el('shaLine').innerHTML +=
53
249
  '<div class="error">' +
@@ -57,99 +253,324 @@ function render () {
57
253
 
58
254
  el('autos').innerHTML = (status.automations || []).map((a) => {
59
255
  const last = a.lastRun || {}
256
+ const on = selected && selected.kind === 'automation' && selected.id === a.id
60
257
  return (
61
- '<div class="card">' +
258
+ '<div class="item' + (on ? ' selected' : '') + '" data-select="automation:' + escapeHtml(a.id) + '" tabindex="0">' +
62
259
  '<div class="row">' +
63
260
  '<div><div class="name">' + escapeHtml(a.alias) + '</div>' +
64
- '<div class="muted">' + escapeHtml(a.id) + ' · ' + fmtTime(last.ts) + '</div></div>' +
261
+ '<div class="muted">' + escapeHtml(a.id) + (last.ts ? ' · ' + fmtTime(last.ts) : '') + '</div></div>' +
65
262
  '<div class="row">' +
66
263
  badge(last.result) +
67
- '<label class="muted"><input type="checkbox" data-toggle="' + escapeHtml(a.id) + '"' +
68
- (a.enabled ? ' checked' : '') + '> on</label>' +
69
- '<button data-traces="' + escapeHtml(a.id) + '" data-alias="' + escapeHtml(a.alias) + '">runs</button>' +
264
+ '<div class="auto-switches" onclick="event.stopPropagation()">' +
265
+ '<label class="pwr' + (a.enabled ? ' is-on' : '') + '">' +
266
+ '<input type="checkbox" data-toggle="' + escapeHtml(a.id) + '"' +
267
+ (a.enabled ? ' checked' : '') + '>' +
268
+ '<span class="pwr-ui"></span>' +
269
+ '<span class="pwr-label">' + (a.enabled ? 'On' : 'Off') + '</span>' +
270
+ '</label>' +
271
+ '<label class="pwr pwr-verbose' + (a.verbose ? ' is-on' : '') + '">' +
272
+ '<input type="checkbox" data-verbose="' + escapeHtml(a.id) + '"' +
273
+ (a.verbose ? ' checked' : '') + '>' +
274
+ '<span class="pwr-ui"></span>' +
275
+ '<span class="pwr-label">Verbose</span>' +
276
+ '</label>' +
277
+ '</div>' +
70
278
  '</div>' +
71
279
  '</div>' +
72
280
  '</div>'
73
281
  )
74
- }).join('') || '<p class="hint">No automations loaded. Set automationsDir and activate a commit.</p>'
282
+ }).join('') || '<p class="hint">No automations loaded.</p>'
75
283
 
76
284
  el('helpers').innerHTML = (status.helpers || []).map((h) => {
285
+ const on = selected && selected.kind === 'helper' && selected.id === h.id
77
286
  const mode = h.on_start === 'restore' ? 'survive reboot' : h.on_start === 'default' ? 'default on start' : 'nothing'
78
287
  const val = h.set ? JSON.stringify(h.value) : 'unset'
79
288
  return (
80
- '<div class="card"><div class="row">' +
81
- '<div><div class="name">' + escapeHtml(h.name) + '</div>' +
82
- '<div class="muted">' + escapeHtml(h.id) + ' · ' + mode + ' · ' + escapeHtml(val) + '</div></div>' +
83
- '<button data-reset="' + escapeHtml(h.id) + '">reset default</button>' +
84
- '</div></div>'
289
+ '<div class="item' + (on ? ' selected' : '') + '" data-select="helper:' + escapeHtml(h.id) + '" tabindex="0">' +
290
+ '<div class="row">' +
291
+ '<div><div class="name">' + escapeHtml(h.name) + '</div>' +
292
+ '<div class="muted">' + escapeHtml(h.id) + ' · ' + mode + ' · ' + escapeHtml(val) + '</div></div>' +
293
+ '<button type="button" data-reset="' + escapeHtml(h.id) + '">reset</button>' +
294
+ '</div>' +
295
+ '</div>'
85
296
  )
86
297
  }).join('') || '<p class="hint">No helpers in live YAML.</p>'
87
298
 
88
- el('yaml').textContent = status.yaml || '# no live YAML'
89
- el('commits').innerHTML = (status.commits || []).map((c) => {
90
- const live = status.activeSha && c.sha === status.activeSha
299
+ el('zones').innerHTML = (status.zones || []).map((z) => {
300
+ const on = selected && selected.kind === 'zone' && selected.id === z.id
301
+ return (
302
+ '<div class="item' + (on ? ' selected' : '') + '" data-select="zone:' + escapeHtml(z.id) + '" tabindex="0">' +
303
+ '<div class="name">' + escapeHtml(z.name || z.id) + '</div>' +
304
+ '<div class="muted">' + escapeHtml(z.id) + ' · ' + Number(z.lat).toFixed(5) + ', ' +
305
+ Number(z.lon).toFixed(5) + ' · r=' + escapeHtml(String(z.radius)) + ' m</div>' +
306
+ '</div>'
307
+ )
308
+ }).join('') || '<p class="hint">No zones in live YAML.</p>'
309
+
310
+ el('switches').innerHTML = (status.switches || []).slice(0, 20).map((s) => {
311
+ const who = [s.automation, s.branch].filter(Boolean).join(' / ')
312
+ const kind = s.kind === 'helper' ? 'helper ' + s.path : 'PUT ' + s.path
313
+ const line = kind + ' ' + fmtVal(s.from) + ' → ' + fmtVal(s.to)
91
314
  return (
92
- '<div class="card"><div class="row">' +
93
- '<div><div class="name">' + escapeHtml(c.subject || '') + '</div>' +
94
- '<div class="muted">' + escapeHtml(c.sha.slice(0, 10)) + ' · ' + escapeHtml(c.date || '') +
95
- (live ? ' · live' : '') + '</div></div>' +
96
- (live ? '' : '<button class="primary" data-sha="' + escapeHtml(c.sha) + '">make live</button>') +
97
- '</div></div>'
315
+ '<div class="switch-line">' +
316
+ '<div class="muted">' + fmtTime(s.ts) + (who ? ' · ' + escapeHtml(who) : '') + '</div>' +
317
+ '<div>' + escapeHtml(line) + '</div>' +
318
+ '</div>'
98
319
  )
99
- }).join('') || '<p class="hint">automationsDir is not a git repo (or git is missing in the container).</p>'
320
+ }).join('') || '<p class="hint">No PUTs or helper changes yet.</p>'
100
321
 
322
+ const commits = status.commits || []
323
+ el('gitBar').hidden = commits.length === 0
324
+ if (commits.length) {
325
+ if (!pickedSha || !commits.some((c) => c.sha === pickedSha)) pickedSha = liveSha() || commits[0].sha
326
+ el('commitSelect').innerHTML = commits.map((c) => {
327
+ const live = c.sha === liveSha()
328
+ const label = (c.subject || c.sha.slice(0, 7)) + (live ? ' (live)' : '')
329
+ return '<option value="' + escapeHtml(c.sha) + '"' + (c.sha === pickedSha ? ' selected' : '') + '>' +
330
+ escapeHtml(label) + '</option>'
331
+ }).join('')
332
+ }
333
+
334
+ renderYaml()
101
335
  bind()
102
336
  }
103
337
 
338
+ function renderYaml () {
339
+ let title = 'YAML'
340
+ let body = '# select an automation or helper'
341
+ if (selected && selected.kind === 'automation') {
342
+ const a = (status.automations || []).find((x) => x.id === selected.id)
343
+ title = a ? a.alias : selected.id
344
+ body = (a && a.yaml) || '# not found'
345
+ } else if (selected && selected.kind === 'zone') {
346
+ const z = (status.zones || []).find((x) => x.id === selected.id)
347
+ title = z ? (z.name || z.id) : selected.id
348
+ body = (z && z.yaml) || '# not found'
349
+ } else if (selected && selected.kind === 'helper') {
350
+ const h = (status.helpers || []).find((x) => x.id === selected.id)
351
+ title = h ? h.name : selected.id
352
+ body = (h && h.yaml) || '# not found'
353
+ }
354
+ el('yamlTitle').textContent = title
355
+ el('yaml').innerHTML = highlightYaml(body)
356
+ const run = el('runAuto')
357
+ run.hidden = !(selected && selected.kind === 'automation')
358
+ run.disabled = false
359
+ }
360
+
361
+ async function showRunLog (id, alias, latest) {
362
+ const box = el('runLog')
363
+ box.hidden = false
364
+ el('runLogTitle').textContent = 'Run — ' + alias
365
+ if (latest) {
366
+ el('runLogBody').innerHTML = fmtRunRecord(latest)
367
+ return
368
+ }
369
+ const data = await getJson(API_READ + '/automations/' + encodeURIComponent(id) + '/traces')
370
+ const traces = data.traces || []
371
+ el('runLogBody').innerHTML = traces.length
372
+ ? traces.slice(0, 8).map(fmtRunRecord).join('')
373
+ : '<p class="hint">No run yet. Press Run to execute this automation now.</p>'
374
+ }
375
+
376
+ async function loadDiff (sha) {
377
+ pickedSha = sha
378
+ const box = el('gitDiff')
379
+ if (!sha || sha === liveSha()) {
380
+ box.hidden = true
381
+ return
382
+ }
383
+ try {
384
+ const data = await getJson(API_READ + '/diff?sha=' + encodeURIComponent(sha))
385
+ box.hidden = false
386
+ el('diffView').innerHTML = highlightDiff(data.diff)
387
+ el('diffHint').textContent = data.current
388
+ ? 'This commit is already live.'
389
+ : 'Live ' + (data.from ? data.from.slice(0, 8) : 'HEAD') + ' → ' + data.to.slice(0, 8)
390
+ el('resetGit').disabled = !!data.current
391
+ el('resetGit').setAttribute('data-sha', sha)
392
+ } catch (err) {
393
+ box.hidden = false
394
+ el('diffView').textContent = err.message
395
+ el('resetGit').disabled = true
396
+ }
397
+ }
398
+
399
+ function showWriteError (err) {
400
+ el('shaLine').innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'
401
+ }
402
+
403
+ async function selectItem (btn) {
404
+ const raw = btn.getAttribute('data-select')
405
+ const i = raw.indexOf(':')
406
+ selected = { kind: raw.slice(0, i), id: raw.slice(i + 1) }
407
+ document.querySelectorAll('.item.selected').forEach((n) => n.classList.remove('selected'))
408
+ btn.classList.add('selected')
409
+ renderYaml()
410
+ if (selected.kind === 'automation') {
411
+ const a = (status.automations || []).find((x) => x.id === selected.id)
412
+ await showRunLog(selected.id, a ? a.alias : selected.id).catch(() => {})
413
+ } else {
414
+ el('runLog').hidden = true
415
+ }
416
+ }
417
+
104
418
  function bind () {
105
- document.querySelectorAll('[data-toggle]').forEach((box) => {
106
- box.addEventListener('change', async () => {
107
- await sendJson(API_WRITE + '/automations/' + box.getAttribute('data-toggle') + '/enabled', 'POST', {
108
- enabled: box.checked
109
- })
110
- await load()
111
- })
112
- })
113
- document.querySelectorAll('[data-traces]').forEach((btn) => {
114
- btn.addEventListener('click', async () => {
115
- const id = btn.getAttribute('data-traces')
116
- const data = await getJson(API_READ + '/automations/' + encodeURIComponent(id) + '/traces')
117
- el('detail').hidden = false
118
- el('detailTitle').textContent = 'Runs — ' + btn.getAttribute('data-alias')
119
- el('traces').innerHTML = (data.traces || []).map((t) => {
120
- return (
121
- '<div class="card">' +
122
- badge(t.result) + ' <span class="muted">' + fmtTime(t.ts) + '</span>' +
123
- (t.reason ? '<div class="muted">' + escapeHtml(t.reason) + '</div>' : '') +
124
- '<pre class="trace">' + escapeHtml(JSON.stringify(t, null, 2)) + '</pre>' +
125
- '</div>'
126
- )
127
- }).join('') || '<p class="hint">No runs yet.</p>'
128
- el('detail').scrollIntoView({ behavior: 'smooth' })
129
- })
130
- })
131
- document.querySelectorAll('[data-sha]').forEach((btn) => {
132
- btn.addEventListener('click', async () => {
133
- await sendJson(API_WRITE + '/live', 'POST', { sha: btn.getAttribute('data-sha') })
419
+ el('commitSelect').onchange = () => loadDiff(el('commitSelect').value)
420
+ el('resetGit').onclick = async () => {
421
+ const sha = el('resetGit').getAttribute('data-sha')
422
+ if (!sha) return
423
+ el('resetGit').disabled = true
424
+ writesInFlight += 1
425
+ try {
426
+ await sendJson(API_WRITE + '/live', 'POST', { sha })
427
+ pickedSha = sha
134
428
  await load()
135
- })
429
+ el('gitDiff').hidden = true
430
+ } catch (err) {
431
+ showWriteError(err)
432
+ el('resetGit').disabled = false
433
+ } finally {
434
+ writesInFlight -= 1
435
+ }
436
+ }
437
+ if (bind.once) return
438
+ bind.once = true
439
+ document.querySelector('aside').addEventListener('click', async (ev) => {
440
+ const reset = ev.target.closest('[data-reset]')
441
+ if (reset) {
442
+ ev.stopPropagation()
443
+ writesInFlight += 1
444
+ try {
445
+ await sendJson(API_WRITE + '/helpers/' + reset.getAttribute('data-reset') + '/reset', 'POST', {})
446
+ await load()
447
+ } catch (err) {
448
+ showWriteError(err)
449
+ } finally {
450
+ writesInFlight -= 1
451
+ }
452
+ return
453
+ }
454
+ if (ev.target.closest('[data-toggle]') || ev.target.closest('[data-verbose]')) return
455
+ const btn = ev.target.closest('[data-select]')
456
+ if (btn) await selectItem(btn)
136
457
  })
137
- document.querySelectorAll('[data-reset]').forEach((btn) => {
138
- btn.addEventListener('click', async () => {
139
- await sendJson(API_WRITE + '/helpers/' + btn.getAttribute('data-reset') + '/reset', 'POST', {})
458
+ document.querySelector('aside').addEventListener('change', async (ev) => {
459
+ const verbose = ev.target.closest('[data-verbose]')
460
+ if (verbose) {
461
+ const wanted = verbose.checked
462
+ writesInFlight += 1
463
+ try {
464
+ await sendJson(API_WRITE + '/automations/' + verbose.getAttribute('data-verbose') + '/verbose', 'POST', {
465
+ verbose: wanted
466
+ })
467
+ await load()
468
+ } catch (err) {
469
+ verbose.checked = !wanted
470
+ showWriteError(err)
471
+ } finally {
472
+ writesInFlight -= 1
473
+ }
474
+ return
475
+ }
476
+ const box = ev.target.closest('[data-toggle]')
477
+ if (!box) return
478
+ const wanted = box.checked
479
+ writesInFlight += 1
480
+ try {
481
+ await sendJson(API_WRITE + '/automations/' + box.getAttribute('data-toggle') + '/enabled', 'POST', {
482
+ enabled: wanted
483
+ })
140
484
  await load()
141
- })
485
+ } catch (err) {
486
+ box.checked = !wanted
487
+ showWriteError(err)
488
+ } finally {
489
+ writesInFlight -= 1
490
+ }
142
491
  })
143
492
  }
144
493
 
145
- function escapeHtml (s) {
146
- return String(s == null ? '' : s)
147
- .replace(/&/g, '&amp;')
148
- .replace(/</g, '&lt;')
149
- .replace(/>/g, '&gt;')
150
- .replace(/"/g, '&quot;')
494
+ el('reloadAutos').onclick = async () => {
495
+ const btn = el('reloadAutos')
496
+ btn.disabled = true
497
+ writesInFlight += 1
498
+ try {
499
+ await sendJson(API_WRITE + '/reload', 'POST', {})
500
+ await load()
501
+ el('gitDiff').hidden = true
502
+ } catch (err) {
503
+ el('shaLine').innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'
504
+ } finally {
505
+ writesInFlight -= 1
506
+ btn.disabled = false
507
+ }
151
508
  }
152
509
 
153
- load().catch((err) => {
154
- el('shaLine').innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'
155
- })
510
+ el('runAuto').onclick = async () => {
511
+ if (!selected || selected.kind !== 'automation') return
512
+ const btn = el('runAuto')
513
+ btn.disabled = true
514
+ writesInFlight += 1
515
+ try {
516
+ const record = await sendJson(
517
+ API_WRITE + '/automations/' + encodeURIComponent(selected.id) + '/run',
518
+ 'POST',
519
+ {}
520
+ )
521
+ const a = (status.automations || []).find((x) => x.id === selected.id)
522
+ await showRunLog(selected.id, a ? a.alias : selected.id, record)
523
+ el('runLog').scrollIntoView({ block: 'nearest' })
524
+ await load()
525
+ await showRunLog(selected.id, a ? a.alias : selected.id).catch(() => {})
526
+ } catch (err) {
527
+ const msg = escapeHtml(err.message)
528
+ el('shaLine').innerHTML = '<span class="error">' + msg + '</span>'
529
+ el('runLog').hidden = false
530
+ el('runLogBody').innerHTML = '<p class="error">' + msg + '</p>'
531
+ } finally {
532
+ writesInFlight -= 1
533
+ btn.disabled = false
534
+ }
535
+ }
536
+
537
+ el('loginForm').onsubmit = async (ev) => {
538
+ ev.preventDefault()
539
+ const errEl = el('loginErr')
540
+ errEl.textContent = ''
541
+ const username = el('loginUser').value
542
+ const password = el('loginPass').value
543
+ try {
544
+ const res = await fetch('/signalk/v1/auth/login', {
545
+ method: 'POST',
546
+ credentials: 'include',
547
+ headers: { 'Content-Type': 'application/json' },
548
+ body: JSON.stringify({ username, password, rememberMe: true })
549
+ })
550
+ const data = await res.json().catch(() => ({}))
551
+ if (!res.ok) {
552
+ errEl.textContent = data.message || data.error || 'Login failed'
553
+ return
554
+ }
555
+ authToken = data.token || ''
556
+ if (authToken) sessionStorage.setItem('skAuthToken', authToken)
557
+ el('loginPass').value = ''
558
+ loggedIn = true
559
+ loginUser = username
560
+ renderLogin()
561
+ } catch (err) {
562
+ errEl.textContent = err.message || 'Login failed'
563
+ }
564
+ }
565
+
566
+ checkLogin()
567
+ .then(() => load())
568
+ .then(() => {
569
+ setInterval(refreshIfRan, POLL_MS)
570
+ document.addEventListener('visibilitychange', () => {
571
+ if (!document.hidden) refreshIfRan()
572
+ })
573
+ })
574
+ .catch((err) => {
575
+ el('shaLine').innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'
576
+ })
Binary file