@bluerobotics/bluevue 0.1.0 → 0.2.0
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 +97 -18
- package/bin/create-blueos-extension.mjs +72 -0
- package/dist/bluevue.js +2464 -778
- package/dist/components/BlueApp.vue.d.ts +50 -0
- package/dist/components/BlueBanner.vue.d.ts +49 -0
- package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
- package/dist/components/BlueButton.vue.d.ts +59 -0
- package/dist/components/BlueCard.vue.d.ts +45 -0
- package/dist/components/BlueCheckbox.vue.d.ts +34 -0
- package/dist/components/BlueChip.vue.d.ts +47 -0
- package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
- package/dist/components/BlueDialog.vue.d.ts +60 -0
- package/dist/components/BlueFileDrop.vue.d.ts +32 -0
- package/dist/components/BlueIcon.vue.d.ts +26 -0
- package/dist/components/BlueInput.vue.d.ts +1 -0
- package/dist/components/BlueProgressBar.vue.d.ts +28 -0
- package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
- package/dist/components/BlueSection.vue.d.ts +36 -0
- package/dist/components/BlueSnackbar.vue.d.ts +19 -0
- package/dist/components/BlueSpinner.vue.d.ts +25 -0
- package/dist/components/BlueStat.vue.d.ts +21 -0
- package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
- package/dist/components/BlueTable.vue.d.ts +62 -0
- package/dist/components/BlueTabs.vue.d.ts +37 -0
- package/dist/components/BlueTextarea.vue.d.ts +40 -0
- package/dist/components/BlueTooltip.vue.d.ts +44 -0
- package/dist/components/BlueWindRose.vue.d.ts +37 -0
- package/dist/composables/useBlueLoading.d.ts +22 -0
- package/dist/composables/useBlueOs.d.ts +39 -0
- package/dist/composables/useBlueSnackbar.d.ts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/services/blueos.d.ts +37 -0
- package/dist/style.css +1 -1
- package/dist/types/banner.d.ts +16 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/theme.d.ts +22 -0
- package/package.json +8 -2
- package/src/components/BlueApp.vue +84 -0
- package/src/components/BlueBanner.vue +196 -0
- package/src/components/BlueBannerGroup.vue +47 -0
- package/src/components/BlueButton.vue +134 -0
- package/src/components/BlueButtonGroup.vue +26 -24
- package/src/components/BlueCard.vue +62 -0
- package/src/components/BlueCheckbox.vue +100 -0
- package/src/components/BlueChip.vue +77 -0
- package/src/components/BlueConfirmDialog.vue +73 -0
- package/src/components/BlueDialog.vue +135 -0
- package/src/components/BlueFileDrop.vue +94 -0
- package/src/components/BlueIcon.vue +37 -0
- package/src/components/BlueInput.vue +21 -11
- package/src/components/BlueLoadingDialog.vue +22 -53
- package/src/components/BlueProgressBar.vue +46 -0
- package/src/components/BluePromptDialog.vue +51 -99
- package/src/components/BlueRadioGroup.vue +104 -0
- package/src/components/BlueSection.vue +33 -0
- package/src/components/BlueSelect.vue +12 -10
- package/src/components/BlueSlider.vue +1 -3
- package/src/components/BlueSnackbar.vue +79 -0
- package/src/components/BlueSpinner.vue +37 -0
- package/src/components/BlueStat.vue +26 -0
- package/src/components/BlueStepsDialog.vue +180 -0
- package/src/components/BlueSwitch.vue +16 -13
- package/src/components/BlueTable.vue +154 -0
- package/src/components/BlueTabs.vue +77 -0
- package/src/components/BlueTextarea.vue +101 -0
- package/src/components/BlueTooltip.vue +88 -0
- package/src/components/BlueWindRose.vue +267 -0
- package/src/composables/useBlueLoading.ts +38 -0
- package/src/composables/useBlueOs.ts +154 -0
- package/src/composables/useBlueSnackbar.ts +89 -0
- package/src/index.ts +40 -0
- package/src/services/blueos.ts +95 -0
- package/src/styles/bluevue.css +136 -0
- package/src/types/banner.ts +17 -0
- package/src/utils/files.ts +44 -0
- package/src/utils/theme.ts +37 -0
- package/templates/extension/.github/workflows/deploy.yml +23 -0
- package/templates/extension/Dockerfile +56 -0
- package/templates/extension/README.md +35 -0
- package/templates/extension/backend/main.py +45 -0
- package/templates/extension/backend/requirements.txt +2 -0
- package/templates/extension/frontend/.eslintrc.cjs +20 -0
- package/templates/extension/frontend/env.d.ts +7 -0
- package/templates/extension/frontend/index.html +12 -0
- package/templates/extension/frontend/package.json +30 -0
- package/templates/extension/frontend/src/App.vue +51 -0
- package/templates/extension/frontend/src/main.ts +8 -0
- package/templates/extension/frontend/src/styles/global.css +11 -0
- package/templates/extension/frontend/tsconfig.json +25 -0
- package/templates/extension/frontend/tsconfig.node.json +11 -0
- package/templates/extension/frontend/vite.config.ts +24 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** What a BlueOS service answered when it did not answer with the thing that was asked for. */
|
|
2
|
+
export class BlueOsError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
message: string,
|
|
5
|
+
readonly url: string,
|
|
6
|
+
/** The HTTP status, or 0 when the service could not be reached at all. */
|
|
7
|
+
readonly status: number
|
|
8
|
+
) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'BlueOsError'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BlueOsServiceOptions {
|
|
15
|
+
/** The API version the service is versioned under. Pass null for the few that are not. */
|
|
16
|
+
version?: string | null
|
|
17
|
+
/** How long a call may take before it is given up on, in milliseconds. */
|
|
18
|
+
timeout?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BlueOsService {
|
|
22
|
+
/** The absolute path a call would be made to, for a request this wrapper does not cover. */
|
|
23
|
+
url: (path: string) => string
|
|
24
|
+
get: <T>(path: string, init?: RequestInit) => Promise<T>
|
|
25
|
+
post: <T>(path: string, body?: unknown, init?: RequestInit) => Promise<T>
|
|
26
|
+
delete: <T>(path: string, init?: RequestInit) => Promise<T>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// An extension is served from the same origin as BlueOS itself, so the services sit at the root of
|
|
30
|
+
// it and a relative path reaches them. A page being developed off the vehicle is the exception,
|
|
31
|
+
// and it says where the vehicle is.
|
|
32
|
+
let host = ''
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Points the services at a BlueOS that is not the origin serving this page, which is what a page
|
|
36
|
+
* running on a development server needs.
|
|
37
|
+
* @param {string} url The vehicle's address, such as 'http://blueos.local'. Empty means the origin.
|
|
38
|
+
*/
|
|
39
|
+
export const setBlueOsHost = (url: string): void => {
|
|
40
|
+
host = url.replace(/\/$/, '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether this page is being served by BlueOS as an extension, rather than run on its own. */
|
|
44
|
+
export const isBlueOsExtension = (): boolean =>
|
|
45
|
+
typeof window !== 'undefined' && window.location.pathname.startsWith('/extensionv2/')
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A wrapper around one of BlueOS's services, reachable by name.
|
|
49
|
+
* @param {string} name The service as nginx exposes it, such as 'beacon', 'bag' or 'kraken'.
|
|
50
|
+
* @param {BlueOsServiceOptions} options The API version to address and how long to wait.
|
|
51
|
+
* @returns {BlueOsService} Its verbs, each parsing JSON and raising BlueOsError on anything else.
|
|
52
|
+
*/
|
|
53
|
+
export const blueOsService = (name: string, options: BlueOsServiceOptions = {}): BlueOsService => {
|
|
54
|
+
const version = options.version === undefined ? 'v1.0' : options.version
|
|
55
|
+
const timeout = options.timeout ?? 5000
|
|
56
|
+
|
|
57
|
+
const url = (path: string): string => {
|
|
58
|
+
const segments = [name, version, path.replace(/^\//, '')].filter((part): part is string => Boolean(part))
|
|
59
|
+
return `${host}/${segments.join('/')}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const request = async <T>(path: string, init: RequestInit): Promise<T> => {
|
|
63
|
+
const target = url(path)
|
|
64
|
+
|
|
65
|
+
let response: Response
|
|
66
|
+
try {
|
|
67
|
+
response = await fetch(target, { signal: AbortSignal.timeout(timeout), ...init })
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// A refused connection, a DNS failure and a timeout all mean the same thing to a page: the
|
|
70
|
+
// vehicle is not answering, which is worth saying differently from a service that refused.
|
|
71
|
+
throw new BlueOsError(`Could not reach ${name}: ${(error as Error).message}`, target, 0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new BlueOsError(await response.text() || response.statusText, target, response.status)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A service that answers with nothing is answering successfully, and undefined is what that is.
|
|
79
|
+
const text = await response.text()
|
|
80
|
+
return (text === '' ? undefined : JSON.parse(text)) as T
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
url,
|
|
85
|
+
get: <T>(path: string, init?: RequestInit) => request<T>(path, { method: 'GET', ...init }),
|
|
86
|
+
post: <T>(path: string, body?: unknown, init?: RequestInit) =>
|
|
87
|
+
request<T>(path, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
91
|
+
...init,
|
|
92
|
+
}),
|
|
93
|
+
delete: <T>(path: string, init?: RequestInit) => request<T>(path, { method: 'DELETE', ...init }),
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/styles/bluevue.css
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
rather than somewhere that is pressed. The light line along the bottom edge is what sells
|
|
18
18
|
the depth, by reading as the lit far wall of the recess. */
|
|
19
19
|
--bluevue-inset-1: inset 0 1px 2px #00000056, inset 0 0.5px 0.5px #0000003a, inset 0 -0.5px 0 #ffffff0b;
|
|
20
|
+
/* The elevation the buttons rest at, with each of its three shadows at half the alpha: a note is
|
|
21
|
+
raised off the surface less than something meant to be pressed. */
|
|
22
|
+
--bluevue-elevation-1-soft: 0 2px 1px -1px #0000001a, 0 1px 1px 0 #00000012, 0 1px 3px 0 #0000000f;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
.bluevue-elevation-1 {
|
|
@@ -31,6 +34,10 @@
|
|
|
31
34
|
box-shadow: var(--bluevue-inset-1);
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
.bluevue-elevation-1-soft {
|
|
38
|
+
box-shadow: var(--bluevue-elevation-1-soft);
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
/* The surface the dialogs are built on: a frosted near-black card with a hairline edge. */
|
|
35
42
|
.bluevue-panel {
|
|
36
43
|
background-color: var(--bluevue-panel-bg);
|
|
@@ -80,6 +87,135 @@
|
|
|
80
87
|
overflow: visible;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/* An icon is a box the size of its em, not of however wide its glyph advances, so a row of them
|
|
91
|
+
lines up. Written against .mdi as well, since the icon font's own rules say otherwise and a
|
|
92
|
+
consumer is free to load the two stylesheets in either order. */
|
|
93
|
+
.mdi.bluevue-icon {
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
flex-shrink: 0;
|
|
98
|
+
line-height: 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.bluevue-icon--spin {
|
|
102
|
+
animation: bluevue-spin 1.2s linear infinite;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* The ring is a circle with one lit quarter, turning. */
|
|
106
|
+
.bluevue-spinner {
|
|
107
|
+
display: inline-block;
|
|
108
|
+
box-sizing: border-box;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
border-radius: 9999px;
|
|
111
|
+
border-style: solid;
|
|
112
|
+
border-color: #ffffff2e;
|
|
113
|
+
border-top-color: var(--bluevue-accent);
|
|
114
|
+
animation: bluevue-spin 0.8s linear infinite;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@keyframes bluevue-spin {
|
|
118
|
+
to {
|
|
119
|
+
transform: rotate(360deg);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Progress with no measure to it: a short bar crossing the track, rather than a fill that would
|
|
124
|
+
have to claim a position it does not know. */
|
|
125
|
+
.bluevue-progress__sweep {
|
|
126
|
+
width: 40%;
|
|
127
|
+
animation: bluevue-progress-sweep 1.4s ease-in-out infinite;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@keyframes bluevue-progress-sweep {
|
|
131
|
+
from {
|
|
132
|
+
transform: translateX(-100%);
|
|
133
|
+
}
|
|
134
|
+
to {
|
|
135
|
+
transform: translateX(250%);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@media (prefers-reduced-motion: reduce) {
|
|
140
|
+
.bluevue-icon--spin,
|
|
141
|
+
.bluevue-spinner,
|
|
142
|
+
.bluevue-progress__sweep {
|
|
143
|
+
animation-duration: 3s;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* A banner's metrics live here, not in utility classes: Tailwind's utilities sit in a cascade layer
|
|
148
|
+
and a host application's reset does not, so a rule as broad as Vuetify's `* { padding: 0 }` beats
|
|
149
|
+
them whatever their selectors say, and the shut banner is left without the padding that makes it
|
|
150
|
+
a square. */
|
|
151
|
+
.bluevue-banner {
|
|
152
|
+
box-sizing: border-box;
|
|
153
|
+
height: 26px;
|
|
154
|
+
padding: 4px;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
line-height: 16px;
|
|
157
|
+
/* Squeezed by the row it shares in a group, it loses the tail of its message rather than painting
|
|
158
|
+
it outside its own rounded box. */
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* A box the size of the em, not of however wide the glyph happens to advance, so the shut banner is
|
|
163
|
+
26 x 26 whichever icon it carries. */
|
|
164
|
+
.bluevue-banner__icon {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
width: 16px;
|
|
169
|
+
height: 16px;
|
|
170
|
+
font-size: 16px;
|
|
171
|
+
line-height: 16px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* The message's gutter rides inside the window that opens, so the shut square keeps the even four
|
|
175
|
+
pixels around its icon. */
|
|
176
|
+
.bluevue-banner__text {
|
|
177
|
+
padding: 0 8px;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
text-overflow: ellipsis;
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Width is the whole of the animation: the row is as tall as its icon whether it is shut or open,
|
|
184
|
+
so nothing above or below the message moves and the banner only ever grows to the right. */
|
|
185
|
+
.bluevue-banner__reveal {
|
|
186
|
+
transition: width 260ms ease-in-out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Measuring lays the message out by its content and puts it back; no frame is painted in between,
|
|
190
|
+
but the geometry it reads would otherwise be somewhere for an animation to start from. */
|
|
191
|
+
.bluevue-banner--measuring .bluevue-banner__reveal {
|
|
192
|
+
transition: none;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@media (prefers-reduced-motion: reduce) {
|
|
196
|
+
.bluevue-banner__reveal {
|
|
197
|
+
transition: none;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* A notice arrives from the edge it stacks against and leaves the same way, and the ones left
|
|
202
|
+
behind slide up into the gap rather than jumping. */
|
|
203
|
+
.bluevue-notice-enter-active,
|
|
204
|
+
.bluevue-notice-leave-active,
|
|
205
|
+
.bluevue-notice-move {
|
|
206
|
+
transition: opacity 180ms ease, transform 180ms ease;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.bluevue-notice-enter-from,
|
|
210
|
+
.bluevue-notice-leave-to {
|
|
211
|
+
opacity: 0;
|
|
212
|
+
transform: translateY(8px);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.bluevue-notice-leave-active {
|
|
216
|
+
position: absolute;
|
|
217
|
+
}
|
|
218
|
+
|
|
83
219
|
/* BlueExpansiblePanel's open and close. */
|
|
84
220
|
.bluevue-expand-enter-active,
|
|
85
221
|
.bluevue-expand-leave-active {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type BannerSeverity = 'error' | 'warning' | 'info' | 'success'
|
|
2
|
+
|
|
3
|
+
/** A banner's message and look, which is all a group needs to lay one out. */
|
|
4
|
+
export interface BannerContent {
|
|
5
|
+
/** The message, which the banner is only as wide as while it is open. */
|
|
6
|
+
text: string
|
|
7
|
+
/** Which of the standard looks to take (default 'info'). */
|
|
8
|
+
severity?: BannerSeverity
|
|
9
|
+
/** An mdi class, in place of the severity's icon. */
|
|
10
|
+
icon?: string
|
|
11
|
+
/** Icon and text colour, in place of the severity's. */
|
|
12
|
+
color?: string
|
|
13
|
+
/** Fill, in place of the severity's. */
|
|
14
|
+
background?: string
|
|
15
|
+
/** Hairline, in place of the severity's. */
|
|
16
|
+
border?: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saves an object as a JSON file the browser downloads. What an extension's "export configuration"
|
|
3
|
+
* button does.
|
|
4
|
+
* @param {unknown} data Anything JSON can hold.
|
|
5
|
+
* @param {string} filename Suggested name. '.json' is appended when it is missing.
|
|
6
|
+
*/
|
|
7
|
+
export function downloadJson(data: unknown, filename: string): void {
|
|
8
|
+
const name = filename.endsWith('.json') ? filename : `${filename}.json`
|
|
9
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
|
|
10
|
+
const link = document.createElement('a')
|
|
11
|
+
link.href = url
|
|
12
|
+
link.download = name
|
|
13
|
+
link.click()
|
|
14
|
+
// The object URL holds the blob alive until it is released, and the click has already been
|
|
15
|
+
// handed the data by the time this runs.
|
|
16
|
+
URL.revokeObjectURL(url)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Asks for a JSON file and parses it. Resolves to null when the picker is closed without a choice.
|
|
21
|
+
* @param {string} accept File types to offer (default '.json,application/json').
|
|
22
|
+
* @returns {Promise<T | null>} The parsed contents, or null. A malformed file rejects.
|
|
23
|
+
*/
|
|
24
|
+
export function pickJsonFile<T = unknown>(accept = '.json,application/json'): Promise<T | null> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const input = document.createElement('input')
|
|
27
|
+
input.type = 'file'
|
|
28
|
+
input.accept = accept
|
|
29
|
+
// A picker closed with Escape fires nothing at all in some browsers, so the promise is left to
|
|
30
|
+
// settle on the next choice rather than being resolved from a cancel event that may not come.
|
|
31
|
+
input.addEventListener('change', () => {
|
|
32
|
+
const file = input.files?.[0]
|
|
33
|
+
if (!file) {
|
|
34
|
+
resolve(null)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
file
|
|
38
|
+
.text()
|
|
39
|
+
.then((text) => resolve(JSON.parse(text) as T))
|
|
40
|
+
.catch(reject)
|
|
41
|
+
})
|
|
42
|
+
input.click()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Every token the components read, in the names they are written with. */
|
|
2
|
+
export interface BlueTheme {
|
|
3
|
+
/** Fills a committing button, a selected segment, a slider's pill. */
|
|
4
|
+
primary?: string
|
|
5
|
+
/** The lighter blue used where the primary would disappear into a dark inset, such as a focus ring. */
|
|
6
|
+
accent?: string
|
|
7
|
+
/** Anything that went wrong, or is about to. */
|
|
8
|
+
error?: string
|
|
9
|
+
/** The card an extension's page is laid out on. */
|
|
10
|
+
surface?: string
|
|
11
|
+
/** The frosted panel dialogs and menus are drawn on. */
|
|
12
|
+
panelBg?: string
|
|
13
|
+
/** The line dividing one part of a panel from the next. */
|
|
14
|
+
hairline?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const VARIABLE: Record<keyof BlueTheme, string> = {
|
|
18
|
+
primary: '--bluevue-primary',
|
|
19
|
+
accent: '--bluevue-accent',
|
|
20
|
+
error: '--bluevue-error',
|
|
21
|
+
surface: '--bluevue-surface',
|
|
22
|
+
panelBg: '--bluevue-panel-bg',
|
|
23
|
+
hairline: '--bluevue-hairline',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Repaints the kit in an extension's own colours, or in the colours BlueOS reports for the vehicle.
|
|
28
|
+
* Only the tokens given are touched, so the rest keep the values the stylesheet ships with.
|
|
29
|
+
* @param {BlueTheme} theme The tokens to change.
|
|
30
|
+
* @param {HTMLElement} target Where to set them (default the document root, so they reach everything).
|
|
31
|
+
*/
|
|
32
|
+
export function applyBlueTheme(theme: BlueTheme, target: HTMLElement = document.documentElement): void {
|
|
33
|
+
for (const [key, variable] of Object.entries(VARIABLE) as [keyof BlueTheme, string][]) {
|
|
34
|
+
const value = theme[key]
|
|
35
|
+
if (value) target.style.setProperty(variable, value)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Deploy BlueOS Extension Image
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
deploy-docker-image:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- name: Deploy BlueOS Extension
|
|
12
|
+
uses: BlueOS-community/Deploy-BlueOS-Extension@v1
|
|
13
|
+
with:
|
|
14
|
+
docker-username: ${{ secrets.DOCKER_USERNAME }}
|
|
15
|
+
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
|
16
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
17
|
+
image-name: '__SLUG__'
|
|
18
|
+
image-prefix: 'blueos-'
|
|
19
|
+
author: ${{ vars.MY_NAME }}
|
|
20
|
+
author-email: ${{ vars.MY_EMAIL }}
|
|
21
|
+
maintainer: ${{ vars.ORG_NAME }}
|
|
22
|
+
maintainer-email: ${{ vars.ORG_EMAIL }}
|
|
23
|
+
build-platforms: linux/arm64,linux/amd64,linux/arm/v7
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
FROM --platform=$BUILDPLATFORM node:20-bookworm-slim AS frontend-builder
|
|
4
|
+
WORKDIR /frontend
|
|
5
|
+
COPY frontend/package.json ./
|
|
6
|
+
RUN npm install
|
|
7
|
+
COPY frontend/ ./
|
|
8
|
+
RUN npm run build
|
|
9
|
+
|
|
10
|
+
FROM python:3.11-slim AS runtime
|
|
11
|
+
WORKDIR /app
|
|
12
|
+
|
|
13
|
+
COPY backend/requirements.txt ./requirements.txt
|
|
14
|
+
RUN python -m pip install --no-cache-dir -r requirements.txt
|
|
15
|
+
|
|
16
|
+
COPY backend/ ./
|
|
17
|
+
COPY --from=frontend-builder /frontend/dist ./static
|
|
18
|
+
|
|
19
|
+
EXPOSE 80
|
|
20
|
+
|
|
21
|
+
LABEL version="0.1.0"
|
|
22
|
+
|
|
23
|
+
LABEL permissions='{\
|
|
24
|
+
"ExposedPorts": {\
|
|
25
|
+
"80/tcp": {}\
|
|
26
|
+
},\
|
|
27
|
+
"HostConfig": {\
|
|
28
|
+
"ExtraHosts": ["host.docker.internal:host-gateway"],\
|
|
29
|
+
"PortBindings": {\
|
|
30
|
+
"80/tcp": [\
|
|
31
|
+
{\
|
|
32
|
+
"HostPort": ""\
|
|
33
|
+
}\
|
|
34
|
+
]\
|
|
35
|
+
}\
|
|
36
|
+
}\
|
|
37
|
+
}'
|
|
38
|
+
|
|
39
|
+
LABEL authors='[\
|
|
40
|
+
{\
|
|
41
|
+
"name": "Blue Robotics",\
|
|
42
|
+
"email": "support@bluerobotics.com"\
|
|
43
|
+
}\
|
|
44
|
+
]'
|
|
45
|
+
|
|
46
|
+
LABEL company='{\
|
|
47
|
+
"about": "A BlueOS extension.",\
|
|
48
|
+
"name": "Blue Robotics",\
|
|
49
|
+
"email": "support@bluerobotics.com"\
|
|
50
|
+
}'
|
|
51
|
+
|
|
52
|
+
LABEL type="tool"
|
|
53
|
+
LABEL tags='["extension"]'
|
|
54
|
+
LABEL requirements="core >= 1.1"
|
|
55
|
+
|
|
56
|
+
CMD ["python", "main.py"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# __TITLE__
|
|
2
|
+
|
|
3
|
+
A BlueOS extension, laid out with [@bluerobotics/bluevue](https://github.com/bluerobotics/BlueVue).
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
The frontend is a Vite app. From `frontend/`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It answers on http://localhost:8080 and proxies `/register_service` and `/v1.0` to the backend.
|
|
15
|
+
|
|
16
|
+
The backend is FastAPI. From `backend/`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install -r requirements.txt
|
|
20
|
+
PORT=8000 python main.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It answers on http://localhost:8000. Point the kit at a vehicle while developing off one:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { setBlueOsHost } from '@bluerobotics/bluevue'
|
|
27
|
+
|
|
28
|
+
setBlueOsHost('http://blueos.local')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Ship
|
|
32
|
+
|
|
33
|
+
The Dockerfile builds the frontend, copies it next to the backend, and carries the labels BlueOS
|
|
34
|
+
reads to install the image. The workflow in `.github/workflows/deploy.yml` builds it for amd64,
|
|
35
|
+
arm64 and armv7 on every push, using [Deploy-BlueOS-Extension](https://github.com/BlueOS-community/Deploy-BlueOS-Extension).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
from uvicorn import Config, Server
|
|
9
|
+
|
|
10
|
+
PORT = int(os.environ.get("PORT", "80"))
|
|
11
|
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
12
|
+
|
|
13
|
+
app = FastAPI(title="__TITLE__")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.get("/register_service", include_in_schema=False)
|
|
17
|
+
def register_service() -> Dict[str, Any]:
|
|
18
|
+
"""Metadata consumed by BlueOS to add this extension to the sidebar."""
|
|
19
|
+
return {
|
|
20
|
+
"name": "__TITLE__",
|
|
21
|
+
"description": "A BlueOS extension.",
|
|
22
|
+
"icon": "mdi-puzzle-outline",
|
|
23
|
+
"company": "Blue Robotics",
|
|
24
|
+
"version": "0.1.0",
|
|
25
|
+
"new_page": False,
|
|
26
|
+
"api": "/docs",
|
|
27
|
+
"works_in_relative_paths": True,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.get("/v1.0/health")
|
|
32
|
+
def health() -> Dict[str, str]:
|
|
33
|
+
return {"status": "ok"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if STATIC_DIR.is_dir():
|
|
37
|
+
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
Server(Config(app=app, host="0.0.0.0", port=PORT, log_config=None)).run()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
env: {
|
|
4
|
+
browser: true,
|
|
5
|
+
es2022: true,
|
|
6
|
+
},
|
|
7
|
+
extends: [
|
|
8
|
+
'plugin:vue/vue3-recommended',
|
|
9
|
+
'plugin:@typescript-eslint/recommended',
|
|
10
|
+
],
|
|
11
|
+
parser: 'vue-eslint-parser',
|
|
12
|
+
parserOptions: {
|
|
13
|
+
parser: '@typescript-eslint/parser',
|
|
14
|
+
ecmaVersion: 'latest',
|
|
15
|
+
sourceType: 'module',
|
|
16
|
+
},
|
|
17
|
+
rules: {
|
|
18
|
+
'vue/multi-word-component-names': 'off',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>__TITLE__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vue-tsc --noEmit && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "vue-tsc --noEmit",
|
|
11
|
+
"lint": "eslint . --ext .vue,.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@bluerobotics/bluevue": "^0.2.0",
|
|
15
|
+
"@mdi/font": "7.4.47",
|
|
16
|
+
"vue": "3.4.38"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@tailwindcss/vite": "4.1.11",
|
|
20
|
+
"@typescript-eslint/eslint-plugin": "7.18.0",
|
|
21
|
+
"@typescript-eslint/parser": "7.18.0",
|
|
22
|
+
"@vitejs/plugin-vue": "5.1.3",
|
|
23
|
+
"eslint": "8.57.0",
|
|
24
|
+
"eslint-plugin-vue": "9.27.0",
|
|
25
|
+
"tailwindcss": "4.1.11",
|
|
26
|
+
"typescript": "5.5.4",
|
|
27
|
+
"vite": "5.4.2",
|
|
28
|
+
"vue-tsc": "2.0.29"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { BlueApp, BlueButton, BlueExpansiblePanel, BlueStat, useBlueOs, useBlueSnackbar } from '@bluerobotics/bluevue'
|
|
3
|
+
|
|
4
|
+
const { vehicleName, version } = useBlueOs()
|
|
5
|
+
const { notify, notifyError } = useBlueSnackbar()
|
|
6
|
+
|
|
7
|
+
async function ping(): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch('./v1.0/health')
|
|
10
|
+
if (!response.ok) throw new Error(response.statusText)
|
|
11
|
+
notify('The extension backend answered.', { severity: 'success' })
|
|
12
|
+
} catch (error) {
|
|
13
|
+
notifyError(error, 'Could not reach the extension backend')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<BlueApp title="__TITLE__">
|
|
20
|
+
<template #actions>
|
|
21
|
+
<BlueButton
|
|
22
|
+
variant="icon"
|
|
23
|
+
icon="mdi-refresh"
|
|
24
|
+
tooltip="Ask the backend"
|
|
25
|
+
@click="ping"
|
|
26
|
+
/>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
30
|
+
<BlueStat
|
|
31
|
+
label="Vehicle"
|
|
32
|
+
:value="vehicleName"
|
|
33
|
+
/>
|
|
34
|
+
<BlueStat
|
|
35
|
+
label="BlueOS"
|
|
36
|
+
:value="version"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<BlueExpansiblePanel
|
|
41
|
+
title="Getting started"
|
|
42
|
+
theme="dark"
|
|
43
|
+
:expanded="true"
|
|
44
|
+
>
|
|
45
|
+
<p class="text-sm leading-relaxed text-[#ffffffcc]">
|
|
46
|
+
This page is a BlueApp. The notices and the blocking wait are already mounted, so
|
|
47
|
+
useBlueSnackbar and useBlueLoading work from anywhere. Replace this panel with yours.
|
|
48
|
+
</p>
|
|
49
|
+
</BlueExpansiblePanel>
|
|
50
|
+
</BlueApp>
|
|
51
|
+
</template>
|