feat: API client, WebSocket composable, and Pinia device store
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
frontend/src/api/index.js
Normal file
46
frontend/src/api/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const BASE_URL = '/api'
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const url = `${BASE_URL}${path}`
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
if (config.body && typeof config.body === 'object') {
|
||||||
|
config.body = JSON.stringify(config.body)
|
||||||
|
}
|
||||||
|
const res = await fetch(url, config)
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = new Error(`API error: ${res.status}`)
|
||||||
|
error.status = res.status
|
||||||
|
try {
|
||||||
|
error.data = await res.json()
|
||||||
|
} catch {
|
||||||
|
error.data = await res.text()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
export const getDevices = () => request('/devices')
|
||||||
|
export const createDevice = (data) => request('/devices', { method: 'POST', body: data })
|
||||||
|
export const getDevice = (id) => request(`/devices/${id}`)
|
||||||
|
export const updateDevice = (id, data) => request(`/devices/${id}`, { method: 'PUT', body: data })
|
||||||
|
export const deleteDevice = (id) => request(`/devices/${id}`, { method: 'DELETE' })
|
||||||
|
export const sendCommand = (id, payload) =>
|
||||||
|
request(`/devices/${id}/command`, { method: 'POST', body: { payload } })
|
||||||
|
export const getDeviceLogs = (id, limit = 20) => request(`/devices/${id}/logs?limit=${limit}`)
|
||||||
|
|
||||||
|
// Broker
|
||||||
|
export const getBrokerStatus = () => request('/broker/status')
|
||||||
|
export const getBrokerClients = () => request('/broker/clients')
|
||||||
|
export const getBrokerTopics = () => request('/broker/topics')
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export const getDashboardStats = () => request('/dashboard')
|
||||||
48
frontend/src/composables/useWebSocket.js
Normal file
48
frontend/src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useWebSocket(url, onMessage) {
|
||||||
|
const connected = ref(false)
|
||||||
|
let ws = null
|
||||||
|
let reconnectTimer = null
|
||||||
|
let retryDelay = 1000
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const fullUrl = `${protocol}//${window.location.host}${url}`
|
||||||
|
|
||||||
|
ws = new WebSocket(fullUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
retryDelay = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
onMessage(data)
|
||||||
|
} catch {
|
||||||
|
// ignore non-JSON messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
reconnectTimer = setTimeout(connect, retryDelay)
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => connect())
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
if (ws) ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connected }
|
||||||
|
}
|
||||||
60
frontend/src/stores/devices.js
Normal file
60
frontend/src/stores/devices.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getDevices, createDevice, deleteDevice, sendCommand as apiSendCommand } from '../api'
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket'
|
||||||
|
|
||||||
|
export const useDeviceStore = defineStore('devices', () => {
|
||||||
|
const devices = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const wsConnected = ref(false)
|
||||||
|
|
||||||
|
async function fetchDevices() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
devices.value = await getDevices()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDevice(data) {
|
||||||
|
const device = await createDevice(data)
|
||||||
|
devices.value.unshift(device)
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDevice(id) {
|
||||||
|
await deleteDevice(id)
|
||||||
|
devices.value = devices.value.filter((d) => d.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(id, payload) {
|
||||||
|
return await apiSendCommand(id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(data) {
|
||||||
|
if (data.type !== 'device_update') return
|
||||||
|
const idx = devices.value.findIndex((d) => d.id === data.device_id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const device = devices.value[idx]
|
||||||
|
devices.value.splice(idx, 1, {
|
||||||
|
...device,
|
||||||
|
state: data.state,
|
||||||
|
is_online: data.is_online,
|
||||||
|
last_seen: data.last_seen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebSocket
|
||||||
|
const { connected } = useWebSocket('/ws/devices', handleWsMessage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
devices,
|
||||||
|
loading,
|
||||||
|
wsConnected,
|
||||||
|
fetchDevices,
|
||||||
|
addDevice,
|
||||||
|
removeDevice,
|
||||||
|
sendCommand,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user