Merge branch 'dashboard'
34
console/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
73
console/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# PrimeVue Vite Example
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div align="start">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This example demonstrates a basic PrimeVue integration with Vite, showcasing a modern and responsive dashboard interface. The project features various PrimeVue components with standard CSS styling.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- PrimeVue components with CSS styling
|
||||||
|
- Powered by Vite for fast development
|
||||||
|
- Real-time search functionality
|
||||||
|
- Customizable UI components
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- [Vue.js](https://vuejs.org/) - The Progressive JavaScript Framework
|
||||||
|
- [PrimeVue](https://primevue.org/) - The Ultimate Vue UI Component Library
|
||||||
|
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/primefaces/primevue-examples.git
|
||||||
|
cd primevue-examples/vite-quickstart
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
vite-quickstart/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Vue components
|
||||||
|
│ ├── assets/ # Static assets
|
||||||
|
│ │ └── styles/ # CSS styles
|
||||||
|
│ └── App.vue # Root component
|
||||||
|
├── public/ # Public static assets
|
||||||
|
├── index.html # Entry HTML file
|
||||||
|
└── vite.config.js # Vite configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions, issues, and feature requests are welcome! Feel free to check the [issues page](https://github.com/primefaces/primevue-examples/issues).
|
||||||
13
console/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3839
console/package-lock.json
generated
Normal file
38
console/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "primevue-vite-quickstart",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@jsonforms/core": "^3.7.0",
|
||||||
|
"@jsonforms/material-renderers": "^3.7.0",
|
||||||
|
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||||
|
"@jsonforms/vue": "^3.7.0",
|
||||||
|
"@jsonforms/vue-vanilla": "^3.7.0",
|
||||||
|
"@primeuix/themes": "^1.0.0",
|
||||||
|
"@primevue/core": "^4.2.5",
|
||||||
|
"chart.js": "^4.4.7",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"jsonforms-primevue": "github:kobbejager/jsonforms-primevue",
|
||||||
|
"mqtt": "^5.15.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.2.5",
|
||||||
|
"vue": "^3.4.27"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@primevue/auto-import-resolver": "^4.3.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"unplugin-vue-components": "^28.4.0",
|
||||||
|
"vite": "^5.2.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
console/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
30
console/src/App.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
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 PropertiesWidget from "./components/dashboard/PropertiesWidget.vue";
|
||||||
|
import CommandsWidget from "./components/dashboard/CommandsWidget.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-container">
|
||||||
|
<AppTopbar />
|
||||||
|
<div class="layout-grid">
|
||||||
|
<div class="layout-grid-row">
|
||||||
|
<PropertiesWidget device-id="example-gps-fedora" />
|
||||||
|
<CommandsWidget device-id="example-gps-fedora" />
|
||||||
|
</div>
|
||||||
|
<StatsWidget />
|
||||||
|
<!-- <div class="layout-grid-row">
|
||||||
|
<SalesTrendWidget />
|
||||||
|
<RecentActivityWidget />
|
||||||
|
</div>
|
||||||
|
<ProductOverviewWidget /> -->
|
||||||
|
</div>
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
BIN
console/src/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
596
console/src/assets/styles/main.css
Normal file
@ -0,0 +1,596 @@
|
|||||||
|
@import "primeicons/primeicons.css";
|
||||||
|
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-variation-settings: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--p-surface-50);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark body {
|
||||||
|
background-color: var(--p-surface-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
background-color: var(--p-surface-50);
|
||||||
|
color: var(--p-surface-950);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .layout-container {
|
||||||
|
background-color: var(--p-surface-950);
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-card {
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
color: var(--p-surface-950);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .layout-card {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 1.714rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-icon-box {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--p-primary-100);
|
||||||
|
color: var(--p-primary-600);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--p-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .stats-icon-box {
|
||||||
|
background-color: color-mix(in srgb, var(--p-primary-400), transparent 80%);
|
||||||
|
border-color: color-mix(in srgb, var(--p-primary-400), transparent 70%);
|
||||||
|
color: var(--p-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .stats-title {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-value {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .stats-value {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subtitle {
|
||||||
|
color: var(--p-surface-600);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .stats-subtitle {
|
||||||
|
color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-item-2 {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
.chart-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .chart-title {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.activity-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: var(--p-surface-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .activity-item {
|
||||||
|
background-color: var(--p-surface-800);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon {
|
||||||
|
font-size: 1.125rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.green {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.blue {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.yellow {
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.pink {
|
||||||
|
color: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--p-surface-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .activity-time {
|
||||||
|
color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.products-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-header .search-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .products-title {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-table-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .products-table-container {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-table {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .products-table {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-table-mask {
|
||||||
|
backdrop-filter: blur(4px) !important;
|
||||||
|
background-color: color-mix(in srgb, var(--p-surface-0), transparent 80%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .products-table-mask {
|
||||||
|
background-color: color-mix(in srgb, var(--p-surface-900), transparent 80%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-table-loading {
|
||||||
|
color: var(--p-primary-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-search {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .products-search {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.products-search {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.products-search {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.layout-grid-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.col-item-2 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .topbar {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.topbar-brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--p-surface-700);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .topbar-title {
|
||||||
|
color: var(--p-surface-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--p-primary-500);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-button {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .topbar-theme-button {
|
||||||
|
color: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-button:hover {
|
||||||
|
background-color: var(--p-surface-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .topbar-theme-button:hover {
|
||||||
|
background-color: var(--p-surface-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-primary {
|
||||||
|
fill: var(--p-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .fill-primary {
|
||||||
|
fill: var(--p-primary-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-surface {
|
||||||
|
fill: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .fill-surface {
|
||||||
|
fill: var(--p-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 4rem;
|
||||||
|
right: 0;
|
||||||
|
width: 16rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
transform-origin: top;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .config-panel {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--p-surface-600);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .config-label {
|
||||||
|
color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-colors {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-button {
|
||||||
|
border: none;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
--ring-offset-shadow: 0 0 0 var(--ring-offset-width) var(--ring-offset-color);
|
||||||
|
--ring-shadow: 0 0 0 calc(var(--ring-width) + var(--ring-offset-width)) var(--ring-color);
|
||||||
|
--ring-width: 2px;
|
||||||
|
--ring-offset-width: 2px;
|
||||||
|
--ring-color: var(--p-primary-500);
|
||||||
|
--ring-offset-color: #ffffff;
|
||||||
|
box-shadow: var(--ring-offset-shadow), var(--ring-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: var(--p-surface-0);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
border: 1px solid var(--p-surface-200);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .footer {
|
||||||
|
background-color: var(--p-surface-900);
|
||||||
|
border-color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.footer-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copyright {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--p-surface-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .footer-copyright {
|
||||||
|
color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--p-surface-600);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dark .footer-link {
|
||||||
|
color: var(--p-surface-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--p-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeout {
|
||||||
|
animation: fadeout 0.15s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scalein {
|
||||||
|
animation: scalein 0.15s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeout {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scalein {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleY(0.8);
|
||||||
|
transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1), opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
console/src/components/AppConfig.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useLayout } from "../composables/useLayout";
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const { primaryColors, surfaces, primary, surface, isDarkMode, updateColors, toggleDarkMode } = useLayout();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
toggleDarkMode()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="config-panel hidden">
|
||||||
|
<div class="config-section">
|
||||||
|
<div>
|
||||||
|
<span class="config-label">Primary</span>
|
||||||
|
<div class="config-colors">
|
||||||
|
<button
|
||||||
|
v-for="pc of primaryColors"
|
||||||
|
:key="pc.name"
|
||||||
|
type="button"
|
||||||
|
:title="pc.name"
|
||||||
|
:class="['color-button', { selected: primary === pc.name }]"
|
||||||
|
:style="{ backgroundColor: pc.palette['500'] }"
|
||||||
|
@click="updateColors('primary', pc.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="config-label">Surface</span>
|
||||||
|
<div class="config-colors">
|
||||||
|
<button
|
||||||
|
v-for="s of surfaces"
|
||||||
|
:key="s.name"
|
||||||
|
type="button"
|
||||||
|
:title="s.name"
|
||||||
|
:class="[
|
||||||
|
'color-button',
|
||||||
|
{
|
||||||
|
selected: surface
|
||||||
|
? surface === s.name
|
||||||
|
: isDarkMode
|
||||||
|
? s.name === 'zinc'
|
||||||
|
: s.name === 'slate',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ backgroundColor: s.palette['500'] }"
|
||||||
|
@click="updateColors('surface', s.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
console/src/components/AppFooter.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="footer-container">
|
||||||
|
<div class="footer-copyright">Jono made this by blatantly copy-pasting from the example projects and vibe-coding the rest</div>
|
||||||
|
and by not being stupid
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
console/src/components/AppTopbar.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useLayout } from "../composables/useLayout";
|
||||||
|
import AppConfig from "./AppConfig.vue";
|
||||||
|
|
||||||
|
const { isDarkMode, toggleDarkMode } = useLayout();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-container">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<!-- Stolen from logoipsum -->
|
||||||
|
<svg width="59" height="36" viewBox="0 0 59 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.0898 18C20.0898 19.1046 19.1944 20 18.0898 20C16.9853 20 16.0898 19.1046 16.0898 18C16.0898 16.8954 16.9853 16 18.0898 16C19.1944 16 20.0898 16.8954 20.0898 18Z" class="fill-surface"></path>
|
||||||
|
<path d="M42.0898 18C42.0898 19.1046 41.1944 20 40.0898 20C38.9853 20 38.0898 19.1046 38.0898 18C38.0898 16.8954 38.9853 16 40.0898 16C41.1944 16 42.0898 16.8954 42.0898 18Z" class="fill-surface"></path>
|
||||||
|
<path class="fill-primary" fill-rule="evenodd" clip-rule="evenodd" d="M40.0899 2.0365e-06C40.7659 2.1547e-06 41.4332 0.0372686 42.0898 0.109859V2.12379C41.4347 2.04209 40.7672 2 40.0899 2C36.5517 2 33.2835 3.14743 30.6345 5.09135C31.1162 5.55951 31.5717 6.0544 31.9988 6.57362C34.2835 4.95312 37.0764 4 40.0899 4C40.7689 4 41.4367 4.04834 42.0898 4.14177V6.16591C41.4394 6.0568 40.7713 6 40.0899 6C37.5171 6 35.1352 6.80868 33.1819 8.18644C33.547 8.74673 33.8817 9.3286 34.1837 9.92973C35.8384 8.71698 37.8811 8 40.0899 8C40.7748 8 41.4436 8.06886 42.0898 8.20003V10.252C41.4506 10.0875 40.7805 10 40.0899 10C38.1572 10 36.386 10.6843 35.0029 11.8253C35.2578 12.5233 35.4707 13.2415 35.6386 13.9768C36.7365 12.7628 38.3241 12 40.0899 12C40.7911 12 41.4643 12.1203 42.0898 12.3414V14.5351C41.5015 14.1948 40.8185 14 40.0899 14C37.8808 14 36.0899 15.7909 36.0899 18C36.0899 20.2091 37.8808 22 40.0899 22C42.299 22 44.0899 20.2091 44.0899 18L44.0898 17.9791V0.446033C52.1058 2.26495 58.0899 9.43365 58.0899 18C58.0899 27.9411 50.031 36 40.0899 36C35.9474 36 32.1317 34.6006 29.0898 32.2488C26.0482 34.6002 22.2313 36 18.0898 36C17.4138 36 16.7465 35.9627 16.0898 35.8901V33.8762C16.745 33.9579 17.4125 34 18.0898 34C21.6281 34 24.8962 32.8525 27.5452 30.9086C27.0636 30.4405 26.6081 29.9456 26.181 29.4264C23.8963 31.0469 21.1033 32 18.0899 32C17.4108 32 16.7431 31.9517 16.0898 31.8582V29.8341C16.7403 29.9432 17.4085 30 18.0899 30C20.6628 30 23.0446 29.1913 24.9979 27.8136C24.6328 27.2533 24.2981 26.6714 23.996 26.0703C22.3414 27.283 20.2987 28 18.0899 28C17.405 28 16.7361 27.9311 16.0898 27.7999V25.7479C16.7291 25.9125 17.3993 26 18.0899 26C20.0226 26 21.7938 25.3157 23.1769 24.1747C22.922 23.4767 22.7091 22.7585 22.5412 22.0232C21.4433 23.2372 19.8557 24 18.0899 24C17.3886 24 16.7154 23.8797 16.0898 23.6586V21.4648C16.6782 21.8052 17.3613 22 18.0899 22C20.299 22 22.0899 20.2091 22.0899 18C22.0899 15.7909 20.299 14 18.0899 14C15.8808 14 14.0899 15.7909 14.0899 18L14.0898 35.554C6.07389 33.7351 0.0898438 26.5663 0.0898438 18C0.0898438 8.05887 8.14877 0 18.0899 0C22.2324 0 26.048 1.39934 29.0899 3.75111C32.1316 1.3998 35.9483 0 40.0899 2.0365e-06ZM46.0898 3.16303V5.34723C50.8198 7.59414 54.0899 12.4152 54.0899 18C54.0899 25.732 47.8219 32 40.0899 32C32.3579 32 26.0899 25.732 26.0899 18C26.0899 13.5817 22.5082 10 18.0899 10C13.6716 10 10.0899 13.5817 10.0899 18C10.0899 20.0289 10.8452 21.8814 12.0899 23.2916V18C12.0899 14.6863 14.7762 12 18.0899 12C21.4036 12 24.0899 14.6863 24.0899 18C24.0899 26.8366 31.2533 34 40.0899 34C48.9265 34 56.0898 26.8366 56.0898 18C56.0898 11.2852 51.9535 5.53658 46.0898 3.16303ZM12.0899 26.0007L12.0898 28.3946C8.50306 26.3197 6.0899 22.4417 6.0899 18C6.0899 11.3726 11.4625 6 18.0899 6C24.7173 6 30.0899 11.3726 30.0899 18C30.0899 23.5228 34.5671 28 40.0899 28C45.6127 28 50.0899 23.5228 50.0899 18C50.0899 14.7284 48.5188 11.8237 46.0899 9.99929V7.60538C49.6767 9.68023 52.0899 13.5583 52.0899 18C52.0899 24.6274 46.7173 30 40.0899 30C33.4625 30 28.0899 24.6274 28.0899 18C28.0899 12.4772 23.6127 8 18.0899 8C12.567 8 8.08992 12.4772 8.08992 18C8.08992 21.2716 9.66101 24.1763 12.0899 26.0007ZM4.0899 18C4.0899 23.5848 7.35998 28.4058 12.0898 30.6527V32.837C6.2262 30.4634 2.08989 24.7148 2.0899 18C2.0899 9.16344 9.25334 2 18.0899 2C26.9265 2 34.0899 9.16344 34.0899 18C34.0899 21.3137 36.7762 24 40.0899 24C43.4036 24 46.0899 21.3137 46.0899 18L46.0899 12.7084C47.3346 14.1186 48.0899 15.9711 48.0899 18C48.0899 22.4183 44.5082 26 40.0899 26C35.6716 26 32.0899 22.4183 32.0899 18C32.0899 10.268 25.8219 4 18.0899 4C10.3579 4 4.0899 10.268 4.0899 18Z"></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span class="topbar-brand-text">
|
||||||
|
<span class="topbar-title">Device Management Console</span>
|
||||||
|
<span class="topbar-subtitle">Shitty Edition™</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<Button type="button" class="topbar-theme-button" @click="toggleDarkMode" text rounded>
|
||||||
|
<i :class="['pi ', 'pi ', { 'pi-moon': isDarkMode, 'pi-sun': !isDarkMode }]" />
|
||||||
|
</Button>
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
v-styleclass="{
|
||||||
|
selector: '@next',
|
||||||
|
enterFromClass: 'hidden',
|
||||||
|
enterActiveClass: 'animate-scalein',
|
||||||
|
leaveToClass: 'hidden',
|
||||||
|
leaveActiveClass: 'animate-fadeout',
|
||||||
|
hideOnOutsideClick: true,
|
||||||
|
}"
|
||||||
|
icon="pi pi-palette"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
aria-label="Settings"
|
||||||
|
/>
|
||||||
|
<AppConfig />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
210
console/src/components/dashboard/CommandsWidget.vue
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import MQTTService from '../../services/mqtt.js'
|
||||||
|
|
||||||
|
import Accordion from 'primevue/accordion';
|
||||||
|
import AccordionPanel from 'primevue/accordionpanel';
|
||||||
|
import AccordionHeader from 'primevue/accordionheader';
|
||||||
|
import AccordionContent from 'primevue/accordioncontent';
|
||||||
|
|
||||||
|
import { JsonForms } from "@jsonforms/vue";
|
||||||
|
import { primeVueRenderers } from 'jsonforms-primevue'
|
||||||
|
|
||||||
|
const renderers = Object.freeze([
|
||||||
|
...primeVueRenderers,
|
||||||
|
// here you can add custom renderers
|
||||||
|
]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
deviceId: String
|
||||||
|
})
|
||||||
|
|
||||||
|
const mqtt2 = new MQTTService()
|
||||||
|
|
||||||
|
const filters = ref({})
|
||||||
|
const commands = ref([])
|
||||||
|
const commandMap = reactive({})
|
||||||
|
|
||||||
|
function rebuildList() {
|
||||||
|
commands.value = Object.values(commandMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCommand(device, name, field, value) {
|
||||||
|
if (!commandMap[name]) {
|
||||||
|
commandMap[name] = {
|
||||||
|
name,
|
||||||
|
description: "No description provided.",
|
||||||
|
schema: {},
|
||||||
|
input: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commandMap[name][field] = value
|
||||||
|
rebuildList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCommand(cmd) {
|
||||||
|
const topic = `device/${props.deviceId}/command/${cmd.name}`
|
||||||
|
var payload = JSON.stringify(cmd.input, null, 2)
|
||||||
|
mqtt2.publish(topic, payload || '{}')
|
||||||
|
}
|
||||||
|
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
const schemaDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function showSchema(cmd) {
|
||||||
|
try {
|
||||||
|
schemaDialog.content = JSON.stringify(cmd.schema, null, 2)
|
||||||
|
} catch {
|
||||||
|
schemaDialog.content = cmd.schema || 'No schema available'
|
||||||
|
}
|
||||||
|
schemaDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
mqtt2.subscribe('device/+/command/#', (payload, topic) => {
|
||||||
|
|
||||||
|
const parts = topic.split('/')
|
||||||
|
|
||||||
|
const device = parts[1]
|
||||||
|
if (device !== props.deviceId) return
|
||||||
|
|
||||||
|
const command = parts[3]
|
||||||
|
const field = parts[4]
|
||||||
|
|
||||||
|
if (field === 'description') {
|
||||||
|
updateCommand(device, command, 'description', payload)
|
||||||
|
}
|
||||||
|
if (field === 'schema') {
|
||||||
|
const schema = JSON.parse(payload)
|
||||||
|
updateCommand(device, command, 'schema', schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonFormsConfig = {
|
||||||
|
validationMode: 'ValidateOnTouched',
|
||||||
|
showAllErrors: false,
|
||||||
|
showErrorsOnTouched: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="schemaDialog.visible"
|
||||||
|
header="Command Schema"
|
||||||
|
:modal="true"
|
||||||
|
:closable="true"
|
||||||
|
:style="{width: '400px'}"
|
||||||
|
>
|
||||||
|
<pre>{{ schemaDialog.content }}</pre>
|
||||||
|
</Dialog>
|
||||||
|
<div class="layout-card col-item-2">
|
||||||
|
<DataView :value="commands">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex products-header">
|
||||||
|
<span class="products-title">Commands</span>
|
||||||
|
<IconField class="search-field">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model="filters['global']" placeholder="Filter by..." />
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #empty> No commands available.</template>
|
||||||
|
<template #list="slotProps">
|
||||||
|
<Accordion >
|
||||||
|
|
||||||
|
<AccordionPanel
|
||||||
|
v-for="cmd in slotProps.items"
|
||||||
|
:key="cmd.name"
|
||||||
|
:value="cmd.name"
|
||||||
|
>
|
||||||
|
|
||||||
|
<AccordionHeader>
|
||||||
|
<div class="command-header">
|
||||||
|
<span class="command-name">{{ cmd.name }}</span>
|
||||||
|
<span class="command-desc">{{ cmd.description }}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionHeader>
|
||||||
|
<AccordionContent>
|
||||||
|
<div class="command-content">
|
||||||
|
<!-- Left: Code editor -->
|
||||||
|
<JsonForms
|
||||||
|
:data="cmd.input"
|
||||||
|
:schema="cmd.schema"
|
||||||
|
:renderers="renderers"
|
||||||
|
:config="jsonFormsConfig"
|
||||||
|
:onChange="({ data, errors }) => cmd.input = data"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Right: Buttons -->
|
||||||
|
<div class="button-container">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-question-circle"
|
||||||
|
class="p-mb-2"
|
||||||
|
label="Help"
|
||||||
|
severity="secondary"
|
||||||
|
@click="showSchema(cmd)"
|
||||||
|
tooltip="Show schema"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-send"
|
||||||
|
label="Send"
|
||||||
|
@click="sendCommand(cmd)"
|
||||||
|
tooltip="Send command"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
|
||||||
|
</AccordionPanel>
|
||||||
|
|
||||||
|
</Accordion>
|
||||||
|
</template>
|
||||||
|
</DataView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.command-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-desc {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.command-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
103
console/src/components/dashboard/ProductOverviewWidget.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<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>
|
||||||
123
console/src/components/dashboard/PropertiesWidget.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import MQTTService from '../../services/mqtt.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
deviceId: String
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes = ref([])
|
||||||
|
const filters = ref({})
|
||||||
|
const filterMode = ref({ label: 'Lenient', value: 'lenient' });
|
||||||
|
|
||||||
|
const mqtt = new MQTTService()
|
||||||
|
|
||||||
|
/*
|
||||||
|
Internal storage
|
||||||
|
device -> property tree
|
||||||
|
*/
|
||||||
|
const devices = {}
|
||||||
|
|
||||||
|
// /*
|
||||||
|
// Convert nested object -> PrimeVue TreeTable format
|
||||||
|
// */
|
||||||
|
// function buildNodes() {
|
||||||
|
// nodes.value = Object.entries(devices).map(([deviceId, tree]) => ({
|
||||||
|
// key: deviceId,
|
||||||
|
// data: { name: deviceId },
|
||||||
|
// children: buildTree(tree, deviceId)
|
||||||
|
// }))
|
||||||
|
// }
|
||||||
|
|
||||||
|
let propertyTree = {}
|
||||||
|
|
||||||
|
// function buildTree(obj, path) {
|
||||||
|
function buildNodes(obj, path='') {
|
||||||
|
return Object.entries(obj).map(([key, val]) => {
|
||||||
|
// const fullKey = `${path}/${key}`
|
||||||
|
const fullKey = path ? `${path}/${key}` : key
|
||||||
|
|
||||||
|
if (val && typeof val === 'object' && !('value' in val)) {
|
||||||
|
return {
|
||||||
|
key: fullKey,
|
||||||
|
data: { name: key },
|
||||||
|
children: buildNodes(val, fullKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: fullKey,
|
||||||
|
data: {
|
||||||
|
name: key,
|
||||||
|
value: val.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Insert MQTT property into nested tree
|
||||||
|
*/
|
||||||
|
function insertProperty(pathParts, value) {
|
||||||
|
let node = propertyTree
|
||||||
|
|
||||||
|
pathParts.forEach((part, i) => {
|
||||||
|
if (i === pathParts.length - 1) {
|
||||||
|
node[part] = { value }
|
||||||
|
} else {
|
||||||
|
if (!node[part]) node[part] = {}
|
||||||
|
node = node[part]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nodes.value = buildNodes(propertyTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.deviceId, () => {
|
||||||
|
propertyTree = {}
|
||||||
|
nodes.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
mqtt.subscribe('device/+/property/#', (payload, topic) => {
|
||||||
|
const parts = topic.split('/')
|
||||||
|
|
||||||
|
const device = parts[1]
|
||||||
|
const propertyPath = parts.slice(3)
|
||||||
|
|
||||||
|
if (device !== props.deviceId) return
|
||||||
|
|
||||||
|
insertProperty(propertyPath, payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-card col-item-2">
|
||||||
|
<TreeTable :value="nodes" :filters="filters" :filterMode="filterMode.value">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex products-header">
|
||||||
|
<span class="products-title">Properties</span>
|
||||||
|
<IconField class="search-field">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model="filters['global']" placeholder="Filter by..." />
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #empty> No properties available.</template>
|
||||||
|
<Column field="name" header="Name" expander style="min-width: 12rem">
|
||||||
|
<template #filter>
|
||||||
|
<InputText v-model="filters['name']" type="text" placeholder="Filter by name" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="value" header="Value" style="min-width: 12rem">
|
||||||
|
<template #filter>
|
||||||
|
<InputText v-model="filters['value']" type="text" placeholder="Filter by value" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</TreeTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
43
console/src/components/dashboard/RecentActivityWidget.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<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>
|
||||||
94
console/src/components/dashboard/SalesTrendWidget.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<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>
|
||||||
45
console/src/components/dashboard/StatsWidget.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<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>
|
||||||
459
console/src/composables/useLayout.js
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
import { updatePrimaryPalette, updateSurfacePalette } from "@primeuix/themes";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const appState = ref({
|
||||||
|
primary: "emerald",
|
||||||
|
surface: null,
|
||||||
|
darkMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryColors = ref([
|
||||||
|
{
|
||||||
|
name: "emerald",
|
||||||
|
palette: {
|
||||||
|
50: "#ecfdf5",
|
||||||
|
100: "#d1fae5",
|
||||||
|
200: "#a7f3d0",
|
||||||
|
300: "#6ee7b7",
|
||||||
|
400: "#34d399",
|
||||||
|
500: "#10b981",
|
||||||
|
600: "#059669",
|
||||||
|
700: "#047857",
|
||||||
|
800: "#065f46",
|
||||||
|
900: "#064e3b",
|
||||||
|
950: "#022c22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "green",
|
||||||
|
palette: {
|
||||||
|
50: "#f0fdf4",
|
||||||
|
100: "#dcfce7",
|
||||||
|
200: "#bbf7d0",
|
||||||
|
300: "#86efac",
|
||||||
|
400: "#4ade80",
|
||||||
|
500: "#22c55e",
|
||||||
|
600: "#16a34a",
|
||||||
|
700: "#15803d",
|
||||||
|
800: "#166534",
|
||||||
|
900: "#14532d",
|
||||||
|
950: "#052e16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lime",
|
||||||
|
palette: {
|
||||||
|
50: "#f7fee7",
|
||||||
|
100: "#ecfccb",
|
||||||
|
200: "#d9f99d",
|
||||||
|
300: "#bef264",
|
||||||
|
400: "#a3e635",
|
||||||
|
500: "#84cc16",
|
||||||
|
600: "#65a30d",
|
||||||
|
700: "#4d7c0f",
|
||||||
|
800: "#3f6212",
|
||||||
|
900: "#365314",
|
||||||
|
950: "#1a2e05"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orange",
|
||||||
|
palette: {
|
||||||
|
50: "#fff7ed",
|
||||||
|
100: "#ffedd5",
|
||||||
|
200: "#fed7aa",
|
||||||
|
300: "#fdba74",
|
||||||
|
400: "#fb923c",
|
||||||
|
500: "#f97316",
|
||||||
|
600: "#ea580c",
|
||||||
|
700: "#c2410c",
|
||||||
|
800: "#9a3412",
|
||||||
|
900: "#7c2d12",
|
||||||
|
950: "#431407"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amber",
|
||||||
|
palette: {
|
||||||
|
50: "#fffbeb",
|
||||||
|
100: "#fef3c7",
|
||||||
|
200: "#fde68a",
|
||||||
|
300: "#fcd34d",
|
||||||
|
400: "#fbbf24",
|
||||||
|
500: "#f59e0b",
|
||||||
|
600: "#d97706",
|
||||||
|
700: "#b45309",
|
||||||
|
800: "#92400e",
|
||||||
|
900: "#78350f",
|
||||||
|
950: "#451a03"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yellow",
|
||||||
|
palette: {
|
||||||
|
50: "#fefce8",
|
||||||
|
100: "#fef9c3",
|
||||||
|
200: "#fef08a",
|
||||||
|
300: "#fde047",
|
||||||
|
400: "#facc15",
|
||||||
|
500: "#eab308",
|
||||||
|
600: "#ca8a04",
|
||||||
|
700: "#a16207",
|
||||||
|
800: "#854d0e",
|
||||||
|
900: "#713f12",
|
||||||
|
950: "#422006"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "teal",
|
||||||
|
palette: {
|
||||||
|
50: "#f0fdfa",
|
||||||
|
100: "#ccfbf1",
|
||||||
|
200: "#99f6e4",
|
||||||
|
300: "#5eead4",
|
||||||
|
400: "#2dd4bf",
|
||||||
|
500: "#14b8a6",
|
||||||
|
600: "#0d9488",
|
||||||
|
700: "#0f766e",
|
||||||
|
800: "#115e59",
|
||||||
|
900: "#134e4a",
|
||||||
|
950: "#042f2e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cyan",
|
||||||
|
palette: {
|
||||||
|
50: "#ecfeff",
|
||||||
|
100: "#cffafe",
|
||||||
|
200: "#a5f3fc",
|
||||||
|
300: "#67e8f9",
|
||||||
|
400: "#22d3ee",
|
||||||
|
500: "#06b6d4",
|
||||||
|
600: "#0891b2",
|
||||||
|
700: "#0e7490",
|
||||||
|
800: "#155e75",
|
||||||
|
900: "#164e63",
|
||||||
|
950: "#083344"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sky",
|
||||||
|
palette: {
|
||||||
|
50: "#f0f9ff",
|
||||||
|
100: "#e0f2fe",
|
||||||
|
200: "#bae6fd",
|
||||||
|
300: "#7dd3fc",
|
||||||
|
400: "#38bdf8",
|
||||||
|
500: "#0ea5e9",
|
||||||
|
600: "#0284c7",
|
||||||
|
700: "#0369a1",
|
||||||
|
800: "#075985",
|
||||||
|
900: "#0c4a6e",
|
||||||
|
950: "#082f49"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blue",
|
||||||
|
palette: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
100: "#dbeafe",
|
||||||
|
200: "#bfdbfe",
|
||||||
|
300: "#93c5fd",
|
||||||
|
400: "#60a5fa",
|
||||||
|
500: "#3b82f6",
|
||||||
|
600: "#2563eb",
|
||||||
|
700: "#1d4ed8",
|
||||||
|
800: "#1e40af",
|
||||||
|
900: "#1e3a8a",
|
||||||
|
950: "#172554"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "indigo",
|
||||||
|
palette: {
|
||||||
|
50: "#eef2ff",
|
||||||
|
100: "#e0e7ff",
|
||||||
|
200: "#c7d2fe",
|
||||||
|
300: "#a5b4fc",
|
||||||
|
400: "#818cf8",
|
||||||
|
500: "#6366f1",
|
||||||
|
600: "#4f46e5",
|
||||||
|
700: "#4338ca",
|
||||||
|
800: "#3730a3",
|
||||||
|
900: "#312e81",
|
||||||
|
950: "#1e1b4b"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "violet",
|
||||||
|
palette: {
|
||||||
|
50: "#f5f3ff",
|
||||||
|
100: "#ede9fe",
|
||||||
|
200: "#ddd6fe",
|
||||||
|
300: "#c4b5fd",
|
||||||
|
400: "#a78bfa",
|
||||||
|
500: "#8b5cf6",
|
||||||
|
600: "#7c3aed",
|
||||||
|
700: "#6d28d9",
|
||||||
|
800: "#5b21b6",
|
||||||
|
900: "#4c1d95",
|
||||||
|
950: "#2e1065"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "purple",
|
||||||
|
palette: {
|
||||||
|
50: "#faf5ff",
|
||||||
|
100: "#f3e8ff",
|
||||||
|
200: "#e9d5ff",
|
||||||
|
300: "#d8b4fe",
|
||||||
|
400: "#c084fc",
|
||||||
|
500: "#a855f7",
|
||||||
|
600: "#9333ea",
|
||||||
|
700: "#7e22ce",
|
||||||
|
800: "#6b21a8",
|
||||||
|
900: "#581c87",
|
||||||
|
950: "#3b0764"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fuchsia",
|
||||||
|
palette: {
|
||||||
|
50: "#fdf4ff",
|
||||||
|
100: "#fae8ff",
|
||||||
|
200: "#f5d0fe",
|
||||||
|
300: "#f0abfc",
|
||||||
|
400: "#e879f9",
|
||||||
|
500: "#d946ef",
|
||||||
|
600: "#c026d3",
|
||||||
|
700: "#a21caf",
|
||||||
|
800: "#86198f",
|
||||||
|
900: "#701a75",
|
||||||
|
950: "#4a044e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pink",
|
||||||
|
palette: {
|
||||||
|
50: "#fdf2f8",
|
||||||
|
100: "#fce7f3",
|
||||||
|
200: "#fbcfe8",
|
||||||
|
300: "#f9a8d4",
|
||||||
|
400: "#f472b6",
|
||||||
|
500: "#ec4899",
|
||||||
|
600: "#db2777",
|
||||||
|
700: "#be185d",
|
||||||
|
800: "#9d174d",
|
||||||
|
900: "#831843",
|
||||||
|
950: "#500724"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rose",
|
||||||
|
palette: {
|
||||||
|
50: "#fff1f2",
|
||||||
|
100: "#ffe4e6",
|
||||||
|
200: "#fecdd3",
|
||||||
|
300: "#fda4af",
|
||||||
|
400: "#fb7185",
|
||||||
|
500: "#f43f5e",
|
||||||
|
600: "#e11d48",
|
||||||
|
700: "#be123c",
|
||||||
|
800: "#9f1239",
|
||||||
|
900: "#881337",
|
||||||
|
950: "#4c0519"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const surfaces = ref([
|
||||||
|
{
|
||||||
|
name: "slate",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#f8fafc",
|
||||||
|
100: "#f1f5f9",
|
||||||
|
200: "#e2e8f0",
|
||||||
|
300: "#cbd5e1",
|
||||||
|
400: "#94a3b8",
|
||||||
|
500: "#64748b",
|
||||||
|
600: "#475569",
|
||||||
|
700: "#334155",
|
||||||
|
800: "#1e293b",
|
||||||
|
900: "#0f172a",
|
||||||
|
950: "#020617"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gray",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#f9fafb",
|
||||||
|
100: "#f3f4f6",
|
||||||
|
200: "#e5e7eb",
|
||||||
|
300: "#d1d5db",
|
||||||
|
400: "#9ca3af",
|
||||||
|
500: "#6b7280",
|
||||||
|
600: "#4b5563",
|
||||||
|
700: "#374151",
|
||||||
|
800: "#1f2937",
|
||||||
|
900: "#111827",
|
||||||
|
950: "#030712"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zinc",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#fafafa",
|
||||||
|
100: "#f4f4f5",
|
||||||
|
200: "#e4e4e7",
|
||||||
|
300: "#d4d4d8",
|
||||||
|
400: "#a1a1aa",
|
||||||
|
500: "#71717a",
|
||||||
|
600: "#52525b",
|
||||||
|
700: "#3f3f46",
|
||||||
|
800: "#27272a",
|
||||||
|
900: "#18181b",
|
||||||
|
950: "#09090b"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neutral",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#fafafa",
|
||||||
|
100: "#f5f5f5",
|
||||||
|
200: "#e5e5e5",
|
||||||
|
300: "#d4d4d4",
|
||||||
|
400: "#a3a3a3",
|
||||||
|
500: "#737373",
|
||||||
|
600: "#525252",
|
||||||
|
700: "#404040",
|
||||||
|
800: "#262626",
|
||||||
|
900: "#171717",
|
||||||
|
950: "#0a0a0a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stone",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#fafaf9",
|
||||||
|
100: "#f5f5f4",
|
||||||
|
200: "#e7e5e4",
|
||||||
|
300: "#d6d3d1",
|
||||||
|
400: "#a8a29e",
|
||||||
|
500: "#78716c",
|
||||||
|
600: "#57534e",
|
||||||
|
700: "#44403c",
|
||||||
|
800: "#292524",
|
||||||
|
900: "#1c1917",
|
||||||
|
950: "#0c0a09"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "soho",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#f4f4f4",
|
||||||
|
100: "#e8e9e9",
|
||||||
|
200: "#d2d2d4",
|
||||||
|
300: "#bbbcbe",
|
||||||
|
400: "#a5a5a9",
|
||||||
|
500: "#8e8f93",
|
||||||
|
600: "#77787d",
|
||||||
|
700: "#616268",
|
||||||
|
800: "#4a4b52",
|
||||||
|
900: "#34343d",
|
||||||
|
950: "#1d1e27"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "viva",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#f3f3f3",
|
||||||
|
100: "#e7e7e8",
|
||||||
|
200: "#cfd0d0",
|
||||||
|
300: "#b7b8b9",
|
||||||
|
400: "#9fa1a1",
|
||||||
|
500: "#87898a",
|
||||||
|
600: "#6e7173",
|
||||||
|
700: "#565a5b",
|
||||||
|
800: "#3e4244",
|
||||||
|
900: "#262b2c",
|
||||||
|
950: "#0e1315"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ocean",
|
||||||
|
palette: {
|
||||||
|
0: "#ffffff",
|
||||||
|
50: "#fbfcfc",
|
||||||
|
100: "#F7F9F8",
|
||||||
|
200: "#EFF3F2",
|
||||||
|
300: "#DADEDD",
|
||||||
|
400: "#B1B7B6",
|
||||||
|
500: "#828787",
|
||||||
|
600: "#5F7274",
|
||||||
|
700: "#415B61",
|
||||||
|
800: "#29444E",
|
||||||
|
900: "#183240",
|
||||||
|
950: "#0c1920"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function useLayout() {
|
||||||
|
function setPrimary(value) {
|
||||||
|
appState.value.primary = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSurface(value) {
|
||||||
|
appState.value.surface = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDarkMode(value) {
|
||||||
|
appState.value.darkMode = value;
|
||||||
|
if (value) {
|
||||||
|
document.documentElement.classList.add("p-dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("p-dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDarkMode() {
|
||||||
|
appState.value.darkMode = !appState.value.darkMode;
|
||||||
|
document.documentElement.classList.toggle("p-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColors(type, colorName) {
|
||||||
|
if (type === "primary") {
|
||||||
|
setPrimary(colorName);
|
||||||
|
const color = primaryColors.value.find((c) => c.name === colorName);
|
||||||
|
updatePrimaryPalette(color.palette);
|
||||||
|
} else if (type === "surface") {
|
||||||
|
setSurface(colorName);
|
||||||
|
const surfaceColor = surfaces.value.find((s) => s.name === colorName);
|
||||||
|
updateSurfacePalette(surfaceColor.palette);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDarkMode = computed(() => appState.value.darkMode);
|
||||||
|
const primary = computed(() => appState.value.primary);
|
||||||
|
const surface = computed(() => appState.value.surface);
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryColors,
|
||||||
|
surfaces,
|
||||||
|
isDarkMode,
|
||||||
|
primary,
|
||||||
|
surface,
|
||||||
|
toggleDarkMode,
|
||||||
|
setDarkMode,
|
||||||
|
setPrimary,
|
||||||
|
setSurface,
|
||||||
|
updateColors
|
||||||
|
};
|
||||||
|
}
|
||||||
19
console/src/main.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import "./assets/styles/main.css";
|
||||||
|
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import PrimeVue from "primevue/config";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import Aura from "@primeuix/themes/aura";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: ".p-dark",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
35
console/src/services/mqtt.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import mqtt from 'mqtt'
|
||||||
|
|
||||||
|
export default class MQTTService {
|
||||||
|
constructor(brokerUrl = 'ws://127.0.0.1:8083/mqtt', clientId = null) {
|
||||||
|
this.clientId = clientId || 'vue-client-' + Math.random().toString(16).substr(2, 8)
|
||||||
|
this.client = mqtt.connect(brokerUrl, { clientId: this.clientId })
|
||||||
|
this.subscriptions = [] // array of {topic, callback}
|
||||||
|
|
||||||
|
this.client.on('connect', () => console.log('Connected to MQTT broker'))
|
||||||
|
this.client.on('message', (topic, payload) => {
|
||||||
|
// iterate over subscriptions and check for matches
|
||||||
|
this.subscriptions.forEach(({topic: subTopic, callback}) => {
|
||||||
|
if (mqttMatch(subTopic, topic)) {
|
||||||
|
callback(payload.toString(), topic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(topic, callback) {
|
||||||
|
this.subscriptions.push({topic, callback})
|
||||||
|
this.client.subscribe(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(topic, message, options = {}) {
|
||||||
|
this.client.publish(topic, message, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper function for MQTT wildcards
|
||||||
|
function mqttMatch(subTopic, topic) {
|
||||||
|
// replace MQTT wildcards with RegExp
|
||||||
|
const regex = '^' + subTopic.replace('+', '[^/]+').replace('#', '.+') + '$'
|
||||||
|
return new RegExp(regex).test(topic)
|
||||||
|
}
|
||||||
16
console/vite.config.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
resolvers: [
|
||||||
|
PrimeVueResolver()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
24
dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
dashboard/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
dashboard/README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
13
dashboard/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2140
dashboard/package-lock.json
generated
Normal file
24
dashboard/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/themes": "^2.0.3",
|
||||||
|
"mqtt": "^5.15.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.5.4",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"sass": "^1.98.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dashboard/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
85
dashboard/src/App.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-grid p-m-2">
|
||||||
|
<div class="p-col-12 p-md-3">
|
||||||
|
<div class="p-panel p-shadow-3">
|
||||||
|
<div class="p-panel-header">Devices</div>
|
||||||
|
<div class="p-panel-content">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(device, id) in devices" :key="id">
|
||||||
|
<button
|
||||||
|
class="p-button p-button-text"
|
||||||
|
@click="selectDevice(id)"
|
||||||
|
>
|
||||||
|
{{ id }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-col-12 p-md-9" v-if="selected">
|
||||||
|
<div class="p-panel p-shadow-3">
|
||||||
|
<div class="p-panel-header">Properties</div>
|
||||||
|
<div class="p-panel-content">
|
||||||
|
<PropertyTree :properties="devices[selected].property" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import MQTTService from './services/mqtt.js'
|
||||||
|
import PropertyTree from './PropertyTree.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { PropertyTree },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
devices: reactive({}),
|
||||||
|
selected: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.mqtt = new MQTTService()
|
||||||
|
|
||||||
|
this.mqtt.client.on('connect', () => {
|
||||||
|
console.log('Connected to MQTT broker')
|
||||||
|
this.mqtt.subscribe('asset/+/status', this.handleStatus)
|
||||||
|
this.mqtt.subscribe('asset/+/property/#', this.handleProperty)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectDevice(id) {
|
||||||
|
this.selected = id
|
||||||
|
},
|
||||||
|
|
||||||
|
handleStatus(payload, topic) {
|
||||||
|
const deviceId = topic.split('/')[1]
|
||||||
|
if (!this.devices[deviceId]) this.devices[deviceId] = { property: {}, status: null }
|
||||||
|
this.devices[deviceId].status = payload
|
||||||
|
},
|
||||||
|
|
||||||
|
handleProperty(payload, topic) {
|
||||||
|
const parts = topic.split('/')
|
||||||
|
const deviceId = parts[1]
|
||||||
|
const propertyPath = parts.slice(3) // after asset/device/property/...
|
||||||
|
if (!this.devices[deviceId]) this.devices[deviceId] = { property: {}, status: null }
|
||||||
|
|
||||||
|
// traverse/create tree
|
||||||
|
let node = this.devices[deviceId].property
|
||||||
|
propertyPath.forEach((p, i) => {
|
||||||
|
if (i === propertyPath.length - 1) {
|
||||||
|
// leaf
|
||||||
|
node[p] = { value: payload, updated: Date.now() } // flash on update
|
||||||
|
} else {
|
||||||
|
if (!node[p]) node[p] = {}
|
||||||
|
node = node[p]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
dashboard/src/PropertyTree.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<Tree
|
||||||
|
:value="treeData"
|
||||||
|
:expansion-keys="expansionKeys"
|
||||||
|
>
|
||||||
|
<template #default="{ node }">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
backgroundColor: recentlyUpdated(node) ? '#fffa90' : 'transparent',
|
||||||
|
padding: '0 2px',
|
||||||
|
borderRadius: '2px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ node.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Tree>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PropertyTree',
|
||||||
|
components: { Tree },
|
||||||
|
props: {
|
||||||
|
properties: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
treeData: [],
|
||||||
|
expansionKeys: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
properties: {
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.treeData = this.buildTree(this.properties)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
buildTree(obj, path = []) {
|
||||||
|
return Object.entries(obj).map(([key, val]) => {
|
||||||
|
if (val && typeof val === 'object' && 'value' in val) {
|
||||||
|
return {
|
||||||
|
key: [...path, key].join('/'),
|
||||||
|
label: `${key}: ${val.value}`,
|
||||||
|
data: val,
|
||||||
|
}
|
||||||
|
} else if (val && typeof val === 'object') {
|
||||||
|
const children = this.buildTree(val, [...path, key])
|
||||||
|
this.expansionKeys[[...path, key].join('/')] = true
|
||||||
|
return {
|
||||||
|
key: [...path, key].join('/'),
|
||||||
|
label: key,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
recentlyUpdated(node) {
|
||||||
|
const updated = node.data?.updated
|
||||||
|
return updated && Date.now() - updated < 500
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
BIN
dashboard/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
dashboard/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
1
dashboard/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
93
dashboard/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
26
dashboard/src/main.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// src/main.js
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// PrimeVue 4
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Aura from '@primeuix/themes/aura';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import Panel from 'primevue/panel'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register components globally
|
||||||
|
app.component('Button', Button)
|
||||||
|
app.component('Tree', Tree)
|
||||||
|
app.component('Panel', Panel)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
35
dashboard/src/services/mqtt.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import mqtt from 'mqtt'
|
||||||
|
|
||||||
|
export default class MQTTService {
|
||||||
|
constructor(brokerUrl = 'ws://127.0.0.1:8083/mqtt', clientId = null) {
|
||||||
|
this.clientId = clientId || 'vue-client-' + Math.random().toString(16).substr(2, 8)
|
||||||
|
this.client = mqtt.connect(brokerUrl, { clientId: this.clientId })
|
||||||
|
this.subscriptions = [] // array of {topic, callback}
|
||||||
|
|
||||||
|
this.client.on('connect', () => console.log('Connected to MQTT broker'))
|
||||||
|
this.client.on('message', (topic, payload) => {
|
||||||
|
// iterate over subscriptions and check for matches
|
||||||
|
this.subscriptions.forEach(({topic: subTopic, callback}) => {
|
||||||
|
if (mqttMatch(subTopic, topic)) {
|
||||||
|
callback(payload.toString(), topic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(topic, callback) {
|
||||||
|
this.subscriptions.push({topic, callback})
|
||||||
|
this.client.subscribe(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(topic, message, options = {}) {
|
||||||
|
this.client.publish(topic, message, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper function for MQTT wildcards
|
||||||
|
function mqttMatch(subTopic, topic) {
|
||||||
|
// replace MQTT wildcards with RegExp
|
||||||
|
const regex = '^' + subTopic.replace('+', '[^/]+').replace('#', '.+') + '$'
|
||||||
|
return new RegExp(regex).test(topic)
|
||||||
|
}
|
||||||
296
dashboard/src/style.css
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
dashboard/vite.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
@ -3,6 +3,7 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import paho
|
import paho
|
||||||
import signal
|
import signal
|
||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from command import (
|
from command import (
|
||||||
@ -63,7 +64,7 @@ class MQTTHandler:
|
|||||||
for name, command in self.get_available_commands().items():
|
for name, command in self.get_available_commands().items():
|
||||||
await self.mqtt_client.publish(
|
await self.mqtt_client.publish(
|
||||||
f"{self.command_topic}/{command.name}/schema",
|
f"{self.command_topic}/{command.name}/schema",
|
||||||
str(command.schema),
|
json.dumps(command.schema),
|
||||||
qos=1,
|
qos=1,
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
|
|||||||