Added auth to the web console now that the broker uses ACLs
This commit is contained in:
parent
c0900a2d78
commit
c8df88ae8d
@ -1,47 +1,59 @@
|
||||
<script setup>
|
||||
import { ref, provide, onMounted } from 'vue'
|
||||
import { useLayout } from './composables/useLayout'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
const toast = useToast()
|
||||
|
||||
const { primaryColors, surfaces, primary, surface, isDarkMode, updateColors, toggleDarkMode } =
|
||||
useLayout()
|
||||
import MQTTService from './services/mqtt.js'
|
||||
|
||||
import AppTopbar from './components/AppTopbar.vue'
|
||||
import AppFooter from './components/AppFooter.vue'
|
||||
import StatsWidget from './components/dashboard/StatsWidget.vue'
|
||||
import SalesTrendWidget from './components/dashboard/SalesTrendWidget.vue'
|
||||
import RecentActivityWidget from './components/dashboard/RecentActivityWidget.vue'
|
||||
import ProductOverviewWidget from './components/dashboard/ProductOverviewWidget.vue'
|
||||
|
||||
import DevicesWidget from './components/dashboard/DevicesWidget.vue'
|
||||
import PropertiesWidget from './components/dashboard/PropertiesWidget.vue'
|
||||
import CommandsWidget from './components/dashboard/CommandsWidget.vue'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import DevicesWidget from './components/dashboard/DevicesWidget.vue'
|
||||
|
||||
const mqtt = ref(null)
|
||||
const loggedIn = ref(false)
|
||||
const selectedDevice = ref(null)
|
||||
|
||||
function handleLogin({ username, password }) {
|
||||
// Create the shared MQTT client
|
||||
mqtt.value = new MQTTService('ws://127.0.0.1:8083/mqtt', username, password)
|
||||
loggedIn.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if(isDarkMode !== false) toggleDarkMode()
|
||||
})
|
||||
|
||||
provide('mqtt', mqtt)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast position="bottom-right" />
|
||||
|
||||
<div class="layout-container">
|
||||
<AppTopbar />
|
||||
<div class="layout-grid">
|
||||
<DevicesWidget @select="selectedDevice = $event" />
|
||||
<div v-if="selectedDevice" class="layout-grid-row">
|
||||
<PropertiesWidget
|
||||
v-if="selectedDevice"
|
||||
:key="'props-' + selectedDevice"
|
||||
:device-id="selectedDevice"
|
||||
/>
|
||||
|
||||
<CommandsWidget
|
||||
v-if="selectedDevice"
|
||||
:key="'cmds-' + selectedDevice"
|
||||
:device-id="selectedDevice"
|
||||
/>
|
||||
<div id="app">
|
||||
<SplashPage v-if="!loggedIn" @login="handleLogin" />
|
||||
<div v-else class="layout-container">
|
||||
<AppTopbar />
|
||||
<div class="layout-grid">
|
||||
<DevicesWidget @select="selectedDevice = $event" />
|
||||
<div v-if="selectedDevice" class="layout-grid-row">
|
||||
<PropertiesWidget
|
||||
v-if="selectedDevice"
|
||||
:key="'props-' + selectedDevice"
|
||||
:device-id="selectedDevice"
|
||||
/>
|
||||
<CommandsWidget
|
||||
v-if="selectedDevice"
|
||||
:key="'cmds-' + selectedDevice"
|
||||
:device-id="selectedDevice"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <StatsWidget /> -->
|
||||
<!-- <div class="layout-grid-row">
|
||||
<SalesTrendWidget />
|
||||
<RecentActivityWidget />
|
||||
</div>-->
|
||||
<!-- <ProductOverviewWidget /> -->
|
||||
<AppFooter />
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -97,6 +97,18 @@ body {
|
||||
color: var(--p-primary-200);
|
||||
}
|
||||
|
||||
.offline {
|
||||
color: color-mix(in srgb, red, transparent 40%);
|
||||
border-color: color-mix(in srgb, red, transparent 70%);
|
||||
background-color: color-mix(in srgb, red, transparent 80%);
|
||||
}
|
||||
|
||||
.p-dark .offline {
|
||||
color: color-mix(in srgb, red, transparent 40%);
|
||||
border-color: color-mix(in srgb, red, transparent 70%);
|
||||
background-color: color-mix(in srgb, red, transparent 90%);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@ -4,10 +4,6 @@
|
||||
|
||||
const { primaryColors, surfaces, primary, surface, isDarkMode, updateColors, toggleDarkMode } =
|
||||
useLayout()
|
||||
|
||||
onMounted(() => {
|
||||
toggleDarkMode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
60
console/src/components/SplashPage.vue
Normal file
60
console/src/components/SplashPage.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['login'])
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
function submitLogin() {
|
||||
if (username.value && password.value) {
|
||||
emit('login', { username: username.value, password: password.value })
|
||||
} else {
|
||||
alert('Please enter username and password')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="splash-page">
|
||||
<div class="login-card">
|
||||
<h2>MQTT Login</h2>
|
||||
<input v-model="username" placeholder="Username" />
|
||||
<input v-model="password" type="password" placeholder="Password" />
|
||||
<Button @click="submitLogin">Login</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.splash-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-color: var(--p-primary-color-light);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.15);
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import MQTTService from '../../services/mqtt.js'
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const mqttRef = inject('mqtt')
|
||||
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
@ -25,8 +27,6 @@
|
||||
deviceId: String,
|
||||
})
|
||||
|
||||
const mqtt2 = new MQTTService()
|
||||
|
||||
const filters = ref({})
|
||||
const commands = ref([])
|
||||
const commandMap = reactive({})
|
||||
@ -65,10 +65,10 @@
|
||||
var responseId = generateId()
|
||||
commandByResponseId[responseId] = cmd
|
||||
|
||||
mqtt2.publish(topic, payload || '{}', {
|
||||
mqttRef.value.publish(topic, payload || '{}', {
|
||||
qos: 1,
|
||||
properties: {
|
||||
responseTopic: `client/${mqtt2.clientId}/responses`,
|
||||
responseTopic: `client/${mqttRef.value.clientId}/responses`,
|
||||
correlationData: new TextEncoder().encode(responseId),
|
||||
},
|
||||
})
|
||||
@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mqtt2.subscribe('device/+/command/#', (payload, topic) => {
|
||||
mqttRef.value.subscribe(`device/${props.deviceId}/command/#`, (payload, topic) => {
|
||||
const parts = topic.split('/')
|
||||
|
||||
const device = parts[1]
|
||||
@ -102,6 +102,8 @@
|
||||
const command = parts[3]
|
||||
const field = parts[4]
|
||||
|
||||
console.log(topic, payload)
|
||||
|
||||
if (field === '$description') {
|
||||
updateCommand(device, command, 'description', payload)
|
||||
}
|
||||
@ -111,30 +113,34 @@
|
||||
}
|
||||
})
|
||||
|
||||
mqtt2.subscribe(`client/${mqtt2.clientId}/responses`, (payload, topic) => {
|
||||
let response = JSON.parse(payload)
|
||||
// mqttRef.value.subscribe(`client/${mqttRef.value.clientId}/responses`, (payload, topic) => {
|
||||
// let response = JSON.parse(payload)
|
||||
|
||||
const responseId = response.correlation
|
||||
const cmd = commandByResponseId[responseId]
|
||||
// const responseId = response.correlation
|
||||
// const cmd = commandByResponseId[responseId]
|
||||
|
||||
if (cmd) {
|
||||
cmd.response = response
|
||||
// if (cmd) {
|
||||
// cmd.response = response
|
||||
|
||||
toast.add({
|
||||
severity: response.success ? 'success' : 'error',
|
||||
summary: cmd.name,
|
||||
detail:
|
||||
response.message !== 'None'
|
||||
? response.message
|
||||
: response.success
|
||||
? 'Success'
|
||||
: 'Failed',
|
||||
life: 4000,
|
||||
})
|
||||
// toast.add({
|
||||
// severity: response.success ? 'success' : 'error',
|
||||
// summary: cmd.name,
|
||||
// detail:
|
||||
// response.message !== 'None'
|
||||
// ? response.message
|
||||
// : response.success
|
||||
// ? 'Success'
|
||||
// : 'Failed',
|
||||
// life: 4000,
|
||||
// })
|
||||
|
||||
delete commandByResponseId[responseId]
|
||||
}
|
||||
})
|
||||
// delete commandByResponseId[responseId]
|
||||
// }
|
||||
// })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mqttRef.value.unsubscribe(`device/${props.deviceId}/command/#`)
|
||||
})
|
||||
|
||||
const jsonFormsConfig = {
|
||||
|
||||
@ -1,93 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import MQTTService from '../../services/mqtt.js'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
const mqttRef = inject('mqtt')
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const mqtt = new MQTTService()
|
||||
const devices = ref([])
|
||||
const deviceMap = ref({}) // map deviceId => device object with all meta properties
|
||||
const selected = ref(null)
|
||||
|
||||
const devices = ref([])
|
||||
const deviceSet = new Set()
|
||||
const selected = ref(null)
|
||||
|
||||
function upsertDevice(device, status) {
|
||||
const existing = devices.value.find((d) => d.id === device)
|
||||
|
||||
if (existing) {
|
||||
// ✅ update reactively
|
||||
existing.value = status
|
||||
} else {
|
||||
deviceSet.add(device)
|
||||
|
||||
devices.value = [
|
||||
...devices.value,
|
||||
{
|
||||
id: device,
|
||||
title: device,
|
||||
value: status,
|
||||
subtitle: 'MQTT Device',
|
||||
icon: status === 'ONLINE' ? 'pi-check-circle' : 'pi-times-circle',
|
||||
},
|
||||
]
|
||||
// Add or update a device's meta property
|
||||
function upsertDevice(deviceId, property, value) {
|
||||
if (!deviceMap.value[deviceId]) {
|
||||
deviceMap.value[deviceId] = {
|
||||
id: deviceId,
|
||||
meta: {}, // stores all meta properties
|
||||
title: deviceId,
|
||||
value: '', // main status display
|
||||
subtitle: 'MQTT Device',
|
||||
icon: 'pi-box',
|
||||
}
|
||||
}
|
||||
function selectDevice(device) {
|
||||
selected.value = device.id
|
||||
emit('select', device.id)
|
||||
|
||||
// update the property
|
||||
deviceMap.value[deviceId].meta[property] = value
|
||||
|
||||
// update card display values (example: show 'status' as main value)
|
||||
if (property === 'status') {
|
||||
deviceMap.value[deviceId].value = value.replace(/['"]+/g, '')
|
||||
deviceMap.value[deviceId].icon = deviceMap.value[deviceId].value === 'ONLINE' ? 'pi-check-circle' : 'pi-times-circle'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mqtt.subscribe('device/+/property/status', (payload, topic) => {
|
||||
const parts = topic.split('/')
|
||||
const device = parts[1]
|
||||
upsertDevice(device, payload)
|
||||
})
|
||||
// rebuild reactive array for rendering
|
||||
devices.value = Object.values(deviceMap.value)
|
||||
}
|
||||
|
||||
function selectDevice(device) {
|
||||
selected.value = device.id
|
||||
emit('select', device.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mqttRef.value.subscribe('device/+/meta/+', (payload, topic) => {
|
||||
const parts = topic.split('/')
|
||||
const deviceId = parts[1]
|
||||
const property = parts.slice(3).join('/') // handles nested properties if any
|
||||
upsertDevice(deviceId, property, payload)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-grid-row">
|
||||
<div
|
||||
v-for="(device, index) in devices"
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="layout-card device-card"
|
||||
:class="{ 'selected-device': device.id === selected }"
|
||||
@click="selectDevice(device)"
|
||||
>
|
||||
<div class="stats-header">
|
||||
<span class="stats-title">{{ device.title }}</span>
|
||||
<span class="stats-icon-box">
|
||||
<span class="stats-title">{{ device.meta['type'] }}</span>
|
||||
<span :class="[ device.value === 'OFFLINE' ? 'offline' : '', 'stats-icon-box']">
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
device.value === 'ONLINE' ? 'pi-check-circle' : 'pi-times-circle',
|
||||
device.value === 'ONLINE' ? '' : 'text-red-500',
|
||||
]"
|
||||
:class="['pi', device.icon]"
|
||||
:style="{ color: device.value === 'ONLINE' ? 'inherit' : 'var(--p-secondary-color)' }"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-content">
|
||||
<div class="stats-value">{{ device.value }}</div>
|
||||
<div class="stats-subtitle">{{ device.subtitle }}</div>
|
||||
<div class="stats-value">{{ device.meta['name'] }}</div>
|
||||
<div class="stats-subtitle"><span class='italic'>{{ device.id }}</span> on <span class="font-bold">{{ device.meta['host'] }}</span></div>
|
||||
<div class="stats-subtitle italic text-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.device-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.device-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 🔥 selected state */
|
||||
.selected-device {
|
||||
border: 2px solid var(--p-primary-color);
|
||||
box-shadow: 0 0 8px var(--p-primary-color);
|
||||
}
|
||||
.selected-device {
|
||||
border: 2px solid var(--p-primary-color);
|
||||
box-shadow: 0 0 8px var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
@ -1,107 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const products = ref([
|
||||
{
|
||||
name: 'Laptop Pro',
|
||||
category: 'Electronics',
|
||||
price: 2499,
|
||||
status: 'In Stock',
|
||||
},
|
||||
{
|
||||
name: 'Wireless Mouse',
|
||||
category: 'Accessories',
|
||||
price: 49,
|
||||
status: 'Low Stock',
|
||||
},
|
||||
{
|
||||
name: 'Monitor 4K',
|
||||
category: 'Electronics',
|
||||
price: 699,
|
||||
status: 'Out of Stock',
|
||||
},
|
||||
{ name: 'Keyboard', category: 'Accessories', price: 149, status: 'In Stock' },
|
||||
])
|
||||
|
||||
const selectedProduct = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(false)
|
||||
const filteredProducts = ref([])
|
||||
|
||||
const searchProducts = () => {
|
||||
loading.value = true
|
||||
filteredProducts.value = products.value.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
product.category.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
product.status.toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(searchQuery, () => {
|
||||
searchProducts()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filteredProducts.value = [...products.value]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-card">
|
||||
<div class="products-header">
|
||||
<span class="products-title">Products Overview</span>
|
||||
<IconField class="search-field">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
placeholder="Search products..."
|
||||
class="products-search"
|
||||
@keyup.enter="searchProducts"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
<div class="products-table-container">
|
||||
<DataTable
|
||||
:value="filteredProducts"
|
||||
v-model:selection="selectedProduct"
|
||||
selectionMode="single"
|
||||
:loading="loading"
|
||||
:rows="5"
|
||||
class="products-table"
|
||||
:pt="{
|
||||
mask: {
|
||||
class: 'products-table-mask',
|
||||
},
|
||||
loadingIcon: {
|
||||
class: 'products-table-loading',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Column field="name" header="Name" sortable></Column>
|
||||
<Column field="category" header="Category" sortable></Column>
|
||||
<Column field="price" header="Price" sortable>
|
||||
<template #body="{ data }"> ${{ data.price }} </template>
|
||||
</Column>
|
||||
<Column field="status" header="Status">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:severity="
|
||||
data.status === 'In Stock'
|
||||
? 'success'
|
||||
: data.status === 'Low Stock'
|
||||
? 'warn'
|
||||
: 'danger'
|
||||
"
|
||||
>
|
||||
{{ data.status }}
|
||||
</Tag>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import MQTTService from '../../services/mqtt.js'
|
||||
import mqtt from 'mqtt'
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const mqttRef = inject('mqtt')
|
||||
|
||||
const props = defineProps({
|
||||
deviceId: String,
|
||||
@ -10,8 +13,6 @@
|
||||
const filters = ref({})
|
||||
const filterMode = ref({ label: 'Lenient', value: 'lenient' })
|
||||
|
||||
const mqtt = new MQTTService()
|
||||
|
||||
const changedKeys = ref({})
|
||||
let propertyTree = {}
|
||||
|
||||
@ -80,7 +81,7 @@
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
mqtt.subscribe('device/+/property/#', (payload, topic) => {
|
||||
mqttRef.value.subscribe(`device/${props.deviceId}/property/#`, (payload, topic) => {
|
||||
const parts = topic.split('/')
|
||||
|
||||
const device = parts[1]
|
||||
@ -91,6 +92,10 @@
|
||||
insertProperty(propertyPath, payload)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mqttRef.value.unsubscribe(`device/${props.deviceId}/property/#`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
const activities = [
|
||||
{
|
||||
icon: 'pi-shopping-cart',
|
||||
text: 'New order #1123',
|
||||
time: '2 minutes ago',
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
icon: 'pi-user-plus',
|
||||
text: 'New customer registered',
|
||||
time: '15 minutes ago',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: 'pi-check-circle',
|
||||
text: 'Payment processed',
|
||||
time: '25 minutes ago',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: 'pi-inbox',
|
||||
text: 'Inventory updated',
|
||||
time: '40 minutes ago',
|
||||
color: 'yellow',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-card col-item-2">
|
||||
<span class="chart-title">Recent Activity</span>
|
||||
<div class="activity-list">
|
||||
<div v-for="(activity, index) in activities" :key="index" class="activity-item">
|
||||
<i :class="['activity-icon', activity.color, 'pi', activity.icon]"></i>
|
||||
<div class="activity-content">
|
||||
<span class="activity-text">{{ activity.text }}</span>
|
||||
<span class="activity-time">{{ activity.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,94 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useLayout } from '../../composables/useLayout'
|
||||
|
||||
const { primary, surface, isDarkMode } = useLayout()
|
||||
|
||||
const chartData = ref(null)
|
||||
const chartOptions = ref(null)
|
||||
|
||||
function setChartData() {
|
||||
const documentStyle = getComputedStyle(document.documentElement)
|
||||
|
||||
return {
|
||||
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar',
|
||||
label: 'Subscriptions',
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-400'),
|
||||
data: [4000, 10000, 15000, 4000],
|
||||
barThickness: 32,
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
label: 'Advertising',
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-300'),
|
||||
data: [2100, 8400, 2400, 7500],
|
||||
barThickness: 32,
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
label: 'Affiliate',
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
|
||||
data: [4100, 5200, 3400, 7400],
|
||||
borderRadius: {
|
||||
topLeft: 8,
|
||||
topRight: 8,
|
||||
},
|
||||
barThickness: 32,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function setChartOptions() {
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
drawTicks: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
watch([primary, surface, isDarkMode], () => {
|
||||
chartData.value = setChartData()
|
||||
chartOptions.value = setChartOptions()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
chartData.value = setChartData()
|
||||
chartOptions.value = setChartOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-card col-item-2">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Sales Trend</span>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" style="height: 300px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,45 +0,0 @@
|
||||
<script setup>
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Orders',
|
||||
icon: 'pi-shopping-cart',
|
||||
value: '1,234',
|
||||
subtitle: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
title: 'Active Users',
|
||||
icon: 'pi-users',
|
||||
value: '2,573',
|
||||
subtitle: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
icon: 'pi-dollar',
|
||||
value: '$45,200',
|
||||
subtitle: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
title: 'Success Rate',
|
||||
icon: 'pi-chart-line',
|
||||
value: '95%',
|
||||
subtitle: 'Last 7 days',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
<div v-for="(stat, index) in stats" :key="index" class="layout-card">
|
||||
<div class="stats-header">
|
||||
<span class="stats-title">{{ stat.title }}</span>
|
||||
<span class="stats-icon-box">
|
||||
<i :class="['pi', stat.icon]"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-value">{{ stat.value }}</div>
|
||||
<div class="stats-subtitle">{{ stat.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,27 +1,79 @@
|
||||
import mqtt from 'mqtt'
|
||||
|
||||
export default class MQTTService {
|
||||
constructor(brokerUrl = 'ws://127.0.0.1:8083/mqtt', clientId = null) {
|
||||
/**
|
||||
* @param {string} brokerUrl - MQTT broker URL (e.g., ws://127.0.0.1:8083/mqtt)
|
||||
* @param {string|null} clientId - Optional MQTT client ID
|
||||
* @param {string|null} username - Optional MQTT username
|
||||
* @param {string|null} password - Optional MQTT password
|
||||
*/
|
||||
constructor(
|
||||
brokerUrl = 'ws://127.0.0.1:8083/mqtt',
|
||||
username = null,
|
||||
password = null,
|
||||
clientId = null,
|
||||
) {
|
||||
this.clientId = clientId || 'vue-client-' + Math.random().toString(16).substr(2, 8)
|
||||
this.client = mqtt.connect(brokerUrl, { clientId: this.clientId, protocolVersion: 5 })
|
||||
this.subscriptions = [] // array of {topic, callback}
|
||||
|
||||
this.client.on('connect', () => console.log('Connected to MQTT broker'))
|
||||
// Connect options
|
||||
const options = {
|
||||
clientId: this.clientId,
|
||||
protocolVersion: 5,
|
||||
}
|
||||
if (username) options.username = username
|
||||
if (password) options.password = password
|
||||
|
||||
this.client = mqtt.connect(brokerUrl, options)
|
||||
this.subscriptions = [] // array of { topic, callback }
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log(`Connected to MQTT broker as ${this.clientId}`)
|
||||
})
|
||||
this.client.on('message', (topic, payload) => {
|
||||
// iterate over subscriptions and check for matches
|
||||
const message = payload.toString()
|
||||
this.subscriptions.forEach(({ topic: subTopic, callback }) => {
|
||||
if (mqttMatch(subTopic, topic)) {
|
||||
callback(payload.toString(), topic)
|
||||
callback(message, topic)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic
|
||||
* @param {string} topic
|
||||
* @param {function} callback
|
||||
*/
|
||||
subscribe(topic, callback) {
|
||||
this.subscriptions.push({ topic, callback })
|
||||
this.client.subscribe(topic)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a topic
|
||||
* @param {string} topic
|
||||
* @param {function|null} callback - optional, remove only this callback
|
||||
*/
|
||||
unsubscribe(topic, callback = null) {
|
||||
this.subscriptions = this.subscriptions.filter((sub) => {
|
||||
if (sub.topic !== topic) return true
|
||||
if (callback && sub.callback !== callback) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Actually tell the broker to unsubscribe only if no more callbacks exist for this topic
|
||||
const stillSubscribed = this.subscriptions.some((sub) => sub.topic === topic)
|
||||
if (!stillSubscribed) {
|
||||
this.client.unsubscribe(topic)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a message
|
||||
* @param {string} topic
|
||||
* @param {string|Buffer} message
|
||||
* @param {object} options
|
||||
*/
|
||||
publish(topic, message, options = {}) {
|
||||
this.client.publish(topic, message, options)
|
||||
}
|
||||
@ -30,6 +82,6 @@ export default class MQTTService {
|
||||
// helper function for MQTT wildcards
|
||||
function mqttMatch(subTopic, topic) {
|
||||
// replace MQTT wildcards with RegExp
|
||||
const regex = '^' + subTopic.replace('+', '[^/]+').replace('#', '.+') + '$'
|
||||
const regex = '^' + subTopic.replace(/\+/g, '[^/]+').replace(/#/g, '.+') + '$'
|
||||
return new RegExp(regex).test(topic)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user