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.
Files changed (34) hide show
  1. goosebit/__init__.py +16 -0
  2. goosebit/api/v1/devices/device/routes.py +2 -2
  3. goosebit/api/v1/devices/routes.py +19 -4
  4. goosebit/auth/__init__.py +5 -1
  5. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  6. goosebit/db/models.py +6 -2
  7. goosebit/realtime/logs.py +1 -1
  8. goosebit/schema/devices.py +1 -1
  9. goosebit/settings/schema.py +2 -0
  10. goosebit/ui/bff/common/requests.py +3 -15
  11. goosebit/ui/bff/common/responses.py +16 -0
  12. goosebit/ui/bff/devices/responses.py +6 -2
  13. goosebit/ui/bff/devices/routes.py +53 -2
  14. goosebit/ui/bff/rollouts/responses.py +6 -2
  15. goosebit/ui/bff/routes.py +4 -2
  16. goosebit/ui/bff/software/responses.py +19 -9
  17. goosebit/ui/routes.py +7 -16
  18. goosebit/ui/static/js/devices.js +53 -69
  19. goosebit/ui/static/js/rollouts.js +16 -13
  20. goosebit/ui/static/js/software.js +5 -11
  21. goosebit/ui/static/js/util.js +21 -1
  22. goosebit/ui/templates/devices.html.jinja +0 -20
  23. goosebit/ui/templates/nav.html.jinja +13 -2
  24. goosebit/updater/controller/v1/routes.py +26 -20
  25. goosebit/updater/manager.py +20 -52
  26. goosebit/updater/routes.py +6 -2
  27. goosebit/updates/swdesc.py +1 -1
  28. {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/METADATA +14 -6
  29. {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/RECORD +32 -31
  30. {goosebit-0.2.4.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
  31. goosebit-0.2.5.dist-info/entry_points.txt +3 -0
  32. goosebit/ui/static/js/index.js +0 -155
  33. goosebit/ui/templates/index.html.jinja +0 -25
  34. {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=["home.read"])],
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=["home.read"])],
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.updater.manager import delete_devices
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=["home.read"])],
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", "hardware")
24
- return DevicesResponse(devices=devices)
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
- oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
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=["home.read"])],
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
@@ -52,6 +52,8 @@ class GooseBitSettings(BaseSettings):
52
52
 
53
53
  logging: dict = LOGGING_DEFAULT
54
54
 
55
+ track_device_ip: bool = True
56
+
55
57
  @classmethod
56
58
  def settings_customise_sources(
57
59
  cls,
@@ -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 = 0
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
- column = self.order[0].column
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.columns[column].data}"
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
- filtered_records = await query.count()
28
- devices = await query.offset(dt_query.start).limit(dt_query.length).all()
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=["home.read"])],
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
- return await BFFDeviceResponse.convert(dt_query, query, search_filter)
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
- filtered_records = await query.count()
26
- rollouts = await query.offset(dt_query.start).limit(dt_query.length).all()
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
- query = query.offset(dt_query.start)
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
- if not dt_query.length == 0:
32
- query = query.limit(dt_query.length)
43
+ software = await query.offset(dt_query.start).all()
33
44
 
34
- devices = await query.all()
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", dependencies=[Depends(redirect_if_unauthenticated)], include_in_schema=False)
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("home_ui"))
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})
@@ -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: [