@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.
Files changed (91) hide show
  1. package/README.md +97 -18
  2. package/bin/create-blueos-extension.mjs +72 -0
  3. package/dist/bluevue.js +2464 -778
  4. package/dist/components/BlueApp.vue.d.ts +50 -0
  5. package/dist/components/BlueBanner.vue.d.ts +49 -0
  6. package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
  7. package/dist/components/BlueButton.vue.d.ts +59 -0
  8. package/dist/components/BlueCard.vue.d.ts +45 -0
  9. package/dist/components/BlueCheckbox.vue.d.ts +34 -0
  10. package/dist/components/BlueChip.vue.d.ts +47 -0
  11. package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
  12. package/dist/components/BlueDialog.vue.d.ts +60 -0
  13. package/dist/components/BlueFileDrop.vue.d.ts +32 -0
  14. package/dist/components/BlueIcon.vue.d.ts +26 -0
  15. package/dist/components/BlueInput.vue.d.ts +1 -0
  16. package/dist/components/BlueProgressBar.vue.d.ts +28 -0
  17. package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
  18. package/dist/components/BlueSection.vue.d.ts +36 -0
  19. package/dist/components/BlueSnackbar.vue.d.ts +19 -0
  20. package/dist/components/BlueSpinner.vue.d.ts +25 -0
  21. package/dist/components/BlueStat.vue.d.ts +21 -0
  22. package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
  23. package/dist/components/BlueTable.vue.d.ts +62 -0
  24. package/dist/components/BlueTabs.vue.d.ts +37 -0
  25. package/dist/components/BlueTextarea.vue.d.ts +40 -0
  26. package/dist/components/BlueTooltip.vue.d.ts +44 -0
  27. package/dist/components/BlueWindRose.vue.d.ts +37 -0
  28. package/dist/composables/useBlueLoading.d.ts +22 -0
  29. package/dist/composables/useBlueOs.d.ts +39 -0
  30. package/dist/composables/useBlueSnackbar.d.ts +30 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/services/blueos.d.ts +37 -0
  33. package/dist/style.css +1 -1
  34. package/dist/types/banner.d.ts +16 -0
  35. package/dist/utils/files.d.ts +13 -0
  36. package/dist/utils/theme.d.ts +22 -0
  37. package/package.json +8 -2
  38. package/src/components/BlueApp.vue +84 -0
  39. package/src/components/BlueBanner.vue +196 -0
  40. package/src/components/BlueBannerGroup.vue +47 -0
  41. package/src/components/BlueButton.vue +134 -0
  42. package/src/components/BlueButtonGroup.vue +26 -24
  43. package/src/components/BlueCard.vue +62 -0
  44. package/src/components/BlueCheckbox.vue +100 -0
  45. package/src/components/BlueChip.vue +77 -0
  46. package/src/components/BlueConfirmDialog.vue +73 -0
  47. package/src/components/BlueDialog.vue +135 -0
  48. package/src/components/BlueFileDrop.vue +94 -0
  49. package/src/components/BlueIcon.vue +37 -0
  50. package/src/components/BlueInput.vue +21 -11
  51. package/src/components/BlueLoadingDialog.vue +22 -53
  52. package/src/components/BlueProgressBar.vue +46 -0
  53. package/src/components/BluePromptDialog.vue +51 -99
  54. package/src/components/BlueRadioGroup.vue +104 -0
  55. package/src/components/BlueSection.vue +33 -0
  56. package/src/components/BlueSelect.vue +12 -10
  57. package/src/components/BlueSlider.vue +1 -3
  58. package/src/components/BlueSnackbar.vue +79 -0
  59. package/src/components/BlueSpinner.vue +37 -0
  60. package/src/components/BlueStat.vue +26 -0
  61. package/src/components/BlueStepsDialog.vue +180 -0
  62. package/src/components/BlueSwitch.vue +16 -13
  63. package/src/components/BlueTable.vue +154 -0
  64. package/src/components/BlueTabs.vue +77 -0
  65. package/src/components/BlueTextarea.vue +101 -0
  66. package/src/components/BlueTooltip.vue +88 -0
  67. package/src/components/BlueWindRose.vue +267 -0
  68. package/src/composables/useBlueLoading.ts +38 -0
  69. package/src/composables/useBlueOs.ts +154 -0
  70. package/src/composables/useBlueSnackbar.ts +89 -0
  71. package/src/index.ts +40 -0
  72. package/src/services/blueos.ts +95 -0
  73. package/src/styles/bluevue.css +136 -0
  74. package/src/types/banner.ts +17 -0
  75. package/src/utils/files.ts +44 -0
  76. package/src/utils/theme.ts +37 -0
  77. package/templates/extension/.github/workflows/deploy.yml +23 -0
  78. package/templates/extension/Dockerfile +56 -0
  79. package/templates/extension/README.md +35 -0
  80. package/templates/extension/backend/main.py +45 -0
  81. package/templates/extension/backend/requirements.txt +2 -0
  82. package/templates/extension/frontend/.eslintrc.cjs +20 -0
  83. package/templates/extension/frontend/env.d.ts +7 -0
  84. package/templates/extension/frontend/index.html +12 -0
  85. package/templates/extension/frontend/package.json +30 -0
  86. package/templates/extension/frontend/src/App.vue +51 -0
  87. package/templates/extension/frontend/src/main.ts +8 -0
  88. package/templates/extension/frontend/src/styles/global.css +11 -0
  89. package/templates/extension/frontend/tsconfig.json +25 -0
  90. package/templates/extension/frontend/tsconfig.node.json +11 -0
  91. 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
+ }
@@ -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,2 @@
1
+ fastapi==0.105.0
2
+ uvicorn==0.18.0
@@ -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,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<object, object, unknown>
6
+ export default component
7
+ }
@@ -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>
@@ -0,0 +1,8 @@
1
+ import '@mdi/font/css/materialdesignicons.css'
2
+ import '@/styles/global.css'
3
+
4
+ import { createApp } from 'vue'
5
+
6
+ import App from './App.vue'
7
+
8
+ createApp(App).mount('#app')
@@ -0,0 +1,11 @@
1
+ @import "tailwindcss";
2
+ @import "@bluerobotics/bluevue/bluevue.css";
3
+
4
+ @source "../../node_modules/@bluerobotics/bluevue/dist";
5
+
6
+ html,
7
+ body,
8
+ #app {
9
+ margin: 0;
10
+ min-height: 100%;
11
+ }