goosebit 0.2.3__py3-none-any.whl → 0.2.4__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 (40) hide show
  1. goosebit/__init__.py +16 -3
  2. goosebit/api/v1/devices/device/routes.py +8 -2
  3. goosebit/api/v1/devices/responses.py +0 -7
  4. goosebit/api/v1/devices/routes.py +2 -1
  5. goosebit/api/v1/rollouts/responses.py +2 -7
  6. goosebit/api/v1/rollouts/routes.py +7 -3
  7. goosebit/api/v1/software/responses.py +0 -7
  8. goosebit/api/v1/software/routes.py +24 -11
  9. goosebit/auth/__init__.py +7 -7
  10. goosebit/db/__init__.py +12 -1
  11. goosebit/db/models.py +13 -2
  12. goosebit/schema/devices.py +41 -37
  13. goosebit/schema/rollouts.py +21 -18
  14. goosebit/schema/software.py +24 -19
  15. goosebit/ui/bff/common/__init__.py +0 -0
  16. goosebit/ui/bff/common/requests.py +56 -0
  17. goosebit/ui/bff/common/util.py +32 -0
  18. goosebit/ui/bff/devices/responses.py +12 -20
  19. goosebit/ui/bff/devices/routes.py +9 -6
  20. goosebit/ui/bff/rollouts/responses.py +12 -20
  21. goosebit/ui/bff/rollouts/routes.py +8 -6
  22. goosebit/ui/bff/software/responses.py +19 -19
  23. goosebit/ui/bff/software/routes.py +29 -16
  24. goosebit/ui/nav.py +1 -1
  25. goosebit/ui/routes.py +4 -4
  26. goosebit/ui/static/js/devices.js +135 -25
  27. goosebit/ui/static/js/rollouts.js +4 -0
  28. goosebit/ui/static/js/util.js +23 -14
  29. goosebit/ui/templates/devices.html.jinja +77 -29
  30. goosebit/ui/templates/nav.html.jinja +22 -2
  31. goosebit/ui/templates/rollouts.html.jinja +23 -23
  32. goosebit/updater/controller/v1/routes.py +7 -3
  33. goosebit/updater/controller/v1/schema.py +4 -4
  34. goosebit/updater/manager.py +16 -8
  35. goosebit/updates/__init__.py +14 -21
  36. goosebit/updates/swdesc.py +35 -14
  37. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/METADATA +11 -3
  38. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/RECORD +40 -37
  39. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/LICENSE +0 -0
  40. {goosebit-0.2.3.dist-info → goosebit-0.2.4.dist-info}/WHEEL +0 -0
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ from typing import Callable
4
4
 
5
- from fastapi.requests import Request
6
5
  from pydantic import BaseModel, Field
6
+ from tortoise.queryset import QuerySet
7
7
 
8
8
  from goosebit.schema.devices import DeviceSchema
9
+ from goosebit.ui.bff.common.requests import DataTableRequest
9
10
 
10
11
 
11
12
  class BFFDeviceResponse(BaseModel):
@@ -15,25 +16,16 @@ class BFFDeviceResponse(BaseModel):
15
16
  records_filtered: int = Field(serialization_alias="recordsFiltered")
16
17
 
17
18
  @classmethod
18
- async def convert(cls, request: Request, query, search_filter, total_records):
19
- params = request.query_params
19
+ async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
20
+ total_records = await query.count()
21
+ if dt_query.search.value:
22
+ query = query.filter(search_filter(dt_query.search.value))
20
23
 
21
- draw = int(params.get("draw", 1))
22
- start = int(params.get("start", 0))
23
- length = int(params.get("length", 10))
24
- search_value = params.get("search[value]", None)
25
- order_column_index = params.get("order[0][column]", None)
26
- order_column = params.get(f"columns[{order_column_index}][data]", None)
27
- order_dir = params.get("order[0][dir]", None)
28
-
29
- if search_value:
30
- query = query.filter(search_filter(search_value))
31
-
32
- if order_column:
33
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
24
+ if dt_query.order_query:
25
+ query = query.order_by(dt_query.order_query)
34
26
 
35
27
  filtered_records = await query.count()
36
- devices = await query.offset(start).limit(length).all()
37
- data = await asyncio.gather(*[DeviceSchema.convert(d) for d in devices])
28
+ devices = await query.offset(dt_query.start).limit(dt_query.length).all()
29
+ data = [DeviceSchema.model_validate(d) for d in devices]
38
30
 
39
- return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
31
+ return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fastapi import APIRouter, Security
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Depends, Security
4
6
  from fastapi.requests import Request
5
7
  from tortoise.expressions import Q
6
8
 
@@ -8,6 +10,8 @@ from goosebit.api.responses import StatusResponse
8
10
  from goosebit.api.v1.devices import routes
9
11
  from goosebit.auth import validate_user_permissions
10
12
  from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum
13
+ from goosebit.ui.bff.common.requests import DataTableRequest
14
+ from goosebit.ui.bff.common.util import parse_datatables_query
11
15
  from goosebit.updater.manager import get_update_manager
12
16
 
13
17
  from .requests import DevicesPatchRequest
@@ -20,8 +24,8 @@ router = APIRouter(prefix="/devices")
20
24
  "",
21
25
  dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
26
  )
23
- async def devices_get(request: Request) -> BFFDeviceResponse:
24
- def search_filter(search_value):
27
+ async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse:
28
+ def search_filter(search_value: str):
25
29
  return (
26
30
  Q(uuid__icontains=search_value)
27
31
  | Q(name__icontains=search_value)
@@ -31,10 +35,9 @@ async def devices_get(request: Request) -> BFFDeviceResponse:
31
35
  | Q(last_state=int(UpdateStateEnum.from_str(search_value)))
32
36
  )
33
37
 
34
- query = Device.all().prefetch_related("assigned_software", "hardware")
35
- total_records = await Device.all().count()
38
+ query = Device.all().prefetch_related("assigned_software", "hardware", "assigned_software__compatibility")
36
39
 
37
- return await BFFDeviceResponse.convert(request, query, search_filter, total_records)
40
+ return await BFFDeviceResponse.convert(dt_query, query, search_filter)
38
41
 
39
42
 
40
43
  @router.patch(
@@ -1,9 +1,10 @@
1
- import asyncio
1
+ from typing import Callable
2
2
 
3
- from fastapi.requests import Request
4
3
  from pydantic import BaseModel, Field
4
+ from tortoise.queryset import QuerySet
5
5
 
6
6
  from goosebit.schema.rollouts import RolloutSchema
7
+ from goosebit.ui.bff.common.requests import DataTableRequest
7
8
 
8
9
 
9
10
  class BFFRolloutsResponse(BaseModel):
@@ -13,25 +14,16 @@ class BFFRolloutsResponse(BaseModel):
13
14
  records_filtered: int = Field(serialization_alias="recordsFiltered")
14
15
 
15
16
  @classmethod
16
- async def convert(cls, request: Request, query, search_filter, total_records):
17
- params = request.query_params
17
+ async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable):
18
+ total_records = await query.count()
19
+ if dt_query.search.value:
20
+ query = query.filter(search_filter(dt_query.search.value))
18
21
 
19
- draw = int(params.get("draw", 1))
20
- start = int(params.get("start", 0))
21
- length = int(params.get("length", 10))
22
- search_value = params.get("search[value]", None)
23
- order_column_index = params.get("order[0][column]", None)
24
- order_column = params.get(f"columns[{order_column_index}][data]", None)
25
- order_dir = params.get("order[0][dir]", None)
26
-
27
- if search_value:
28
- query = query.filter(search_filter(search_value))
29
-
30
- if order_column:
31
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
22
+ if dt_query.order_query:
23
+ query = query.order_by(dt_query.order_query)
32
24
 
33
25
  filtered_records = await query.count()
34
- rollouts = await query.offset(start).limit(length).all()
35
- data = await asyncio.gather(*[RolloutSchema.convert(r) for r in rollouts])
26
+ rollouts = await query.offset(dt_query.start).limit(dt_query.length).all()
27
+ data = [RolloutSchema.model_validate(r) for r in rollouts]
36
28
 
37
- return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
29
+ return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -1,10 +1,13 @@
1
- from fastapi import APIRouter, Security
2
- from fastapi.requests import Request
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends, Security
3
4
  from tortoise.expressions import Q
4
5
 
5
6
  from goosebit.api.v1.rollouts import routes
6
7
  from goosebit.auth import validate_user_permissions
7
8
  from goosebit.db.models import Rollout
9
+ from goosebit.ui.bff.common.requests import DataTableRequest
10
+ from goosebit.ui.bff.common.util import parse_datatables_query
8
11
 
9
12
  from .responses import BFFRolloutsResponse
10
13
 
@@ -15,14 +18,13 @@ router = APIRouter(prefix="/rollouts")
15
18
  "",
16
19
  dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
20
  )
18
- async def rollouts_get(request: Request) -> BFFRolloutsResponse:
21
+ async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse:
19
22
  def search_filter(search_value):
20
23
  return Q(name__icontains=search_value) | Q(feed__icontains=search_value)
21
24
 
22
- query = Rollout.all().prefetch_related("software")
23
- total_records = await Rollout.all().count()
25
+ query = Rollout.all().prefetch_related("software", "software__compatibility")
24
26
 
25
- return await BFFRolloutsResponse.convert(request, query, search_filter, total_records)
27
+ return await BFFRolloutsResponse.convert(dt_query, query, search_filter)
26
28
 
27
29
 
28
30
  router.add_api_route(
@@ -1,9 +1,11 @@
1
- import asyncio
1
+ from typing import Callable
2
2
 
3
- from fastapi.requests import Request
4
3
  from pydantic import BaseModel, Field
4
+ from tortoise.expressions import Q
5
+ from tortoise.queryset import QuerySet
5
6
 
6
7
  from goosebit.schema.software import SoftwareSchema
8
+ from goosebit.ui.bff.common.requests import DataTableRequest
7
9
 
8
10
 
9
11
  class BFFSoftwareResponse(BaseModel):
@@ -13,25 +15,23 @@ class BFFSoftwareResponse(BaseModel):
13
15
  records_filtered: int = Field(serialization_alias="recordsFiltered")
14
16
 
15
17
  @classmethod
16
- async def convert(cls, request: Request, query, search_filter, total_records):
17
- params = request.query_params
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))
18
23
 
19
- draw = int(params.get("draw", 1))
20
- start = int(params.get("start", 0))
21
- length = int(params.get("length", 10))
22
- search_value = params.get("search[value]", None)
23
- order_column_index = params.get("order[0][column]", None)
24
- order_column = params.get(f"columns[{order_column_index}][data]", None)
25
- order_dir = params.get("order[0][dir]", None)
24
+ if dt_query.order_query:
25
+ query = query.order_by(dt_query.order_query)
26
26
 
27
- if search_value:
28
- query = query.filter(search_filter(search_value))
27
+ filtered_records = await query.count()
29
28
 
30
- if order_column:
31
- query = query.order_by(f"{'-' if order_dir == 'desc' else ''}{order_column}")
29
+ query = query.offset(dt_query.start)
32
30
 
33
- filtered_records = await query.count()
34
- devices = await query.offset(start).limit(length).all()
35
- data = await asyncio.gather(*[SoftwareSchema.convert(d) for d in devices])
31
+ if not dt_query.length == 0:
32
+ query = query.limit(dt_query.length)
33
+
34
+ devices = await query.all()
35
+ data = [SoftwareSchema.model_validate(d) for d in devices]
36
36
 
37
- return cls(data=data, draw=draw, records_total=total_records, records_filtered=filtered_records)
37
+ return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records)
@@ -1,14 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
- import aiofiles
4
- from fastapi import APIRouter, Form, HTTPException, Security, UploadFile
3
+ from typing import Annotated
4
+
5
+ from anyio import Path, open_file
6
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Security, UploadFile
5
7
  from fastapi.requests import Request
6
8
  from tortoise.expressions import Q
7
9
 
8
10
  from goosebit.api.v1.software import routes
9
11
  from goosebit.auth import validate_user_permissions
10
- from goosebit.db.models import Rollout, Software
12
+ from goosebit.db.models import Hardware, Rollout, Software
11
13
  from goosebit.settings import config
14
+ from goosebit.ui.bff.common.requests import DataTableRequest
15
+ from goosebit.ui.bff.common.util import parse_datatables_query
12
16
  from goosebit.updates import create_software_update
13
17
 
14
18
  from .responses import BFFSoftwareResponse
@@ -20,14 +24,23 @@ router = APIRouter(prefix="/software")
20
24
  "",
21
25
  dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
26
  )
23
- async def software_get(request: Request) -> BFFSoftwareResponse:
27
+ async def software_get(
28
+ dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)],
29
+ uuids: list[str] = Query(default=None),
30
+ ) -> BFFSoftwareResponse:
31
+ filters: list[Q] = []
32
+
24
33
  def search_filter(search_value):
25
- return Q(uri__icontains=search_value) | Q(version__icontains=search_value)
34
+ base_filter = Q(Q(uri__icontains=search_value), Q(version__icontains=search_value), join_type="OR")
35
+ return Q(base_filter, *filters, join_type="AND")
26
36
 
27
37
  query = Software.all().prefetch_related("compatibility")
28
- total_records = await Software.all().count()
29
38
 
30
- return await BFFSoftwareResponse.convert(request, query, search_filter, total_records)
39
+ if uuids:
40
+ hardware = await Hardware.filter(devices__uuid__in=uuids).distinct()
41
+ filters.append(Q(*[Q(compatibility__id=c.id) for c in hardware], join_type="AND"))
42
+
43
+ return await BFFSoftwareResponse.convert(dt_query, query, search_filter, Q(*filters))
31
44
 
32
45
 
33
46
  router.add_api_route(
@@ -64,20 +77,20 @@ async def post_update(
64
77
  await create_software_update(url, None)
65
78
  else:
66
79
  # local file
67
- file = config.artifacts_dir.joinpath(filename)
68
- config.artifacts_dir.mkdir(parents=True, exist_ok=True)
80
+ artifacts_dir = Path(config.artifacts_dir)
81
+ file = artifacts_dir.joinpath(filename)
82
+ await artifacts_dir.mkdir(parents=True, exist_ok=True)
69
83
 
70
84
  temp_file = file.with_suffix(".tmp")
71
85
  if init:
72
- temp_file.unlink(missing_ok=True)
73
-
74
- contents = await chunk.read()
86
+ await temp_file.unlink(missing_ok=True)
75
87
 
76
- async with aiofiles.open(temp_file, mode="ab") as f:
77
- await f.write(contents)
88
+ async with await open_file(temp_file, "ab") as f:
89
+ await f.write(await chunk.read())
78
90
 
79
91
  if done:
80
92
  try:
81
- await create_software_update(file.absolute().as_uri(), temp_file)
93
+ absolute = await file.absolute()
94
+ await create_software_update(absolute.as_uri(), temp_file)
82
95
  finally:
83
- temp_file.unlink(missing_ok=True)
96
+ await temp_file.unlink(missing_ok=True)
goosebit/ui/nav.py CHANGED
@@ -2,7 +2,7 @@ class Navigation:
2
2
  def __init__(self):
3
3
  self.items = []
4
4
 
5
- def route(self, text: str, permissions: str = None):
5
+ def route(self, text: str, permissions: str | None = None):
6
6
  def decorator(func):
7
7
  self.items.append({"function": func.__name__, "text": text, "permissions": permissions})
8
8
  return func
goosebit/ui/routes.py CHANGED
@@ -24,7 +24,7 @@ async def ui_root(request: Request):
24
24
  "/home",
25
25
  dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
26
26
  )
27
- @nav.route("Home", permissions=["home.read"])
27
+ @nav.route("Home", permissions="home.read")
28
28
  async def home_ui(request: Request):
29
29
  return templates.TemplateResponse(request, "index.html.jinja", context={"title": "Home"})
30
30
 
@@ -33,7 +33,7 @@ async def home_ui(request: Request):
33
33
  "/devices",
34
34
  dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
35
35
  )
36
- @nav.route("Devices", permissions=["device.read"])
36
+ @nav.route("Devices", permissions="device.read")
37
37
  async def devices_ui(request: Request):
38
38
  return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"})
39
39
 
@@ -42,7 +42,7 @@ async def devices_ui(request: Request):
42
42
  "/software",
43
43
  dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
44
44
  )
45
- @nav.route("Software", permissions=["software.read"])
45
+ @nav.route("Software", permissions="software.read")
46
46
  async def software_ui(request: Request):
47
47
  return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"})
48
48
 
@@ -51,7 +51,7 @@ async def software_ui(request: Request):
51
51
  "/rollouts",
52
52
  dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
53
53
  )
54
- @nav.route("Rollouts", permissions=["rollout.read"])
54
+ @nav.route("Rollouts", permissions="rollout.read")
55
55
  async def rollouts_ui(request: Request):
56
56
  return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"})
57
57
 
@@ -124,20 +124,11 @@ document.addEventListener("DOMContentLoaded", async () => {
124
124
  {
125
125
  text: '<i class="bi bi-pen" ></i>',
126
126
  action: () => {
127
- const selectedDevice = dataTable.rows({ selected: true }).data().toArray()[0];
128
- $("#device-selected-name").val(selectedDevice.name);
127
+ const selectedDevices = dataTable.rows({ selected: true }).data().toArray();
128
+ const selectedDevice = selectedDevices[0];
129
+ updateSoftwareSelection(selectedDevices);
130
+ $("#device-name").val(selectedDevice.name);
129
131
  $("#device-selected-feed").val(selectedDevice.feed);
130
-
131
- let selectedValue;
132
- if (selectedDevice.update_mode === "Rollout") {
133
- selectedValue = "rollout";
134
- } else if (selectedDevice.update_mode === "Latest") {
135
- selectedValue = "latest";
136
- } else {
137
- selectedValue = selectedDevice.sw_assigned;
138
- }
139
- $("#selected-sw").val(selectedValue);
140
-
141
132
  new bootstrap.Modal("#device-config-modal").show();
142
133
  },
143
134
  className: "buttons-config",
@@ -199,22 +190,89 @@ document.addEventListener("DOMContentLoaded", async () => {
199
190
  dataTable.ajax.reload(null, false);
200
191
  }, TABLE_UPDATE_TIME);
201
192
 
202
- await updateSoftwareSelection(true);
193
+ await updateSoftwareSelection();
194
+
195
+ // Name update form submit
196
+ const nameForm = document.getElementById("device-name-form");
197
+ nameForm.addEventListener(
198
+ "submit",
199
+ async (event) => {
200
+ if (nameForm.checkValidity() === false) {
201
+ event.preventDefault();
202
+ event.stopPropagation();
203
+ nameForm.classList.add("was-validated");
204
+ } else {
205
+ event.preventDefault();
206
+ await updateDeviceName();
207
+ nameForm.classList.remove("was-validated");
208
+ nameForm.reset();
209
+ const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
210
+ modal.hide();
211
+ }
212
+ },
213
+ false,
214
+ );
215
+
216
+ // Rollout form submit
217
+ const rolloutForm = document.getElementById("device-software-rollout-form");
218
+ rolloutForm.addEventListener(
219
+ "submit",
220
+ async (event) => {
221
+ if (rolloutForm.checkValidity() === false) {
222
+ event.preventDefault();
223
+ event.stopPropagation();
224
+ rolloutForm.classList.add("was-validated");
225
+ } else {
226
+ event.preventDefault();
227
+ await updateDeviceRollout();
228
+ rolloutForm.classList.remove("was-validated");
229
+ rolloutForm.reset();
230
+ const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
231
+ modal.hide();
232
+ }
233
+ },
234
+ false,
235
+ );
236
+
237
+ // Manual software form submit
238
+ const manualSoftwareForm = document.getElementById("device-software-manual-form");
239
+ manualSoftwareForm.addEventListener(
240
+ "submit",
241
+ async (event) => {
242
+ if (manualSoftwareForm.checkValidity() === false) {
243
+ event.preventDefault();
244
+ event.stopPropagation();
245
+ manualSoftwareForm.classList.add("was-validated");
246
+ if (document.getElementById("selected-sw").value === "") {
247
+ document.getElementById("selected-sw").parentElement.classList.add("is-invalid");
248
+ }
249
+ } else {
250
+ event.preventDefault();
251
+ await updateDeviceManualSoftware();
252
+ manualSoftwareForm.classList.remove("was-validated");
253
+ document.getElementById("selected-sw").parentElement.classList.remove("is-invalid");
254
+ manualSoftwareForm.reset();
255
+ const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
256
+ modal.hide();
257
+ }
258
+ },
259
+ false,
260
+ );
203
261
 
204
- // Config form submit
205
- const configForm = document.getElementById("device-config-form");
206
- configForm.addEventListener(
262
+ // Latest software form submit
263
+ const latestSoftwareForm = document.getElementById("device-software-latest-form");
264
+ latestSoftwareForm.addEventListener(
207
265
  "submit",
208
266
  async (event) => {
209
- if (configForm.checkValidity() === false) {
267
+ if (latestSoftwareForm.checkValidity() === false) {
210
268
  event.preventDefault();
211
269
  event.stopPropagation();
212
- configForm.classList.add("was-validated");
270
+ latestSoftwareForm.classList.add("was-validated");
213
271
  } else {
214
272
  event.preventDefault();
215
- await updateDeviceConfig();
216
- configForm.classList.remove("was-validated");
217
- configForm.reset();
273
+ await updateDeviceLatest();
274
+ latestSoftwareForm.classList.remove("was-validated");
275
+ latestSoftwareForm.reset();
218
276
  const modal = bootstrap.Modal.getInstance(document.getElementById("device-config-modal"));
219
277
  modal.hide();
220
278
  }
@@ -244,18 +302,70 @@ function updateBtnState() {
244
302
  }
245
303
  }
246
304
 
247
- async function updateDeviceConfig() {
305
+ async function updateDeviceName() {
306
+ const devices = dataTable
307
+ .rows({ selected: true })
308
+ .data()
309
+ .toArray()
310
+ .map((d) => d.uuid);
311
+ const name = document.getElementById("device-name").value;
312
+
313
+ try {
314
+ await patch_request("/ui/bff/devices", { devices, name });
315
+ } catch (error) {
316
+ console.error("Update device config failed:", error);
317
+ }
318
+
319
+ setTimeout(updateDeviceList, 50);
320
+ }
321
+
322
+ async function updateDeviceRollout() {
248
323
  const devices = dataTable
249
324
  .rows({ selected: true })
250
325
  .data()
251
326
  .toArray()
252
327
  .map((d) => d.uuid);
253
- const name = document.getElementById("device-selected-name").value;
254
328
  const feed = document.getElementById("device-selected-feed").value;
329
+ const software = "rollout";
330
+
331
+ try {
332
+ await patch_request("/ui/bff/devices", { devices, feed, software });
333
+ } catch (error) {
334
+ console.error("Update device config failed:", error);
335
+ }
336
+
337
+ setTimeout(updateDeviceList, 50);
338
+ }
339
+
340
+ async function updateDeviceManualSoftware() {
341
+ const devices = dataTable
342
+ .rows({ selected: true })
343
+ .data()
344
+ .toArray()
345
+ .map((d) => d.uuid);
346
+ const feed = null;
255
347
  const software = document.getElementById("selected-sw").value;
256
348
 
257
349
  try {
258
- await patch_request("/ui/bff/devices", { devices, name, feed, software });
350
+ await patch_request("/ui/bff/devices", { devices, feed, software });
351
+ } catch (error) {
352
+ console.error("Update device config failed:", error);
353
+ }
354
+
355
+ setTimeout(updateDeviceList, 50);
356
+ }
357
+
358
+ async function updateDeviceLatest() {
359
+ const devices = dataTable
360
+ .rows({ selected: true })
361
+ .data()
362
+ .toArray()
363
+ .map((d) => d.uuid);
364
+ const feed = null;
365
+ const software = "latest";
366
+
367
+ try {
368
+ await patch_request("/ui/bff/devices", { devices, feed, software });
259
369
  } catch (error) {
260
370
  console.error("Update device config failed:", error);
261
371
  }
@@ -136,6 +136,9 @@ document.addEventListener("DOMContentLoaded", async () => {
136
136
  "submit",
137
137
  (event) => {
138
138
  if (form.checkValidity() === false) {
139
+ if (document.getElementById("selected-sw").value === "") {
140
+ document.getElementById("selected-sw").parentElement.classList.add("is-invalid");
141
+ }
139
142
  event.preventDefault();
140
143
  event.stopPropagation();
141
144
  form.classList.add("was-validated");
@@ -143,6 +146,7 @@ document.addEventListener("DOMContentLoaded", async () => {
143
146
  event.preventDefault();
144
147
  createRollout();
145
148
  form.classList.remove("was-validated");
149
+ document.getElementById("selected-sw").parentElement.classList.remove("is-invalid");
146
150
  form.reset();
147
151
  const modal = bootstrap.Modal.getInstance(document.getElementById("rollout-create-modal"));
148
152
  modal.hide();
@@ -20,27 +20,22 @@ function secondsToRecentDate(t) {
20
20
  return s + (s === 1 ? " second" : " seconds");
21
21
  }
22
22
 
23
- async function updateSoftwareSelection(addSpecialMode = false) {
23
+ async function updateSoftwareSelection(devices = null) {
24
24
  try {
25
- const response = await fetch("/ui/bff/software");
25
+ const url = new URL("/ui/bff/software", window.location.origin);
26
+ if (devices != null) {
27
+ for (const device of devices) {
28
+ url.searchParams.append("uuids", device.uuid);
29
+ }
30
+ }
31
+ const response = await fetch(url.toString());
26
32
  if (!response.ok) {
27
33
  console.error("Retrieving software list failed.");
28
34
  return;
29
35
  }
30
36
  const data = (await response.json()).data;
31
37
  const selectElem = document.getElementById("selected-sw");
32
-
33
- if (addSpecialMode) {
34
- let optionElem = document.createElement("option");
35
- optionElem.value = "rollout";
36
- optionElem.textContent = "Rollout";
37
- selectElem.appendChild(optionElem);
38
-
39
- optionElem = document.createElement("option");
40
- optionElem.value = "latest";
41
- optionElem.textContent = "Latest";
42
- selectElem.appendChild(optionElem);
43
- }
38
+ selectElem.innerHTML = "";
44
39
 
45
40
  for (const item of data) {
46
41
  const optionElem = document.createElement("option");
@@ -50,6 +45,20 @@ async function updateSoftwareSelection(addSpecialMode = false) {
50
45
  optionElem.textContent = `${item.version} (${models})`;
51
46
  selectElem.appendChild(optionElem);
52
47
  }
48
+ $("#selected-sw").selectpicker("destroy");
49
+ if (data.length === 0) {
50
+ selectElem.title = "No valid software found for selected device";
51
+ if (devices != null) {
52
+ if (devices.length > 1) {
53
+ selectElem.title += "s";
54
+ }
55
+ }
56
+ selectElem.disabled = true;
57
+ } else {
58
+ selectElem.disabled = false;
59
+ selectElem.title = "Select Software";
60
+ }
61
+ $("#selected-sw").selectpicker();
53
62
  } catch (error) {
54
63
  console.error("Failed to fetch device data:", error);
55
64
  }