goosebit 0.2.4__tar.gz → 0.2.5__tar.gz
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.
- {goosebit-0.2.4 → goosebit-0.2.5}/PKG-INFO +14 -6
- {goosebit-0.2.4 → goosebit-0.2.5}/README.md +9 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/__init__.py +16 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/routes.py +2 -2
- goosebit-0.2.5/goosebit/api/v1/devices/routes.py +51 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/auth/__init__.py +5 -1
- goosebit-0.2.5/goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/models.py +6 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/logs.py +1 -1
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/devices.py +1 -1
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/schema.py +2 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/requests.py +3 -15
- goosebit-0.2.5/goosebit/ui/bff/common/responses.py +16 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/responses.py +6 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/routes.py +53 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/responses.py +6 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/routes.py +4 -2
- goosebit-0.2.5/goosebit/ui/bff/software/responses.py +47 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/routes.py +7 -16
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/devices.js +53 -69
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/rollouts.js +16 -13
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/software.js +5 -11
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/util.js +21 -1
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/devices.html.jinja +0 -20
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/nav.html.jinja +13 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/routes.py +26 -20
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/manager.py +20 -52
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/routes.py +6 -2
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updates/swdesc.py +1 -1
- {goosebit-0.2.4 → goosebit-0.2.5}/pyproject.toml +8 -4
- goosebit-0.2.4/goosebit/api/v1/devices/routes.py +0 -36
- goosebit-0.2.4/goosebit/ui/bff/software/responses.py +0 -37
- goosebit-0.2.4/goosebit/ui/static/js/index.js +0 -155
- goosebit-0.2.4/goosebit/ui/templates/index.html.jinja +0 -25
- {goosebit-0.2.4 → goosebit-0.2.5}/LICENSE +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/__main__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/responses.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/metrics.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/readers.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/telemetry/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/device/responses.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/requests.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/devices/responses.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/download/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/download/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/requests.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/responses.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/rollouts/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/requests.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/responses.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/api/v1/software/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/config.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/db/migrations/models/0_20240830054046_init.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/realtime/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/rollouts.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/schema/software.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/settings/const.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/common/util.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/devices/requests.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/download/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/download/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/software/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/bff/software/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/nav.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/favicon.ico +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/favicon.svg +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/login.js +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/js/logs.js +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/login.html.jinja +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/logs.html.jinja +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/rollouts.html.jinja +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/ui/templates/software.html.jinja +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/routes.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/__init__.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updater/controller/v1/schema.py +0 -0
- {goosebit-0.2.4 → goosebit-0.2.5}/goosebit/updates/__init__.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: goosebit
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.5
|
4
4
|
Summary:
|
5
5
|
Author: Upstream Data
|
6
6
|
Author-email: brett@upstreamdata.ca
|
@@ -8,6 +8,7 @@ Requires-Python: >=3.11,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
9
9
|
Classifier: Programming Language :: Python :: 3.11
|
10
10
|
Classifier: Programming Language :: Python :: 3.12
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
11
12
|
Provides-Extra: postgresql
|
12
13
|
Requires-Dist: aerich (>=0.7.2,<0.8.0)
|
13
14
|
Requires-Dist: aiocache (>=0.12.2,<0.13.0)
|
@@ -19,9 +20,9 @@ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
|
|
19
20
|
Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
|
20
21
|
Requires-Dist: joserfc (>=1.0.0,<2.0.0)
|
21
22
|
Requires-Dist: libconf (>=2.0.1,<3.0.0)
|
22
|
-
Requires-Dist: opentelemetry-distro (>=0.
|
23
|
-
Requires-Dist: opentelemetry-exporter-prometheus (>=0.
|
24
|
-
Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.
|
23
|
+
Requires-Dist: opentelemetry-distro (>=0.49b1,<0.50)
|
24
|
+
Requires-Dist: opentelemetry-exporter-prometheus (>=0.49b1,<0.50)
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.49b1,<0.50)
|
25
26
|
Requires-Dist: pydantic-settings (>=2.4.0,<3.0.0)
|
26
27
|
Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
|
27
28
|
Requires-Dist: semver (>=3.0.2,<4.0.0)
|
@@ -33,6 +34,8 @@ Description-Content-Type: text/markdown
|
|
33
34
|
|
34
35
|
<img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
|
35
36
|
|
37
|
+
[](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
|
38
|
+
|
36
39
|
---
|
37
40
|
|
38
41
|
A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
|
@@ -50,7 +53,6 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
|
|
50
53
|
2. Create the database:
|
51
54
|
|
52
55
|
```bash
|
53
|
-
poetry run aerich init -t goosebit.db.config
|
54
56
|
poetry run aerich upgrade
|
55
57
|
```
|
56
58
|
|
@@ -125,6 +127,12 @@ After a model change create the migration
|
|
125
127
|
poetry run aerich migrate
|
126
128
|
```
|
127
129
|
|
130
|
+
To seed some sample data (attention: drops all current data) use
|
131
|
+
|
132
|
+
```bash
|
133
|
+
poetry run generate-sample-data
|
134
|
+
```
|
135
|
+
|
128
136
|
### Code formatting and linting
|
129
137
|
|
130
138
|
Code is formatted using different tools
|
@@ -147,7 +155,7 @@ poetry run pre-commit install
|
|
147
155
|
To manually apply the hooks to all files use:
|
148
156
|
|
149
157
|
```bash
|
150
|
-
pre-commit run --all-files
|
158
|
+
poetry run pre-commit run --all-files
|
151
159
|
```
|
152
160
|
|
153
161
|
### Testing
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
<img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
|
4
4
|
|
5
|
+
[](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
|
6
|
+
|
5
7
|
---
|
6
8
|
|
7
9
|
A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
|
@@ -19,7 +21,6 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
|
|
19
21
|
2. Create the database:
|
20
22
|
|
21
23
|
```bash
|
22
|
-
poetry run aerich init -t goosebit.db.config
|
23
24
|
poetry run aerich upgrade
|
24
25
|
```
|
25
26
|
|
@@ -94,6 +95,12 @@ After a model change create the migration
|
|
94
95
|
poetry run aerich migrate
|
95
96
|
```
|
96
97
|
|
98
|
+
To seed some sample data (attention: drops all current data) use
|
99
|
+
|
100
|
+
```bash
|
101
|
+
poetry run generate-sample-data
|
102
|
+
```
|
103
|
+
|
97
104
|
### Code formatting and linting
|
98
105
|
|
99
106
|
Code is formatted using different tools
|
@@ -116,7 +123,7 @@ poetry run pre-commit install
|
|
116
123
|
To manually apply the hooks to all files use:
|
117
124
|
|
118
125
|
```bash
|
119
|
-
pre-commit run --all-files
|
126
|
+
poetry run pre-commit run --all-files
|
120
127
|
```
|
121
128
|
|
122
129
|
### Testing
|
@@ -4,16 +4,19 @@ from logging import getLogger
|
|
4
4
|
from typing import Annotated
|
5
5
|
|
6
6
|
from fastapi import Depends, FastAPI, HTTPException
|
7
|
+
from fastapi.exception_handlers import http_exception_handler
|
7
8
|
from fastapi.openapi.docs import get_swagger_ui_html
|
8
9
|
from fastapi.requests import Request
|
9
10
|
from fastapi.responses import RedirectResponse
|
10
11
|
from fastapi.security import OAuth2PasswordRequestForm
|
11
12
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
|
13
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
12
14
|
from tortoise.exceptions import ValidationError
|
13
15
|
|
14
16
|
from goosebit import api, db, realtime, ui, updater
|
15
17
|
from goosebit.api.telemetry import metrics
|
16
18
|
from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
|
19
|
+
from goosebit.settings import config
|
17
20
|
from goosebit.ui.nav import nav
|
18
21
|
from goosebit.ui.static import static
|
19
22
|
from goosebit.ui.templates import templates
|
@@ -65,6 +68,13 @@ async def tortoise_validation_exception_handler(request: Request, exc: Validatio
|
|
65
68
|
raise HTTPException(422, str(exc))
|
66
69
|
|
67
70
|
|
71
|
+
# Extend default handler to do logging
|
72
|
+
@app.exception_handler(StarletteHTTPException)
|
73
|
+
async def custom_http_exception_handler(request, exc):
|
74
|
+
logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}")
|
75
|
+
return await http_exception_handler(request, exc)
|
76
|
+
|
77
|
+
|
68
78
|
@app.middleware("http")
|
69
79
|
async def attach_user(request: Request, call_next):
|
70
80
|
request.scope["user"] = await get_user_from_request(request)
|
@@ -77,6 +87,12 @@ async def attach_nav(request: Request, call_next):
|
|
77
87
|
return await call_next(request)
|
78
88
|
|
79
89
|
|
90
|
+
@app.middleware("http")
|
91
|
+
async def attach_config(request: Request, call_next):
|
92
|
+
request.scope["config"] = config
|
93
|
+
return await call_next(request)
|
94
|
+
|
95
|
+
|
80
96
|
@app.get("/", include_in_schema=False)
|
81
97
|
def root_redirect(request: Request):
|
82
98
|
return RedirectResponse(request.url_for("ui_root"))
|
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/{dev_id}")
|
|
12
12
|
|
13
13
|
@router.get(
|
14
14
|
"",
|
15
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
15
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
16
16
|
)
|
17
17
|
async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
|
18
18
|
device = await updater.get_device()
|
@@ -24,7 +24,7 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
|
|
24
24
|
|
25
25
|
@router.get(
|
26
26
|
"/log",
|
27
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
27
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
28
28
|
)
|
29
29
|
async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
|
30
30
|
device = await updater.get_device()
|
@@ -0,0 +1,51 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
|
5
|
+
from fastapi import APIRouter, Security
|
6
|
+
from fastapi.requests import Request
|
7
|
+
|
8
|
+
from goosebit.api.responses import StatusResponse
|
9
|
+
from goosebit.auth import validate_user_permissions
|
10
|
+
from goosebit.db.models import Device
|
11
|
+
from goosebit.schema.devices import DeviceSchema
|
12
|
+
from goosebit.schema.software import SoftwareSchema
|
13
|
+
from goosebit.updater.manager import delete_devices, get_update_manager
|
14
|
+
|
15
|
+
from . import device
|
16
|
+
from .requests import DevicesDeleteRequest
|
17
|
+
from .responses import DevicesResponse
|
18
|
+
|
19
|
+
router = APIRouter(prefix="/devices", tags=["devices"])
|
20
|
+
|
21
|
+
|
22
|
+
@router.get(
|
23
|
+
"",
|
24
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
25
|
+
)
|
26
|
+
async def devices_get(_: Request) -> DevicesResponse:
|
27
|
+
devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
|
28
|
+
response = DevicesResponse(devices=devices)
|
29
|
+
|
30
|
+
async def set_assigned_sw(d: DeviceSchema):
|
31
|
+
updater = await get_update_manager(d.uuid)
|
32
|
+
_, target = await updater.get_update()
|
33
|
+
if target is not None:
|
34
|
+
await target.fetch_related("compatibility")
|
35
|
+
d.assigned_software = SoftwareSchema.model_validate(target)
|
36
|
+
return d
|
37
|
+
|
38
|
+
response.devices = await asyncio.gather(*[set_assigned_sw(d) for d in response.devices])
|
39
|
+
return response
|
40
|
+
|
41
|
+
|
42
|
+
@router.delete(
|
43
|
+
"",
|
44
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
|
45
|
+
)
|
46
|
+
async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
|
47
|
+
await delete_devices(config.devices)
|
48
|
+
return StatusResponse(success=True)
|
49
|
+
|
50
|
+
|
51
|
+
router.include_router(device.router)
|
@@ -16,7 +16,11 @@ from goosebit.settings.schema import User
|
|
16
16
|
logger = logging.getLogger(__name__)
|
17
17
|
|
18
18
|
|
19
|
-
|
19
|
+
oauth2_bearer = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
|
20
|
+
|
21
|
+
|
22
|
+
async def oauth2_auth(connection: HTTPConnection):
|
23
|
+
return await oauth2_bearer(connection)
|
20
24
|
|
21
25
|
|
22
26
|
async def session_auth(connection: HTTPConnection) -> str:
|
@@ -0,0 +1,11 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
return """
|
6
|
+
ALTER TABLE "device" DROP COLUMN "log_complete";"""
|
7
|
+
|
8
|
+
|
9
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
10
|
+
return """
|
11
|
+
ALTER TABLE "device" ADD "log_complete" INT NOT NULL DEFAULT 0;"""
|
@@ -7,6 +7,7 @@ from urllib.request import url2pathname
|
|
7
7
|
|
8
8
|
import semver
|
9
9
|
from anyio import Path
|
10
|
+
from semver import Version
|
10
11
|
from tortoise import Model, fields
|
11
12
|
from tortoise.exceptions import ValidationError
|
12
13
|
|
@@ -68,7 +69,6 @@ class Device(Model):
|
|
68
69
|
update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
|
69
70
|
last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
|
70
71
|
progress = fields.IntField(null=True)
|
71
|
-
log_complete = fields.BooleanField(default=False)
|
72
72
|
last_log = fields.TextField(null=True)
|
73
73
|
last_seen = fields.BigIntField(null=True)
|
74
74
|
last_ip = fields.CharField(max_length=15, null=True)
|
@@ -138,7 +138,7 @@ class Software(Model):
|
|
138
138
|
return None
|
139
139
|
return sorted(
|
140
140
|
updates,
|
141
|
-
key=lambda x: semver.Version.parse(x.version),
|
141
|
+
key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
|
142
142
|
reverse=True,
|
143
143
|
)[0]
|
144
144
|
|
@@ -156,3 +156,7 @@ class Software(Model):
|
|
156
156
|
return self.path.name
|
157
157
|
else:
|
158
158
|
return self.uri
|
159
|
+
|
160
|
+
@property
|
161
|
+
def parsed_version(self) -> Version:
|
162
|
+
return semver.Version.parse(self.version, optional_minor_and_patch=True)
|
@@ -19,7 +19,7 @@ class RealtimeLogModel(BaseModel):
|
|
19
19
|
|
20
20
|
@router.websocket(
|
21
21
|
"/{dev_id}",
|
22
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
22
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
23
23
|
)
|
24
24
|
async def device_logs(websocket: WebSocket, dev_id: str):
|
25
25
|
await websocket.accept()
|
@@ -49,7 +49,7 @@ class DeviceSchema(BaseModel):
|
|
49
49
|
@computed_field # type: ignore[misc]
|
50
50
|
@property
|
51
51
|
def online(self) -> bool | None:
|
52
|
-
return self.last_seen < self.poll_seconds if self.last_seen is not None else None
|
52
|
+
return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None
|
53
53
|
|
54
54
|
@computed_field # type: ignore[misc]
|
55
55
|
@property
|
@@ -10,14 +10,6 @@ class DataTableSearchSchema(BaseModel):
|
|
10
10
|
regex: bool | None = False
|
11
11
|
|
12
12
|
|
13
|
-
class DataTableColumnSchema(BaseModel):
|
14
|
-
data: str | None
|
15
|
-
name: str | None = None
|
16
|
-
searchable: bool | None = None
|
17
|
-
orderable: bool | None = None
|
18
|
-
search: DataTableSearchSchema = DataTableSearchSchema()
|
19
|
-
|
20
|
-
|
21
13
|
class DataTableOrderDirection(StrEnum):
|
22
14
|
ASCENDING = "asc"
|
23
15
|
DESCENDING = "desc"
|
@@ -36,21 +28,17 @@ class DataTableOrderSchema(BaseModel):
|
|
36
28
|
|
37
29
|
class DataTableRequest(BaseModel):
|
38
30
|
draw: int = 1
|
39
|
-
columns: list[DataTableColumnSchema] = list()
|
40
31
|
order: list[DataTableOrderSchema] = list()
|
41
32
|
start: int = 0
|
42
|
-
length: int =
|
33
|
+
length: int | None = None
|
43
34
|
search: DataTableSearchSchema = DataTableSearchSchema()
|
44
35
|
|
45
36
|
@computed_field # type: ignore[misc]
|
46
37
|
@property
|
47
38
|
def order_query(self) -> str | None:
|
48
39
|
try:
|
49
|
-
|
50
|
-
if column is None:
|
51
|
-
return None
|
52
|
-
if self.columns[column].name is None:
|
40
|
+
if len(self.order) == 0 or self.order[0].direction is None or self.order[0].name is None:
|
53
41
|
return None
|
54
|
-
return f"{self.order[0].direction}{self.
|
42
|
+
return f"{self.order[0].direction}{self.order[0].name}"
|
55
43
|
except LookupError:
|
56
44
|
return None
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from pydantic import BaseModel
|
4
|
+
|
5
|
+
|
6
|
+
class DTColumnDescription(BaseModel):
|
7
|
+
title: str
|
8
|
+
data: str
|
9
|
+
name: str | None = None
|
10
|
+
|
11
|
+
searchable: bool | None = None
|
12
|
+
orderable: bool | None = None
|
13
|
+
|
14
|
+
|
15
|
+
class DTColumns(BaseModel):
|
16
|
+
columns: list[DTColumnDescription]
|
@@ -21,11 +21,15 @@ class BFFDeviceResponse(BaseModel):
|
|
21
21
|
if dt_query.search.value:
|
22
22
|
query = query.filter(search_filter(dt_query.search.value))
|
23
23
|
|
24
|
+
filtered_records = await query.count()
|
25
|
+
|
24
26
|
if dt_query.order_query:
|
25
27
|
query = query.order_by(dt_query.order_query)
|
26
28
|
|
27
|
-
|
28
|
-
|
29
|
+
if dt_query.length is not None:
|
30
|
+
query = query.limit(dt_query.length)
|
31
|
+
|
32
|
+
devices = await query.offset(dt_query.start).all()
|
29
33
|
data = [DeviceSchema.model_validate(d) for d in devices]
|
30
34
|
|
31
35
|
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
3
4
|
from typing import Annotated
|
4
5
|
|
5
6
|
from fastapi import APIRouter, Depends, Security
|
@@ -10,10 +11,14 @@ from goosebit.api.responses import StatusResponse
|
|
10
11
|
from goosebit.api.v1.devices import routes
|
11
12
|
from goosebit.auth import validate_user_permissions
|
12
13
|
from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
|
14
|
+
from goosebit.schema.devices import DeviceSchema
|
15
|
+
from goosebit.schema.software import SoftwareSchema
|
16
|
+
from goosebit.settings import config
|
13
17
|
from goosebit.ui.bff.common.requests import DataTableRequest
|
14
18
|
from goosebit.ui.bff.common.util import parse_datatables_query
|
15
19
|
from goosebit.updater.manager import get_update_manager
|
16
20
|
|
21
|
+
from ..common.responses import DTColumnDescription, DTColumns
|
17
22
|
from .requests import DevicesPatchRequest
|
18
23
|
from .responses import BFFDeviceResponse
|
19
24
|
|
@@ -22,7 +27,7 @@ router = APIRouter(prefix="/devices")
|
|
22
27
|
|
23
28
|
@router.get(
|
24
29
|
"",
|
25
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
30
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
26
31
|
)
|
27
32
|
async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
|
28
33
|
def search_filter(search_value: str):
|
@@ -37,7 +42,18 @@ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datata
|
|
37
42
|
|
38
43
|
query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
|
39
44
|
|
40
|
-
|
45
|
+
response = await BFFDeviceResponse.convert(dt_query, query, search_filter)
|
46
|
+
|
47
|
+
async def set_assigned_sw(d: DeviceSchema):
|
48
|
+
updater = await get_update_manager(d.uuid)
|
49
|
+
_, target = await updater.get_update()
|
50
|
+
if target is not None:
|
51
|
+
await target.fetch_related("compatibility")
|
52
|
+
d.assigned_software = SoftwareSchema.model_validate(target)
|
53
|
+
return d
|
54
|
+
|
55
|
+
response.data = await asyncio.gather(*[set_assigned_sw(d) for d in response.data])
|
56
|
+
return response
|
41
57
|
|
42
58
|
|
43
59
|
@router.patch(
|
@@ -73,3 +89,38 @@ router.add_api_route(
|
|
73
89
|
dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
|
74
90
|
name="bff_devices_delete",
|
75
91
|
)
|
92
|
+
|
93
|
+
|
94
|
+
@router.get(
|
95
|
+
"/columns",
|
96
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
97
|
+
response_model_exclude_none=True,
|
98
|
+
)
|
99
|
+
async def devices_get_columns() -> DTColumns:
|
100
|
+
columns = []
|
101
|
+
columns.append(DTColumnDescription(title="Online", data="online"))
|
102
|
+
columns.append(DTColumnDescription(title="UUID", data="uuid", name="uuid", searchable=True, orderable=True))
|
103
|
+
columns.append(DTColumnDescription(title="Name", data="name", name="name", searchable=True, orderable=True))
|
104
|
+
columns.append(DTColumnDescription(title="Model", data="hw_model"))
|
105
|
+
columns.append(DTColumnDescription(title="Revision", data="hw_revision"))
|
106
|
+
columns.append(DTColumnDescription(title="Feed", data="feed", name="feed", searchable=True, orderable=True))
|
107
|
+
columns.append(
|
108
|
+
DTColumnDescription(
|
109
|
+
title="Installed Software", data="sw_version", name="sw_version", searchable=True, orderable=True
|
110
|
+
)
|
111
|
+
)
|
112
|
+
columns.append(DTColumnDescription(title="Target Software", data="sw_target_version"))
|
113
|
+
columns.append(
|
114
|
+
DTColumnDescription(
|
115
|
+
title="Update Mode", data="update_mode", name="update_mode", searchable=True, orderable=True
|
116
|
+
)
|
117
|
+
)
|
118
|
+
columns.append(
|
119
|
+
DTColumnDescription(title="State", data="last_state", name="last_state", searchable=True, orderable=True)
|
120
|
+
)
|
121
|
+
columns.append(DTColumnDescription(title="Force Update", data="force_update"))
|
122
|
+
columns.append(DTColumnDescription(title="Progress", data="progress"))
|
123
|
+
if config.track_device_ip:
|
124
|
+
columns.append(DTColumnDescription(title="Last IP", data="last_ip"))
|
125
|
+
columns.append(DTColumnDescription(title="Last Seen", data="last_seen"))
|
126
|
+
return DTColumns(columns=columns)
|
@@ -19,11 +19,15 @@ class BFFRolloutsResponse(BaseModel):
|
|
19
19
|
if dt_query.search.value:
|
20
20
|
query = query.filter(search_filter(dt_query.search.value))
|
21
21
|
|
22
|
+
filtered_records = await query.count()
|
23
|
+
|
22
24
|
if dt_query.order_query:
|
23
25
|
query = query.order_by(dt_query.order_query)
|
24
26
|
|
25
|
-
|
26
|
-
|
27
|
+
if dt_query.length is not None:
|
28
|
+
query = query.limit(dt_query.length)
|
29
|
+
|
30
|
+
rollouts = await query.offset(dt_query.start).all()
|
27
31
|
data = [RolloutSchema.model_validate(r) for r in rollouts]
|
28
32
|
|
29
33
|
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -1,10 +1,12 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from fastapi import APIRouter
|
3
|
+
from fastapi import APIRouter, Depends
|
4
|
+
|
5
|
+
from goosebit.auth import validate_current_user
|
4
6
|
|
5
7
|
from . import devices, download, rollouts, software
|
6
8
|
|
7
|
-
router = APIRouter(prefix="/bff", tags=["bff"])
|
9
|
+
router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)])
|
8
10
|
router.include_router(devices.router)
|
9
11
|
router.include_router(software.router)
|
10
12
|
router.include_router(rollouts.router)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
from typing import Callable
|
2
|
+
|
3
|
+
from pydantic import BaseModel, Field
|
4
|
+
from tortoise.expressions import Q
|
5
|
+
from tortoise.queryset import QuerySet
|
6
|
+
|
7
|
+
from goosebit.schema.software import SoftwareSchema
|
8
|
+
from goosebit.ui.bff.common.requests import DataTableOrderDirection, DataTableRequest
|
9
|
+
|
10
|
+
|
11
|
+
class BFFSoftwareResponse(BaseModel):
|
12
|
+
data: list[SoftwareSchema]
|
13
|
+
draw: int
|
14
|
+
records_total: int = Field(serialization_alias="recordsTotal")
|
15
|
+
records_filtered: int = Field(serialization_alias="recordsFiltered")
|
16
|
+
|
17
|
+
@classmethod
|
18
|
+
async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable, alt_filter: Q):
|
19
|
+
total_records = await query.count()
|
20
|
+
query = query.filter(alt_filter)
|
21
|
+
if dt_query.search.value:
|
22
|
+
query = query.filter(search_filter(dt_query.search.value))
|
23
|
+
|
24
|
+
filtered_records = await query.count()
|
25
|
+
|
26
|
+
if len(dt_query.order) > 0 and dt_query.order[0].name == "version":
|
27
|
+
# ordering cannot be delegated to database as semantic versioning sorting is not supported
|
28
|
+
software = await query.all()
|
29
|
+
reverse = dt_query.order[0].dir == DataTableOrderDirection.DESCENDING
|
30
|
+
software.sort(key=lambda s: s.parsed_version, reverse=reverse)
|
31
|
+
|
32
|
+
# in-memory paging
|
33
|
+
if dt_query.length is None:
|
34
|
+
software = software[dt_query.start :]
|
35
|
+
else:
|
36
|
+
software = software[dt_query.start : dt_query.start + dt_query.length]
|
37
|
+
|
38
|
+
else:
|
39
|
+
# if no ordering is specified, database-side paging can be used
|
40
|
+
if dt_query.length is not None:
|
41
|
+
query = query.limit(dt_query.length)
|
42
|
+
|
43
|
+
software = await query.offset(dt_query.start).all()
|
44
|
+
|
45
|
+
data = [SoftwareSchema.model_validate(s) for s in software]
|
46
|
+
|
47
|
+
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
@@ -11,27 +11,18 @@ from .templates import templates
|
|
11
11
|
|
12
12
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
|
13
13
|
|
14
|
-
router = APIRouter(prefix="/ui",
|
14
|
+
router = APIRouter(prefix="/ui", include_in_schema=False)
|
15
15
|
router.include_router(bff.router)
|
16
16
|
|
17
17
|
|
18
|
-
@router.get("")
|
18
|
+
@router.get("", dependencies=[Depends(redirect_if_unauthenticated)])
|
19
19
|
async def ui_root(request: Request):
|
20
|
-
return RedirectResponse(request.url_for("
|
21
|
-
|
22
|
-
|
23
|
-
@router.get(
|
24
|
-
"/home",
|
25
|
-
dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
|
26
|
-
)
|
27
|
-
@nav.route("Home", permissions="home.read")
|
28
|
-
async def home_ui(request: Request):
|
29
|
-
return templates.TemplateResponse(request, "index.html.jinja", context={"title": "Home"})
|
20
|
+
return RedirectResponse(request.url_for("devices_ui"))
|
30
21
|
|
31
22
|
|
32
23
|
@router.get(
|
33
24
|
"/devices",
|
34
|
-
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
25
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])],
|
35
26
|
)
|
36
27
|
@nav.route("Devices", permissions="device.read")
|
37
28
|
async def devices_ui(request: Request):
|
@@ -40,7 +31,7 @@ async def devices_ui(request: Request):
|
|
40
31
|
|
41
32
|
@router.get(
|
42
33
|
"/software",
|
43
|
-
dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
|
34
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["software.read"])],
|
44
35
|
)
|
45
36
|
@nav.route("Software", permissions="software.read")
|
46
37
|
async def software_ui(request: Request):
|
@@ -49,7 +40,7 @@ async def software_ui(request: Request):
|
|
49
40
|
|
50
41
|
@router.get(
|
51
42
|
"/rollouts",
|
52
|
-
dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
|
43
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["rollout.read"])],
|
53
44
|
)
|
54
45
|
@nav.route("Rollouts", permissions="rollout.read")
|
55
46
|
async def rollouts_ui(request: Request):
|
@@ -58,7 +49,7 @@ async def rollouts_ui(request: Request):
|
|
58
49
|
|
59
50
|
@router.get(
|
60
51
|
"/logs/{dev_id}",
|
61
|
-
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
52
|
+
dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])],
|
62
53
|
)
|
63
54
|
async def logs_ui(request: Request, dev_id: str):
|
64
55
|
return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id})
|