diff --git a/management-ui/src/customcompometns/DeviceDashboard.vue b/management-ui/src/customcompometns/DeviceDashboard.vue index 8ebf120..4bcdc04 100644 --- a/management-ui/src/customcompometns/DeviceDashboard.vue +++ b/management-ui/src/customcompometns/DeviceDashboard.vue @@ -1,15 +1,10 @@ \ No newline at end of file + + + + + + + + +
+ Tasks + +
+
+ + +
{{ tasksError }}
+ +
+ Loading tasks… +
+
+ +
+
+
+ + diff --git a/management-ui/src/pages/Create.vue b/management-ui/src/pages/Create.vue index 2cf1944..729821b 100644 --- a/management-ui/src/pages/Create.vue +++ b/management-ui/src/pages/Create.vue @@ -5,47 +5,61 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import AssignDevice from '@/customcompometns/AssignDevice.vue' +import Navbar from '@/customcompometns/Navbar.vue' import { useColorMode } from '@vueuse/core' -import Navbar from '@/customcompometns/Navbar.vue'; -import type { PropType } from 'vue'; -import { defineProps, defineEmits, reactive, watch, ref, computed } from 'vue'; +import { defineProps, reactive, watch, ref, computed, type PropType } from 'vue' import { v4 as uuidv4 } from 'uuid' import { useRouter } from 'vue-router' import { api } from '@/lib/api' +// shadcn-vue number field parts +import { + NumberField, + NumberFieldContent, + NumberFieldDecrement, + NumberFieldIncrement, + NumberFieldInput, +} from '@/components/ui/number-field' + const mode = useColorMode() +const router = useRouter() type CreateDevicePayload = { guid: string name: string userIds: number[] } - type CreateUserPayload = { username: string password: string role: string // 'admin' | 'user' } - -type CreateTrackerPayload = { - guid: string - name: string - userIds: number[] +type CreateDeviceConfigDto = { + m_recordingDuration: number + m_baseUrl: string + m_polling: number + m_jitter: number } -const router = useRouter() +type CreateTrackerConfigDto = { + m_baseUrl: string + m_polling: number + m_jitter: number +} const props = defineProps({ - modelValue: { - type: Boolean as PropType, - required: true, - }, + modelValue: { type: Boolean as PropType, required: true }, }) -// local form state +// ------------------------ Local form state ------------------------ const device_form = reactive({ - guid: uuidv4(), // default fresh UUID + guid: uuidv4(), name: '', + // config fields + baseUrl: '', + duration: 240, + polling: 60, + jitter: 10, }) const user_form = reactive({ @@ -57,31 +71,50 @@ const user_form = reactive({ const tracker_form = reactive({ guid: uuidv4(), name: '', + // config fields + baseUrl: '', + polling: 60, + jitter: 10, }) -// userIds from AssignDevice (expects v-model of string[] ids) +// Selected user IDs for both forms (split if you want separate sets) const selectedUserIds = ref([]) watch( () => props.modelValue, (val) => { if (val) { + // reset device device_form.guid = uuidv4() device_form.name = '' + device_form.baseUrl = '' + device_form.duration = 240 + device_form.polling = 60 + device_form.jitter = 10 + + // reset tracker + tracker_form.guid = uuidv4() + tracker_form.name = '' + tracker_form.baseUrl = '' + tracker_form.polling = 60 + tracker_form.jitter = 10 + + // reset users + user form selectedUserIds.value = [] user_form.username = '' user_form.password = '' user_form.isAdmin = false + userError.value = null userSubmitting.value = false userSuccess.value = null - // tracker_form.guid = uuidv4() - // tracker_form.name = '' + errorMsg.value = null + submitting.value = false } } ) -// simple UUIDv4 check (best-effort) +// ------------------------ Validation / helpers ------------------------ const uuidV4Re = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const submitting = ref(false) const errorMsg = ref(null) @@ -91,19 +124,58 @@ const userError = ref(null) const userSuccess = ref(null) const userRole = computed(() => (user_form.isAdmin ? 'admin' : 'user')) -const canSubmit = computed(() => { - return ( - !!device_form.guid && - uuidV4Re.test(device_form.guid) && - !!device_form.name.trim() && - !submitting.value - ) -}) +const canSubmitDevice = computed(() => + !!device_form.guid && + uuidV4Re.test(device_form.guid) && + !!device_form.name.trim() && + !!device_form.baseUrl.trim() && + Number.isFinite(device_form.duration) && + Number.isFinite(device_form.polling) && + Number.isFinite(device_form.jitter) && + !submitting.value +) +const canSubmitTracker = computed(() => + !!tracker_form.guid && + uuidV4Re.test(tracker_form.guid) && + !!tracker_form.name.trim() && + !!tracker_form.baseUrl.trim() && + Number.isFinite(tracker_form.polling) && + Number.isFinite(tracker_form.jitter) && + !submitting.value +) + +const canSubmitUser = computed(() => + user_form.username.trim().length >= 3 && + user_form.password.length >= 8 && + !userSubmitting.value +) + +const canDownloadDeviceConfig = computed(() => + !!device_form.guid && uuidV4Re.test(device_form.guid) && !!device_form.baseUrl.trim() +) +const canDownloadTrackerConfig = computed(() => + !!tracker_form.guid && uuidV4Re.test(tracker_form.guid) && !!tracker_form.baseUrl.trim() +) + +// Save a config.json file to the browser +function downloadConfig(filename: string, data: unknown) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +// ------------------------ Submit handlers ------------------------ async function submitDevice() { errorMsg.value = null - if (!canSubmit.value) { - errorMsg.value = 'Please provide a valid GUID and name.' + if (!canSubmitDevice.value) { + errorMsg.value = 'Please provide a valid GUID, name and config fields.' return } @@ -117,25 +189,90 @@ async function submitDevice() { userIds, } + const config: CreateDeviceConfigDto = { + m_recordingDuration: Number(device_form.duration), + m_baseUrl: device_form.baseUrl.trim(), + m_polling: Number(device_form.polling), + m_jitter: Number(device_form.jitter), + } + submitting.value = true try { + // 1) create device await api.post('/devices/create', payload) + + // 2) send config to device + await api.post(`/device/${encodeURIComponent(device_form.guid)}/config`, config) + + // 3) download config.json example + // downloadConfig( + // 'config.json', + // { + // m_guid: device_form.guid, + // m_recordingDuration: config.m_recordingDuration, + // m_baseUrl: config.m_baseUrl, + // m_polling: config.m_polling, + // m_jitter: config.m_jitter, + // } + // ) + router.replace('/admin') } catch (e: any) { - // keep client error generic - errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create device.' + errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create device or send config.' } finally { submitting.value = false } } -const canSubmitUser = computed(() => { - return ( - user_form.username.trim().length >= 3 && - user_form.password.length >= 8 && // basic client-side check; real policy enforced server-side - !userSubmitting.value - ) -}) +async function submitTracker() { + errorMsg.value = null + if (!canSubmitTracker.value) { + errorMsg.value = 'Please provide a valid GUID, name and config fields.' + return + } + + const userIds: number[] = selectedUserIds.value + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n) && n >= 0) + + const payload: CreateDevicePayload = { + guid: tracker_form.guid, + name: tracker_form.name.trim(), + userIds, + } + + const config: CreateTrackerConfigDto = { + m_baseUrl: tracker_form.baseUrl.trim(), + m_polling: Number(tracker_form.polling), + m_jitter: Number(tracker_form.jitter), + } + + submitting.value = true + try { + // 1) create tracker (your API already uses /trackers/create) + await api.post('/trackers/create', payload) + + // 2) send config; assuming symmetrical tracker endpoint: + // await api.post(`/trackers/${encodeURIComponent(tracker_form.guid)}/config`, config) + + // 3) download config.json + // downloadConfig( + // 'config.json', + // { + // m_guid: tracker_form.guid, + // m_baseUrl: config.m_baseUrl, + // m_polling: config.m_polling, + // m_jitter: config.m_jitter, + // } + // ) + + router.replace('/admin') + } catch (e: any) { + errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create tracker or send config.' + } finally { + submitting.value = false + } +} async function submitUser() { userError.value = null @@ -148,7 +285,7 @@ async function submitUser() { const payload: CreateUserPayload = { username: user_form.username.trim(), - password: user_form.password, // do not trim passwords + password: user_form.password, role: userRole.value, } @@ -164,33 +301,25 @@ async function submitUser() { } } -async function submitTracker() { - errorMsg.value = null - if (!canSubmit.value) { - errorMsg.value = 'Please provide a valid GUID and name.' - return +function downloadDeviceConfig() { + const payload = { + m_guid: device_form.guid, + m_recordingDuration: Number(device_form.duration), + m_baseUrl: device_form.baseUrl.trim(), + m_polling: Number(device_form.polling), + m_jitter: Number(device_form.jitter), } + downloadConfig(`config-${device_form.guid}.json`, payload) +} - const userIds: number[] = selectedUserIds.value - .map((s) => Number(s)) - .filter((n) => Number.isFinite(n) && n >= 0) - - const payload: CreateDevicePayload = { - guid: device_form.guid, - name: device_form.name.trim(), - userIds, - } - - submitting.value = true - try { - await api.post('/trackers/create', payload) - router.replace('/admin') - } catch (e: any) { - // keep client error generic - errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create device.' - } finally { - submitting.value = false +function downloadTrackerConfig() { + const payload = { + m_guid: tracker_form.guid, + m_baseUrl: tracker_form.baseUrl.trim(), + m_polling: Number(tracker_form.polling), + m_jitter: Number(tracker_form.jitter), } + downloadConfig(`config-${tracker_form.guid}.json`, payload) } @@ -198,40 +327,75 @@ async function submitTracker() {
-
+ Create device - -
- - + + +
+
+ +
+
- - + +
-
- - - +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
-

- {{ errorMsg }} -

+ + +
+ + +
+ +

{{ errorMsg }}

- - - + @@ -242,76 +406,97 @@ async function submitTracker() { Create user -
- - + +
-
- - Password +
-
- +
-
-

- {{ userError }} -

-

- {{ userSuccess }} -

+

{{ userError }}

+

{{ userSuccess }}

- - + + Create tracker - -
- - + + +
+
+ + +
+ + +
+ + +
+ +
+
+ + + + + + + + +
+
+ + + + + + + + +
- - + +
-
- - - -
-

- {{ errorMsg }} -

+

{{ errorMsg }}

- - - + @@ -321,4 +506,4 @@ async function submitTracker() {
- \ No newline at end of file + diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 9c87ae8..712830c 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -22,5 +22,6 @@ func AutoMigrate(db *gorm.DB) error { &models.DEviceTask{}, &models.DeviceCertificate{}, &models.RevokedSerial{}, + &models.DeviceConfig{}, ) } diff --git a/server/internal/dto/cert.go b/server/internal/dto/cert.go new file mode 100644 index 0000000..3d0e646 --- /dev/null +++ b/server/internal/dto/cert.go @@ -0,0 +1,36 @@ +package dto + +import ( + "smoop-api/internal/models" + "time" +) + +type DeviceCertDto struct { + ID uint `json:"id"` + DeviceGUID string `json:"deviceGuid"` + SerialHex string `json:"serialHex"` + IssuerCN string `json:"issuerCN"` + SubjectDN string `json:"subjectDN"` + NotBefore time.Time `json:"notBefore"` + NotAfter time.Time `json:"notAfter"` + CreatedAt time.Time `json:"createdAt"` + // PemCert is sensitive/noisy; expose only if you really need it: + // PemCert string `json:"pemCert,omitempty"` +} + +type DeviceCertListDto struct { + Certs []DeviceCertDto `json:"certs"` +} + +func MapDeviceCert(c models.DeviceCertificate) DeviceCertDto { + return DeviceCertDto{ + ID: c.ID, + DeviceGUID: c.DeviceGUID, + SerialHex: c.SerialHex, + IssuerCN: c.IssuerCN, + SubjectDN: c.SubjectDN, + NotBefore: c.NotBefore, + NotAfter: c.NotAfter, + CreatedAt: c.CreatedAt, + } +} diff --git a/server/internal/dto/device_config.go b/server/internal/dto/device_config.go new file mode 100644 index 0000000..51e0012 --- /dev/null +++ b/server/internal/dto/device_config.go @@ -0,0 +1,35 @@ +package dto + +import "smoop-api/internal/models" + +type DeviceConfigDto struct { + MGuid string `json:"m_guid"` + MRecordingDuration int `json:"m_recordingDuration"` + MBaseURL string `json:"m_baseUrl"` + MPolling int `json:"m_polling"` + MJitter int `json:"m_jitter"` +} + +type CreateDeviceConfigDto struct { + MRecordingDuration int `json:"m_recordingDuration" binding:"required"` + MBaseURL string `json:"m_baseUrl" binding:"required"` + MPolling int `json:"m_polling" binding:"required"` + MJitter int `json:"m_jitter" binding:"required"` +} + +type UpdateDeviceConfigDto struct { + MRecordingDuration *int `json:"m_recordingDuration,omitempty"` + MBaseURL *string `json:"m_baseUrl,omitempty"` + MPolling *int `json:"m_polling,omitempty"` + MJitter *int `json:"m_jitter,omitempty"` +} + +func MapDeviceConfig(cfg models.DeviceConfig) DeviceConfigDto { + return DeviceConfigDto{ + MGuid: cfg.MGuid, + MRecordingDuration: cfg.MRecordingDuration, + MBaseURL: cfg.MBaseURL, + MPolling: cfg.MPolling, + MJitter: cfg.MJitter, + } +} diff --git a/server/internal/handlers/devices.go b/server/internal/handlers/devices.go index 61c3c62..319981b 100644 --- a/server/internal/handlers/devices.go +++ b/server/internal/handlers/devices.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "fmt" "net/http" "strconv" @@ -302,5 +303,147 @@ func (h *DevicesHandler) ListCertsByDevice(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - c.JSON(http.StatusOK, gin.H{"certs": list}) + out := make([]dto.DeviceCertDto, 0, len(list)) + for _, it := range list { + out = append(out, dto.MapDeviceCert(it)) + } + c.JSON(http.StatusOK, dto.DeviceCertListDto{Certs: out}) +} + +// GET /device/:guid/config (admin or assigned user — choose policy; here adminOnly for symmetry with certs) +func (h *DevicesHandler) GetDeviceConfig(c *gin.Context) { + guid := c.Param("guid") + + // Ensure device exists + var d models.Device + if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) + return + } + + var cfg models.DeviceConfig + if err := h.db.Where("device_guid = ?", guid).First(&cfg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + c.JSON(http.StatusOK, dto.MapDeviceConfig(cfg)) +} + +// POST /device/:guid/config (create) +func (h *DevicesHandler) CreateDeviceConfig(c *gin.Context) { + guid := c.Param("guid") + + var d models.Device + if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) + return + } + + // Ensure not exists + var exists int64 + _ = h.db.Model(&models.DeviceConfig{}).Where("device_guid = ?", guid).Count(&exists).Error + if exists > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "config already exists"}) + return + } + + var req dto.CreateDeviceConfigDto + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cfg := models.DeviceConfig{ + DeviceGUID: guid, + MGuid: guid, + MRecordingDuration: req.MRecordingDuration, + MBaseURL: req.MBaseURL, + MPolling: req.MPolling, + MJitter: req.MJitter, + } + if err := h.db.Create(&cfg).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"}) + return + } + c.JSON(http.StatusCreated, dto.MapDeviceConfig(cfg)) +} + +// PUT /device/:guid/config (partial update) +func (h *DevicesHandler) UpdateDeviceConfig(c *gin.Context) { + guid := c.Param("guid") + + var req dto.UpdateDeviceConfigDto + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cfg models.DeviceConfig + err := h.db.Where("device_guid = ?", guid).First(&cfg).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // Create-on-update behavior + // m_baseUrl is required to create (NOT NULL constraint in model) + if req.MBaseURL == nil || strings.TrimSpace(*req.MBaseURL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "m_baseUrl is required to create config"}) + return + } + + // Defaults + recDur := 240 + if req.MRecordingDuration != nil { + recDur = *req.MRecordingDuration + } + poll := 30 + if req.MPolling != nil { + poll = *req.MPolling + } + jitter := 10 + if req.MJitter != nil { + jitter = *req.MJitter + } + + cfg = models.DeviceConfig{ + DeviceGUID: guid, + MGuid: guid, + MRecordingDuration: recDur, + MBaseURL: strings.TrimSpace(*req.MBaseURL), + MPolling: poll, + MJitter: jitter, + } + if err := h.db.Create(&cfg).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"}) + return + } + c.JSON(http.StatusCreated, dto.MapDeviceConfig(cfg)) + return + } + // Other DB error + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + // Patch only provided fields + if req.MRecordingDuration != nil { + cfg.MRecordingDuration = *req.MRecordingDuration + } + if req.MBaseURL != nil { + cfg.MBaseURL = strings.TrimSpace(*req.MBaseURL) + } + if req.MPolling != nil { + cfg.MPolling = *req.MPolling + } + if req.MJitter != nil { + cfg.MJitter = *req.MJitter + } + + if err := h.db.Save(&cfg).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}) + return + } + c.JSON(http.StatusOK, dto.MapDeviceConfig(cfg)) } diff --git a/server/internal/models/device.go b/server/internal/models/device.go index 51b4c00..041b300 100644 --- a/server/internal/models/device.go +++ b/server/internal/models/device.go @@ -11,4 +11,6 @@ type Device struct { Certs []DeviceCertificate `gorm:"foreignKey:DeviceGUID;references:GUID;constraint:OnDelete:CASCADE"` CreatedAt time.Time UpdatedAt time.Time + + Config *DeviceConfig `gorm:"foreignKey:DeviceGUID;references:GUID;constraint:OnDelete:CASCADE"` } diff --git a/server/internal/models/device_config.go b/server/internal/models/device_config.go new file mode 100644 index 0000000..c8b007e --- /dev/null +++ b/server/internal/models/device_config.go @@ -0,0 +1,19 @@ +package models + +import "time" + +// One-to-one config bound to a device GUID. +type DeviceConfig struct { + DeviceGUID string `gorm:"primaryKey;size:64"` // 1:1 with Device.GUID + // Fields reflect your device JSON keys (m_*) + MGuid string `gorm:"size:64;not null"` // duplicate for device FW convenience + MRecordingDuration int `gorm:"not null;default:240"` + MBaseURL string `gorm:"size:512;not null"` + MPolling int `gorm:"not null;default:30"` + MJitter int `gorm:"not null;default:10"` + + CreatedAt time.Time + UpdatedAt time.Time + + Device Device `gorm:"constraint:OnDelete:CASCADE;foreignKey:DeviceGUID;references:GUID"` +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index bdd16f9..a7189ce 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -62,6 +62,9 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine { r.GET("/device/:guid/tasks", authMW, middleware.DeviceAccessFilter(), tasksH.ListDeviceTasks) r.GET("/device/:guid/certs", authMW, adminOnly, devH.ListCertsByDevice) r.POST("/certs/revoke", authMW, adminOnly, certsAdminH.Revoke) + r.GET("/device/:guid/config", authMW, middleware.DeviceAccessFilter(), devH.GetDeviceConfig) + r.POST("/device/:guid/config", authMW, adminOnly, devH.CreateDeviceConfig) + r.PUT("/device/:guid/config", authMW, middleware.DeviceAccessFilter(), devH.UpdateDeviceConfig) r.POST("/records/upload", middleware.MTLSGuardUpload(db), recH.Upload) r.GET("/records", authMW, recH.List)