goosebit 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl
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/__init__.py +16 -0
- goosebit/api/v1/devices/device/routes.py +2 -2
- goosebit/api/v1/devices/routes.py +19 -4
- goosebit/auth/__init__.py +5 -1
- goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
- goosebit/db/models.py +6 -2
- goosebit/realtime/logs.py +1 -1
- goosebit/schema/devices.py +1 -1
- goosebit/settings/schema.py +2 -0
- goosebit/ui/bff/common/requests.py +3 -15
- goosebit/ui/bff/common/responses.py +16 -0
- goosebit/ui/bff/devices/responses.py +6 -2
- goosebit/ui/bff/devices/routes.py +53 -2
- goosebit/ui/bff/rollouts/responses.py +6 -2
- goosebit/ui/bff/routes.py +4 -2
- goosebit/ui/bff/software/responses.py +19 -9
- goosebit/ui/routes.py +7 -16
- goosebit/ui/static/js/devices.js +53 -69
- goosebit/ui/static/js/rollouts.js +16 -13
- goosebit/ui/static/js/software.js +5 -11
- goosebit/ui/static/js/util.js +21 -1
- goosebit/ui/templates/devices.html.jinja +0 -20
- goosebit/ui/templates/nav.html.jinja +13 -2
- goosebit/updater/controller/v1/routes.py +26 -20
- goosebit/updater/manager.py +20 -52
- goosebit/updater/routes.py +6 -2
- goosebit/updates/swdesc.py +1 -1
- {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/METADATA +14 -6
- {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/RECORD +32 -31
- {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
- goosebit-0.2.5.dist-info/entry_points.txt +3 -0
- goosebit/ui/static/js/index.js +0 -155
- goosebit/ui/templates/index.html.jinja +0 -25
- {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
goosebit/__init__.py
CHANGED
@@ -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()
|
@@ -1,12 +1,16 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
4
|
+
|
3
5
|
from fastapi import APIRouter, Security
|
4
6
|
from fastapi.requests import Request
|
5
7
|
|
6
8
|
from goosebit.api.responses import StatusResponse
|
7
9
|
from goosebit.auth import validate_user_permissions
|
8
10
|
from goosebit.db.models import Device
|
9
|
-
from goosebit.
|
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
|
10
14
|
|
11
15
|
from . import device
|
12
16
|
from .requests import DevicesDeleteRequest
|
@@ -17,11 +21,22 @@ router = APIRouter(prefix="/devices", tags=["devices"])
|
|
17
21
|
|
18
22
|
@router.get(
|
19
23
|
"",
|
20
|
-
dependencies=[Security(validate_user_permissions, scopes=["
|
24
|
+
dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
|
21
25
|
)
|
22
26
|
async def devices_get(_: Request) -> DevicesResponse:
|
23
|
-
devices = await Device.all().prefetch_related("assigned_software", "
|
24
|
-
|
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
|
25
40
|
|
26
41
|
|
27
42
|
@router.delete(
|
goosebit/auth/__init__.py
CHANGED
@@ -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;"""
|
goosebit/db/models.py
CHANGED
@@ -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)
|
goosebit/realtime/logs.py
CHANGED
@@ -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()
|
goosebit/schema/devices.py
CHANGED
@@ -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
|
goosebit/settings/schema.py
CHANGED
@@ -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)
|
goosebit/ui/bff/routes.py
CHANGED
@@ -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)
|
@@ -5,7 +5,7 @@ from tortoise.expressions import Q
|
|
5
5
|
from tortoise.queryset import QuerySet
|
6
6
|
|
7
7
|
from goosebit.schema.software import SoftwareSchema
|
8
|
-
from goosebit.ui.bff.common.requests import DataTableRequest
|
8
|
+
from goosebit.ui.bff.common.requests import DataTableOrderDirection, DataTableRequest
|
9
9
|
|
10
10
|
|
11
11
|
class BFFSoftwareResponse(BaseModel):
|
@@ -21,17 +21,27 @@ class BFFSoftwareResponse(BaseModel):
|
|
21
21
|
if dt_query.search.value:
|
22
22
|
query = query.filter(search_filter(dt_query.search.value))
|
23
23
|
|
24
|
-
if dt_query.order_query:
|
25
|
-
query = query.order_by(dt_query.order_query)
|
26
|
-
|
27
24
|
filtered_records = await query.count()
|
28
25
|
|
29
|
-
|
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)
|
30
42
|
|
31
|
-
|
32
|
-
query = query.limit(dt_query.length)
|
43
|
+
software = await query.offset(dt_query.start).all()
|
33
44
|
|
34
|
-
|
35
|
-
data = [SoftwareSchema.model_validate(d) for d in devices]
|
45
|
+
data = [SoftwareSchema.model_validate(s) for s in software]
|
36
46
|
|
37
47
|
return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
|
goosebit/ui/routes.py
CHANGED
@@ -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})
|
goosebit/ui/static/js/devices.js
CHANGED
@@ -1,29 +1,70 @@
|
|
1
1
|
let dataTable;
|
2
2
|
|
3
|
+
const renderFunctions = {
|
4
|
+
online: (data, type) => {
|
5
|
+
if (type === "display" || type === "filter") {
|
6
|
+
const color = data ? "success" : "danger";
|
7
|
+
return `
|
8
|
+
<div class="text-${color}">
|
9
|
+
●
|
10
|
+
</div>
|
11
|
+
`;
|
12
|
+
}
|
13
|
+
return data;
|
14
|
+
},
|
15
|
+
force_update: (data, type) => {
|
16
|
+
if (type === "display" || type === "filter") {
|
17
|
+
const color = data ? "success" : "muted";
|
18
|
+
return `
|
19
|
+
<div class="text-${color}">
|
20
|
+
●
|
21
|
+
</div>
|
22
|
+
`;
|
23
|
+
}
|
24
|
+
return data;
|
25
|
+
},
|
26
|
+
progress: (data, type) => {
|
27
|
+
if (type === "display" || type === "filter") {
|
28
|
+
return data ? `${data}%` : "-";
|
29
|
+
}
|
30
|
+
return data;
|
31
|
+
},
|
32
|
+
last_seen: (data, type) => {
|
33
|
+
if (type === "display" || type === "filter") {
|
34
|
+
return secondsToRecentDate(data);
|
35
|
+
}
|
36
|
+
return data;
|
37
|
+
},
|
38
|
+
};
|
39
|
+
|
3
40
|
document.addEventListener("DOMContentLoaded", async () => {
|
41
|
+
const columnConfig = await get_request("/ui/bff/devices/columns");
|
42
|
+
for (const col in columnConfig.columns) {
|
43
|
+
const colDesc = columnConfig.columns[col];
|
44
|
+
const colName = colDesc.data;
|
45
|
+
if (renderFunctions[colName]) {
|
46
|
+
columnConfig.columns[col].render = renderFunctions[colName];
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
4
50
|
dataTable = new DataTable("#device-table", {
|
5
51
|
responsive: true,
|
6
52
|
paging: true,
|
7
53
|
processing: false,
|
8
54
|
serverSide: true,
|
9
|
-
order:
|
55
|
+
order: { name: "uuid", dir: "asc" },
|
10
56
|
scrollCollapse: true,
|
11
57
|
scroller: true,
|
12
58
|
scrollY: "65vh",
|
13
59
|
stateSave: true,
|
14
|
-
stateLoadParams: (settings, data) => {
|
15
|
-
// if save state is older than last breaking code change...
|
16
|
-
if (data.time <= 1722434189000) {
|
17
|
-
// ... delete it
|
18
|
-
for (const key of Object.keys(data)) {
|
19
|
-
delete data[key];
|
20
|
-
}
|
21
|
-
}
|
22
|
-
},
|
23
60
|
select: true,
|
24
61
|
rowId: "uuid",
|
25
62
|
ajax: {
|
26
|
-
url: "/ui/bff/devices
|
63
|
+
url: "/ui/bff/devices",
|
64
|
+
data: (data) => {
|
65
|
+
// biome-ignore lint/performance/noDelete: really has to be deleted
|
66
|
+
delete data.columns;
|
67
|
+
},
|
27
68
|
contentType: "application/json",
|
28
69
|
},
|
29
70
|
initComplete: () => {
|
@@ -37,64 +78,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
37
78
|
render: (data) => data || "-",
|
38
79
|
},
|
39
80
|
],
|
40
|
-
columns:
|
41
|
-
{
|
42
|
-
data: "online",
|
43
|
-
render: (data, type) => {
|
44
|
-
if (type === "display" || type === "filter") {
|
45
|
-
const color = data ? "success" : "danger";
|
46
|
-
return `
|
47
|
-
<div class="text-${color}">
|
48
|
-
●
|
49
|
-
</div>
|
50
|
-
`;
|
51
|
-
}
|
52
|
-
return data;
|
53
|
-
},
|
54
|
-
},
|
55
|
-
{ data: "uuid", searchable: true, orderable: true },
|
56
|
-
{ data: "name", searchable: true, orderable: true },
|
57
|
-
{ data: "hw_model" },
|
58
|
-
{ data: "hw_revision" },
|
59
|
-
{ data: "feed", searchable: true, orderable: true },
|
60
|
-
{ data: "sw_version", searchable: true, orderable: true },
|
61
|
-
{ data: "sw_target_version" },
|
62
|
-
{ data: "update_mode", searchable: true, orderable: true },
|
63
|
-
{ data: "last_state", searchable: true, orderable: true },
|
64
|
-
{
|
65
|
-
data: "force_update",
|
66
|
-
render: (data, type) => {
|
67
|
-
if (type === "display" || type === "filter") {
|
68
|
-
const color = data ? "success" : "muted";
|
69
|
-
return `
|
70
|
-
<div class="text-${color}">
|
71
|
-
●
|
72
|
-
</div>
|
73
|
-
`;
|
74
|
-
}
|
75
|
-
return data;
|
76
|
-
},
|
77
|
-
},
|
78
|
-
{
|
79
|
-
data: "progress",
|
80
|
-
render: (data, type) => {
|
81
|
-
if (type === "display" || type === "filter") {
|
82
|
-
return data ? `${data}%` : "-";
|
83
|
-
}
|
84
|
-
return data;
|
85
|
-
},
|
86
|
-
},
|
87
|
-
{ data: "last_ip" },
|
88
|
-
{
|
89
|
-
data: "last_seen",
|
90
|
-
render: (data, type) => {
|
91
|
-
if (type === "display" || type === "filter") {
|
92
|
-
return secondsToRecentDate(data);
|
93
|
-
}
|
94
|
-
return data;
|
95
|
-
},
|
96
|
-
},
|
97
|
-
],
|
81
|
+
columns: columnConfig.columns,
|
98
82
|
layout: {
|
99
83
|
top1Start: {
|
100
84
|
buttons: [
|