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/README.md +16 -8
- package/examples/automations.yaml +49 -44
- package/lib/engine.js +233 -20
- package/lib/geo.js +17 -9
- package/lib/git.js +33 -1
- package/lib/runtime.js +215 -21
- package/lib/store.js +42 -1
- package/lib/yaml.js +85 -3
- package/package.json +2 -1
- package/plugin/index.js +205 -52
- package/public/app.js +493 -72
- package/public/icon.png +0 -0
- package/public/index.html +48 -21
- package/public/style.css +195 -20
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: '
|
|
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
|
|
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: '
|
|
18
|
-
headers:
|
|
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
|
|
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, '&')
|
|
144
|
+
.replace(/</g, '<')
|
|
145
|
+
.replace(/>/g, '>')
|
|
146
|
+
.replace(/"/g, '"')
|
|
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 =
|
|
242
|
+
const sha = liveSha()
|
|
47
243
|
el('shaLine').textContent =
|
|
48
244
|
(status.started ? 'Running' : 'Not started') +
|
|
49
|
-
' · live ' + sha +
|
|
50
|
-
(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="
|
|
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
|
-
'<
|
|
68
|
-
(a.enabled ? '
|
|
69
|
-
|
|
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
|
|
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="
|
|
81
|
-
'<div
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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('
|
|
89
|
-
|
|
90
|
-
|
|
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="
|
|
93
|
-
'<div
|
|
94
|
-
'<div
|
|
95
|
-
|
|
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">
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
+
})
|
package/public/icon.png
ADDED
|
Binary file
|