diff --git a/webclient/src/api/modules/firmwares.js b/webclient/src/api/modules/firmwares.js
new file mode 100644
index 0000000..0eaf5af
--- /dev/null
+++ b/webclient/src/api/modules/firmwares.js
@@ -0,0 +1,30 @@
+import { apiGet, apiPost } from "../client";
+
+function safeId(id) {
+ return encodeURIComponent(String(id));
+}
+
+export const firmwaresApi = {
+ async list() {
+ return apiGet("/api/v1/firmwares");
+ },
+
+ async detail(id) {
+ return apiGet(`/api/v1/firmwares/id/${safeId(id)}`);
+ },
+
+ async refresh() {
+ return apiPost("/api/v1/firmwares/refresh");
+ },
+
+ async deviceCompatibility(deviceId) {
+ return apiGet(`/api/v1/devices/id/${safeId(deviceId)}/firmware-compatibility`);
+ },
+
+ async updateDeviceFirmware(deviceId, firmwareId) {
+ return apiPost("/api/v1/devices/update-firmware", {
+ device_id: deviceId,
+ firmware_id: firmwareId,
+ });
+ },
+};
diff --git a/webclient/src/components/layout/AppShell.vue b/webclient/src/components/layout/AppShell.vue
index c7dcc6d..869dcd1 100644
--- a/webclient/src/components/layout/AppShell.vue
+++ b/webclient/src/components/layout/AppShell.vue
@@ -32,6 +32,7 @@
"scripts-regular": "Regular",
"scripts-scopes": "Scopes",
"script-detail": "Script",
+ firmwares: "Firmwares",
};
const pageTitle = computed(() => {
@@ -50,5 +51,6 @@
{ label: "Actions", to: "/scripts/actions", icon: "ph-play" },
{ label: "Regular", to: "/scripts/regular", icon: "ph-clock" },
{ label: "Scopes", to: "/scripts/scopes", icon: "ph-brackets-curly" },
+ { label: "Firmwares", to: "/firmwares", icon: "ph-cloud-arrow-down" },
];
diff --git a/webclient/src/features/devices/pages/DeviceDetailPage.vue b/webclient/src/features/devices/pages/DeviceDetailPage.vue
index b6f5e16..087e7ca 100644
--- a/webclient/src/features/devices/pages/DeviceDetailPage.vue
+++ b/webclient/src/features/devices/pages/DeviceDetailPage.vue
@@ -78,6 +78,17 @@
@assign="openAssign"
/>
+
Description
{{ device.description }}
@@ -205,6 +216,41 @@
+
+
+ Select firmware to install on {{ device?.name || device?.alias }}:
+
+
+
{{ fw.version }}
+
{{ fw.description }}
+
+
+
+ {{ firmwareUpdateError }}
+
+
+ Cancel
+
+ Update
+
+
+
@@ -213,6 +259,7 @@
import { useRoute, useRouter } from "vue-router";
import { useDevicesStore } from "../../../stores/devices";
import { useAreasStore } from "../../../stores/areas";
+import { useFirmwaresStore } from "../../../stores/firmwares";
import { useAreaAssign } from "../../../composables/useAreaAssign";
import {
GnPageHeader,
@@ -236,6 +283,7 @@
const router = useRouter();
const devicesStore = useDevicesStore();
const areasStore = useAreasStore();
+const firmwaresStore = useFirmwaresStore();
const toast = useToast();
const {
@@ -288,6 +336,18 @@
const resetLoading = ref(false);
const resetError = ref("");
+const showFirmwareUpdateModal = ref(false);
+const selectedFirmwareId = ref(null);
+const firmwareUpdateError = ref("");
+
+const hasCompatibleFirmwares = computed(() => {
+ return (firmwaresStore.compatibility?.compatible?.length || 0) > 0;
+});
+
+const compatibleFirmwares = computed(() => {
+ return firmwaresStore.compatibility?.compatible || [];
+});
+
const deviceActions = computed(() => {
const actions = [{ label: "Edit", icon: "ph-pencil", onSelect: openEdit }];
@@ -470,6 +530,24 @@
router.push({ name: "devices" });
}
+function openFirmwareUpdate() {
+ selectedFirmwareId.value = compatibleFirmwares.value[0]?.id || null;
+ firmwareUpdateError.value = "";
+ showFirmwareUpdateModal.value = true;
+}
+
+async function submitFirmwareUpdate() {
+ if (!selectedFirmwareId.value || !device.value) return;
+ const result = await firmwaresStore.updateDeviceFirmware(device.value.id, selectedFirmwareId.value);
+ if (!result.ok) {
+ firmwareUpdateError.value = result.error?.message || "Firmware update failed";
+ return;
+ }
+ showFirmwareUpdateModal.value = false;
+ toast.success({ title: "Updated", text: "Firmware update pushed successfully" });
+ await devicesStore.loadDeviceDetail(deviceId.value);
+}
+
function systemStatusVariant(status) {
const map = {
active: "success",
@@ -509,6 +587,7 @@
if (device.value?.connection_status === "active") {
await loadStatus();
+ await firmwaresStore.loadDeviceCompatibility(id);
}
}
@@ -518,6 +597,7 @@
onUnmounted(() => {
devicesStore.clearDeviceDetail();
+ firmwaresStore.clearCompatibility();
});
watch(() => route.params.id, (newId, oldId) => {
@@ -577,4 +657,34 @@
.form-group {
margin-bottom: 16px;
}
+
+.firmware-options {
+ display: grid;
+ gap: 8px;
+ margin: 12px 0;
+}
+
+.firmware-option {
+ padding: 10px 12px;
+ border: 1px solid rgba(192, 202, 245, 0.12);
+ border-radius: 6px;
+ cursor: pointer;
+ background: var(--color-panel);
+}
+
+.firmware-option.active {
+ border-color: var(--color-primary);
+ background: rgba(59, 130, 246, 0.1);
+}
+
+.fw-version {
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.fw-desc {
+ font-size: 12px;
+ color: var(--color-text-muted);
+ margin-top: 2px;
+}
diff --git a/webclient/src/features/firmwares/pages/FirmwaresListPage.vue b/webclient/src/features/firmwares/pages/FirmwaresListPage.vue
new file mode 100644
index 0000000..2c153ef
--- /dev/null
+++ b/webclient/src/features/firmwares/pages/FirmwaresListPage.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+ Refresh Catalog
+
+
+
+
+
+
+
+
+
+
+
+
+ Total: {{ firmwaresStore.firmwares.length }}
+
+
+
+
+
+
+
+ {{ fw.device_type }}
+ {{ fw.platform }}
+ {{ fw.channels }} ch
+
+
+
{{ fw.description }}
+
{{ fw.changelog }}
+
+
+
+
+
+
+
+
+
diff --git a/webclient/src/router/routes.js b/webclient/src/router/routes.js
index 41fe5fc..63c423a 100644
--- a/webclient/src/router/routes.js
+++ b/webclient/src/router/routes.js
@@ -8,6 +8,7 @@
import ScriptsRegularPage from "../features/scripts/pages/ScriptsRegularPage.vue";
import ScriptsScopesPage from "../features/scripts/pages/ScriptsScopesPage.vue";
import ScriptDetailPage from "../features/scripts/pages/ScriptDetailPage.vue";
+import FirmwaresListPage from "../features/firmwares/pages/FirmwaresListPage.vue";
export const routes = [
{
@@ -65,6 +66,11 @@
component: ScriptDetailPage,
},
{
+ path: "/firmwares",
+ name: "firmwares",
+ component: FirmwaresListPage,
+ },
+ {
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("../features/system/NotFoundPage.vue"),
diff --git a/webclient/src/stores/firmwares.js b/webclient/src/stores/firmwares.js
new file mode 100644
index 0000000..36169a9
--- /dev/null
+++ b/webclient/src/stores/firmwares.js
@@ -0,0 +1,89 @@
+import { ref, computed } from "vue";
+import { defineStore } from "pinia";
+import { firmwaresApi } from "../api/modules/firmwares";
+import { useAsyncRequest } from "../composables/useAsyncRequest";
+
+export const useFirmwaresStore = defineStore("firmwares", () => {
+ const firmwares = ref([]);
+ const current = ref(null);
+ const compatibility = ref(null);
+
+ const listRequest = useAsyncRequest();
+ const detailRequest = useAsyncRequest();
+ const actionRequest = useAsyncRequest();
+ const compatibilityRequest = useAsyncRequest();
+
+ const isLoadingList = computed(() => listRequest.isLoading.value);
+ const isLoadingDetail = computed(() => detailRequest.isLoading.value);
+ const isUpdating = computed(() => actionRequest.isLoading.value);
+ const isLoadingCompatibility = computed(() => compatibilityRequest.isLoading.value);
+
+ async function loadFirmwares() {
+ return listRequest.execute(async (signal) => {
+ const result = await firmwaresApi.list({ signal });
+ if (result.ok) {
+ firmwares.value = result.data?.data?.firmwares || [];
+ }
+ return result;
+ });
+ }
+
+ async function refreshFirmwares() {
+ return actionRequest.execute(async () => {
+ const result = await firmwaresApi.refresh();
+ if (result.ok) {
+ await loadFirmwares();
+ }
+ return result;
+ });
+ }
+
+ async function loadFirmwareDetail(id) {
+ return detailRequest.execute(async (signal) => {
+ const result = await firmwaresApi.detail(id, { signal });
+ if (result.ok) {
+ current.value = result.data?.data?.firmware || null;
+ }
+ return result;
+ });
+ }
+
+ async function loadDeviceCompatibility(deviceId) {
+ return compatibilityRequest.execute(async (signal) => {
+ const result = await firmwaresApi.deviceCompatibility(deviceId, { signal });
+ if (result.ok) {
+ compatibility.value = {
+ compatible: result.data?.data?.compatible || [],
+ currentVersion: result.data?.data?.current_version || "unknown",
+ };
+ }
+ return result;
+ });
+ }
+
+ async function updateDeviceFirmware(deviceId, firmwareId) {
+ return actionRequest.execute(async () => {
+ return firmwaresApi.updateDeviceFirmware(deviceId, firmwareId);
+ });
+ }
+
+ function clearCompatibility() {
+ compatibility.value = null;
+ }
+
+ return {
+ firmwares,
+ current,
+ compatibility,
+ isLoadingList,
+ isLoadingDetail,
+ isUpdating,
+ isLoadingCompatibility,
+ loadFirmwares,
+ refreshFirmwares,
+ loadFirmwareDetail,
+ loadDeviceCompatibility,
+ updateDeviceFirmware,
+ clearCompatibility,
+ };
+});