mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-19 00:41:41 +00:00
Device metrics and control reorganization (#2144)
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"axios": "^1.11.0",
|
||||
"echarts": "^6.0.0",
|
||||
"jspdf": "^3.0.1",
|
||||
"pinia": "^3.0.2",
|
||||
"three": "^0.178.0",
|
||||
|
||||
27
photon-client/pnpm-lock.yaml
generated
27
photon-client/pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
jspdf:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
@@ -848,6 +851,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -1408,6 +1414,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -1548,6 +1557,9 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
@@ -2062,7 +2074,7 @@ snapshots:
|
||||
typescript: 5.8.3
|
||||
vue: 3.5.13(typescript@5.8.3)
|
||||
|
||||
'@vuetify/loader-shared@2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3)':
|
||||
'@vuetify/loader-shared@2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3(typescript@5.8.3)(vite-plugin-vuetify@2.1.1)(vue@3.5.13(typescript@5.8.3)))':
|
||||
dependencies:
|
||||
upath: 2.0.1
|
||||
vue: 3.5.13(typescript@5.8.3)
|
||||
@@ -2215,6 +2227,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
@@ -2784,6 +2801,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
@@ -2819,7 +2838,7 @@ snapshots:
|
||||
|
||||
vite-plugin-vuetify@2.1.1(vite@7.0.5(@types/node@22.15.14)(sass@1.89.2))(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3):
|
||||
dependencies:
|
||||
'@vuetify/loader-shared': 2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3)
|
||||
'@vuetify/loader-shared': 2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3(typescript@5.8.3)(vite-plugin-vuetify@2.1.1)(vue@3.5.13(typescript@5.8.3)))
|
||||
debug: 4.4.0
|
||||
upath: 2.0.1
|
||||
vite: 7.0.5(@types/node@22.15.14)(sass@1.89.2)
|
||||
@@ -2889,3 +2908,7 @@ snapshots:
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
defineProps<{
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
location?: "top" | "bottom" | "left" | "right";
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-tooltip :disabled="tooltip === undefined" location="right" open-delay="300">
|
||||
<v-tooltip :disabled="tooltip === undefined" :location="location ?? 'right'" open-delay="300">
|
||||
<template #activator="{ props }">
|
||||
<span style="cursor: text !important" class="text-white" v-bind="props">{{ label }}</span>
|
||||
<v-icon v-if="icon" small class="ml-2" color="primary" v-bind="props">
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ tooltip }}</span>
|
||||
</v-tooltip>
|
||||
|
||||
489
photon-client/src/components/settings/DeviceCard.vue
Normal file
489
photon-client/src/components/settings/DeviceCard.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
@ -0,0 +1,565 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, ref, watch } from "vue";
|
||||
import { useStateStore } from "@/stores/StateStore";
|
||||
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
|
||||
import PvSelect from "@/components/common/pv-select.vue";
|
||||
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
|
||||
import MetricsChart from "./MetricsChart.vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import { axiosPost, forceReloadPage } from "@/lib/PhotonUtils";
|
||||
import TooltippedLabel from "@/components/common/pv-tooltipped-label.vue";
|
||||
import { metricsHistorySnapshot } from "@/stores/settings/GeneralSettingsStore";
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const restartProgram = () => {
|
||||
axiosPost("/utils/restartProgram", "restart PhotonVision");
|
||||
forceReloadPage();
|
||||
};
|
||||
const restartDevice = () => {
|
||||
axiosPost("/utils/restartDevice", "restart the device");
|
||||
forceReloadPage();
|
||||
};
|
||||
|
||||
const address = inject<string>("backendHost");
|
||||
|
||||
const offlineUpdate = ref();
|
||||
const openOfflineUpdatePrompt = () => {
|
||||
offlineUpdate.value.click();
|
||||
};
|
||||
const handleOfflineUpdate = async () => {
|
||||
const files = offlineUpdate.value.files;
|
||||
if (files.length === 0) return;
|
||||
const formData = new FormData();
|
||||
formData.append("jarData", files[0]);
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "New Software Upload in Progress...",
|
||||
color: "secondary",
|
||||
timeout: -1
|
||||
});
|
||||
await axiosPost("/utils/offlineUpdate", "upload new software", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
onUploadProgress: ({ progress }) => {
|
||||
const uploadPercentage = (progress || 0) * 100.0;
|
||||
if (uploadPercentage < 99.5) {
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "New Software Upload in Progress",
|
||||
color: "secondary",
|
||||
timeout: -1,
|
||||
progressBar: uploadPercentage,
|
||||
progressBarColor: "primary"
|
||||
});
|
||||
} else {
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "Installing uploaded software...",
|
||||
color: "secondary",
|
||||
timeout: -1
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
forceReloadPage();
|
||||
};
|
||||
|
||||
const exportLogFile = ref();
|
||||
const openExportLogsPrompt = () => {
|
||||
exportLogFile.value.click();
|
||||
};
|
||||
|
||||
const exportSettings = ref();
|
||||
const openExportSettingsPrompt = () => {
|
||||
exportSettings.value.click();
|
||||
};
|
||||
|
||||
enum ImportType {
|
||||
AllSettings,
|
||||
HardwareConfig,
|
||||
HardwareSettings,
|
||||
NetworkConfig,
|
||||
ApriltagFieldLayout
|
||||
}
|
||||
|
||||
const showImportDialog = ref(false);
|
||||
const importType = ref<ImportType | undefined>(undefined);
|
||||
const importFile = ref<File | null>(null);
|
||||
|
||||
const handleSettingsImport = () => {
|
||||
if (importType.value === undefined || importFile.value === null) return;
|
||||
const formData = new FormData();
|
||||
formData.append("data", importFile.value);
|
||||
let settingsEndpoint: string;
|
||||
switch (importType.value) {
|
||||
case ImportType.HardwareConfig:
|
||||
settingsEndpoint = "/hardwareConfig";
|
||||
break;
|
||||
case ImportType.HardwareSettings:
|
||||
settingsEndpoint = "/hardwareSettings";
|
||||
break;
|
||||
case ImportType.NetworkConfig:
|
||||
settingsEndpoint = "/networkConfig";
|
||||
break;
|
||||
case ImportType.ApriltagFieldLayout:
|
||||
settingsEndpoint = "/aprilTagFieldLayout";
|
||||
break;
|
||||
default:
|
||||
case ImportType.AllSettings:
|
||||
settingsEndpoint = "";
|
||||
break;
|
||||
}
|
||||
axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
showImportDialog.value = false;
|
||||
importType.value = undefined;
|
||||
importFile.value = null;
|
||||
};
|
||||
|
||||
const showFactoryReset = ref(false);
|
||||
const nukePhotonConfigDirectory = () => {
|
||||
axiosPost("/utils/nukeConfigDirectory", "delete the config directory");
|
||||
forceReloadPage();
|
||||
};
|
||||
|
||||
interface MetricItem {
|
||||
header: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const generalMetrics = computed<MetricItem[]>(() => {
|
||||
const stats = [
|
||||
{ header: "Version", value: useSettingsStore().general.version || "Unknown" },
|
||||
{ header: "Hardware Model", value: useSettingsStore().general.hardwareModel || "Unknown" },
|
||||
{ header: "Platform", value: useSettingsStore().general.hardwarePlatform || "Unknown" },
|
||||
{ header: "GPU Acceleration", value: useSettingsStore().general.gpuAcceleration || "None detected" }
|
||||
];
|
||||
|
||||
if (!useSettingsStore().network.networkingDisabled) {
|
||||
stats.push({ header: "IP Address", value: useSettingsStore().metrics.ipAddress || "Unknown" });
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
// @ts-expect-error This uses Intl.DurationFormat which is newly implemented and not available in TS.
|
||||
const durationFormatter = new Intl.DurationFormat("en", { style: "narrow" });
|
||||
const platformMetrics = computed<MetricItem[]>(() => {
|
||||
const metrics = useSettingsStore().metrics;
|
||||
const stats = [
|
||||
{
|
||||
header: "Uptime",
|
||||
value: (() => {
|
||||
const seconds = metrics.uptime;
|
||||
if (seconds === undefined) return "Unknown";
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
return durationFormatter.format({
|
||||
days: days,
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
seconds: secs
|
||||
});
|
||||
})()
|
||||
}
|
||||
];
|
||||
|
||||
if (metrics.npuUsage && metrics.npuUsage.length > 0) {
|
||||
stats.push({
|
||||
header: "NPU Usage",
|
||||
value: metrics.npuUsage?.map((usage, index) => `Core${index} ${usage}%`).join(", ") || "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.gpuMem && metrics.gpuMemUtil && metrics.gpuMem > 0 && metrics.gpuMemUtil > 0) {
|
||||
stats.push({
|
||||
header: "GPU Memory Usage",
|
||||
value: `${metrics.gpuMemUtil}MB of ${metrics.gpuMem}MB`
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.cpuThr) {
|
||||
stats.push({
|
||||
header: "CPU Throttling",
|
||||
value: metrics.cpuThr.toString()
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.recvBitRate && metrics.recvBitRate !== -1) {
|
||||
stats.push({
|
||||
header: "Received Bit Rate",
|
||||
value: `${(metrics.recvBitRate / 1e6).toFixed(5)} Mb/s`
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
const cpuUsageData = ref<{ time: number; value: number }[]>([]);
|
||||
const cpuMemoryUsageData = ref<{ time: number; value: number }[]>([]);
|
||||
const cpuTempData = ref<{ time: number; value: number }[]>([]);
|
||||
const networkUsageData = ref<{ time: number; value: number }[]>([]);
|
||||
|
||||
watch(metricsHistorySnapshot, () => {
|
||||
cpuUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
|
||||
time: entry.time,
|
||||
value: entry.metrics.cpuUtil ?? 0
|
||||
}));
|
||||
cpuMemoryUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
|
||||
time: entry.time,
|
||||
value: entry.metrics.ramUtil === -1 ? -1 : ((entry.metrics.ramUtil ?? 0) / (entry.metrics.ramMem ?? -1.0)) * 100
|
||||
}));
|
||||
cpuTempData.value = metricsHistorySnapshot.value.map((entry) => ({
|
||||
time: entry.time,
|
||||
value: entry.metrics.cpuTemp ?? 0
|
||||
}));
|
||||
networkUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
|
||||
time: entry.time,
|
||||
value: entry.metrics.sentBitRate === -1 ? -1 : (entry.metrics.sentBitRate ?? 0) / 1e6
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row no-gutters>
|
||||
<!-- Device control card -->
|
||||
<v-col class="pr-3">
|
||||
<v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
|
||||
<v-card-title class="d-flex justify-space-between">
|
||||
<span>Device Control</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="flex-0-0">
|
||||
<v-table>
|
||||
<tbody>
|
||||
<tr v-for="(item, itemIndex) in generalMetrics.concat(platformMetrics)" :key="itemIndex">
|
||||
<td :key="itemIndex">
|
||||
{{ item.header }}
|
||||
</td>
|
||||
<td :key="itemIndex">
|
||||
{{ item.value }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="useStateStore().showLogModal = true"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-eye </v-icon>
|
||||
<span class="open-label">View Logs</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openExportLogsPrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-download </v-icon>
|
||||
<span class="open-label">Download Logs</span>
|
||||
|
||||
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
|
||||
<a
|
||||
ref="exportLogFile"
|
||||
style="color: black; text-decoration: none; display: none"
|
||||
:href="`http://${address}/api/utils/photonvision-journalctl.txt`"
|
||||
download="photonvision-journalctl.txt"
|
||||
target="_blank"
|
||||
/>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="() => (showImportDialog = true)"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-import </v-icon>
|
||||
<span class="open-label">Import Settings</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openExportSettingsPrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-export </v-icon>
|
||||
<span class="open-label">Export Settings</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6"
|
||||
><v-btn
|
||||
color="buttonActive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="restartProgram"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-restart </v-icon>
|
||||
<span class="open-label">Restart Software</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openOfflineUpdatePrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
|
||||
<span class="open-label">Offline Update</span>
|
||||
</v-btn>
|
||||
<input
|
||||
ref="offlineUpdate"
|
||||
type="file"
|
||||
accept=".jar"
|
||||
style="display: none"
|
||||
@change="handleOfflineUpdate"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonActive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="restartDevice"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-restart-alert </v-icon>
|
||||
<span class="open-label">Reboot Device</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="error"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="() => (showFactoryReset = true)"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-trash-can-outline </v-icon>
|
||||
<span class="open-icon"> Factory Reset </span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Device metrics card -->
|
||||
<v-col>
|
||||
<v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
|
||||
<v-card-title class="d-flex justify-space-between">
|
||||
<span>Device Metrics</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0 flex-0-0 pb-2">
|
||||
<div class="d-flex justify-space-between pb-3">
|
||||
<span>CPU Usage</span>
|
||||
<span>{{ Math.round(cpuUsageData.at(-1)?.value ?? 0) }}%</span>
|
||||
</div>
|
||||
<MetricsChart id="chart" :data="cpuUsageData" type="percentage" :min="0" :max="100" color="blue" />
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0 pb-2">
|
||||
<div class="d-flex justify-space-between pb-3 pt-3">
|
||||
<span>CPU Memory Usage</span>
|
||||
<span>{{ Math.round(cpuMemoryUsageData.at(-1)?.value ?? 0) }}%</span>
|
||||
</div>
|
||||
<MetricsChart id="chart" :data="cpuMemoryUsageData" type="percentage" :min="0" :max="100" color="purple" />
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0 pb-2">
|
||||
<div class="d-flex justify-space-between pb-3 pt-3">
|
||||
<span>CPU Temperature</span>
|
||||
<span>{{ cpuTempData.at(-1)?.value == -1 ? "--- " : Math.round(cpuTempData.at(-1)?.value ?? 0) }}°C</span>
|
||||
</div>
|
||||
<MetricsChart id="chart" :data="cpuTempData" type="temperature" color="red" />
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0 flex-0-0">
|
||||
<div class="d-flex justify-space-between pb-3 pt-3">
|
||||
<tooltipped-label
|
||||
label="Network Usage"
|
||||
icon="mdi-information"
|
||||
location="top"
|
||||
tooltip="Measured rate for this coprocessor ONLY. This FMS limit is for ALL robot communication. If you are experiencing bandwidth issues while under this limit, check other sources."
|
||||
/>
|
||||
<span
|
||||
>{{ networkUsageData.at(-1)?.value == -1 ? "---" : networkUsageData.at(-1)?.value.toFixed(3) }} Mb/s</span
|
||||
>
|
||||
</div>
|
||||
<MetricsChart id="chart" :data="networkUsageData" type="mb" :min="0" :max="10" color="green" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Factory reset modal -->
|
||||
<pv-delete-modal
|
||||
v-model="showFactoryReset"
|
||||
title="Factory Reset PhotonVision"
|
||||
description="This will delete all settings and configurations stored on this device, including network settings. This action cannot be undone."
|
||||
expected-confirmation-text="Delete Everything"
|
||||
:on-confirm="nukePhotonConfigDirectory"
|
||||
:on-backup="openExportSettingsPrompt"
|
||||
delete-text="Factory reset"
|
||||
/>
|
||||
|
||||
<!-- Import settings modal -->
|
||||
<v-dialog
|
||||
v-model="showImportDialog"
|
||||
width="600"
|
||||
@update:modelValue="
|
||||
() => {
|
||||
importType = undefined;
|
||||
importFile = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<v-card color="surface" dark>
|
||||
<v-card-title class="pb-0">Import Settings</v-card-title>
|
||||
<v-card-text>
|
||||
Upload and apply previously saved or exported PhotonVision settings to this device
|
||||
<div class="pa-5 pb-0">
|
||||
<pv-select
|
||||
v-model="importType"
|
||||
label="Type"
|
||||
tooltip="Select the type of settings file you are trying to upload"
|
||||
:items="['All Settings', 'Hardware Config', 'Hardware Settings', 'Network Config', 'Apriltag Layout']"
|
||||
:select-cols="10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<v-file-input
|
||||
v-model="importFile"
|
||||
class="pb-5"
|
||||
variant="underlined"
|
||||
:disabled="importType === undefined"
|
||||
:error-messages="importType === undefined ? 'Settings type not selected' : ''"
|
||||
:accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="importFile === null"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="handleSettingsImport"
|
||||
>
|
||||
<v-icon start class="open-icon"> mdi-import </v-icon>
|
||||
<span class="open-label">Import Settings</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<a
|
||||
ref="exportSettings"
|
||||
style="color: black; text-decoration: none; display: none"
|
||||
:href="`http://${address}/api/settings/photonvision_config.zip`"
|
||||
download="photonvision-settings.zip"
|
||||
target="_blank"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.v-btn:not(.refresh) {
|
||||
width: 100%;
|
||||
}
|
||||
.fill-height {
|
||||
height: calc(100% - 12px) !important;
|
||||
}
|
||||
@media only screen and (max-width: 351px) {
|
||||
.open-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.open-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,307 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from "vue";
|
||||
import { useStateStore } from "@/stores/StateStore";
|
||||
import PvSelect from "@/components/common/pv-select.vue";
|
||||
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import { axiosPost, forceReloadPage } from "@/lib/PhotonUtils";
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const restartProgram = async () => {
|
||||
await axiosPost("/utils/restartProgram", "restart PhotonVision");
|
||||
forceReloadPage();
|
||||
};
|
||||
const restartDevice = async () => {
|
||||
await axiosPost("/utils/restartDevice", "restart the device");
|
||||
forceReloadPage();
|
||||
};
|
||||
|
||||
const address = inject<string>("backendHost");
|
||||
|
||||
const offlineUpdate = ref();
|
||||
const openOfflineUpdatePrompt = () => {
|
||||
offlineUpdate.value.click();
|
||||
};
|
||||
const handleOfflineUpdate = async () => {
|
||||
const files = offlineUpdate.value.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("jarData", files[0]);
|
||||
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "New Software Upload in Progress...",
|
||||
color: "secondary",
|
||||
timeout: -1
|
||||
});
|
||||
|
||||
await axiosPost("/utils/offlineUpdate", "upload new software", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
onUploadProgress: ({ progress }) => {
|
||||
const uploadPercentage = (progress || 0) * 100.0;
|
||||
if (uploadPercentage < 99.5) {
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "New Software Upload in Progress",
|
||||
color: "secondary",
|
||||
timeout: -1,
|
||||
progressBar: uploadPercentage,
|
||||
progressBarColor: "primary"
|
||||
});
|
||||
} else {
|
||||
useStateStore().showSnackbarMessage({
|
||||
message: "Installing uploaded software...",
|
||||
color: "secondary",
|
||||
timeout: -1
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
forceReloadPage();
|
||||
};
|
||||
|
||||
const exportLogFile = ref();
|
||||
const openExportLogsPrompt = () => {
|
||||
exportLogFile.value.click();
|
||||
};
|
||||
|
||||
const exportSettings = ref();
|
||||
const openExportSettingsPrompt = () => {
|
||||
exportSettings.value.click();
|
||||
};
|
||||
|
||||
enum ImportType {
|
||||
AllSettings,
|
||||
HardwareConfig,
|
||||
HardwareSettings,
|
||||
NetworkConfig,
|
||||
ApriltagFieldLayout
|
||||
}
|
||||
const showImportDialog = ref(false);
|
||||
const importType = ref<ImportType | undefined>(undefined);
|
||||
const importFile = ref<File | null>(null);
|
||||
const handleSettingsImport = () => {
|
||||
if (importType.value === undefined || importFile.value === null) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("data", importFile.value);
|
||||
|
||||
let settingsEndpoint: string;
|
||||
switch (importType.value) {
|
||||
case ImportType.HardwareConfig:
|
||||
settingsEndpoint = "/hardwareConfig";
|
||||
break;
|
||||
case ImportType.HardwareSettings:
|
||||
settingsEndpoint = "/hardwareSettings";
|
||||
break;
|
||||
case ImportType.NetworkConfig:
|
||||
settingsEndpoint = "/networkConfig";
|
||||
break;
|
||||
case ImportType.ApriltagFieldLayout:
|
||||
settingsEndpoint = "/aprilTagFieldLayout";
|
||||
break;
|
||||
default:
|
||||
case ImportType.AllSettings:
|
||||
settingsEndpoint = "";
|
||||
break;
|
||||
}
|
||||
|
||||
axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
|
||||
showImportDialog.value = false;
|
||||
importType.value = undefined;
|
||||
importFile.value = null;
|
||||
};
|
||||
|
||||
const showFactoryReset = ref(false);
|
||||
const nukePhotonConfigDirectory = async () => {
|
||||
await axiosPost("/utils/nukeConfigDirectory", "delete the config directory");
|
||||
forceReloadPage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="mb-3 rounded-12" color="surface">
|
||||
<v-card-title>Device Control</v-card-title>
|
||||
<div class="pa-5 pt-0">
|
||||
<v-row>
|
||||
<v-col cols="12" lg="4" md="6">
|
||||
<v-btn
|
||||
color="buttonActive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="restartProgram"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-restart </v-icon>
|
||||
<span class="open-label">Restart PhotonVision</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" lg="4" md="6">
|
||||
<v-btn
|
||||
color="buttonActive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="restartDevice"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-restart-alert </v-icon>
|
||||
<span class="open-label">Restart Device</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" lg="4">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openOfflineUpdatePrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
|
||||
<span class="open-label">Offline Update</span>
|
||||
</v-btn>
|
||||
<input ref="offlineUpdate" type="file" accept=".jar" style="display: none" @change="handleOfflineUpdate" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="() => (showImportDialog = true)"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-import </v-icon>
|
||||
<span class="open-label">Import Settings</span>
|
||||
</v-btn>
|
||||
<v-dialog
|
||||
v-model="showImportDialog"
|
||||
width="600"
|
||||
@update:modelValue="
|
||||
() => {
|
||||
importType = undefined;
|
||||
importFile = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<v-card color="surface" dark>
|
||||
<v-card-title class="pb-0">Import Settings</v-card-title>
|
||||
<v-card-text>
|
||||
Upload and apply previously saved or exported PhotonVision settings to this device
|
||||
<div class="pa-5 pb-0">
|
||||
<pv-select
|
||||
v-model="importType"
|
||||
label="Type"
|
||||
tooltip="Select the type of settings file you are trying to upload"
|
||||
:items="[
|
||||
'All Settings',
|
||||
'Hardware Config',
|
||||
'Hardware Settings',
|
||||
'Network Config',
|
||||
'Apriltag Layout'
|
||||
]"
|
||||
:select-cols="10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<v-file-input
|
||||
v-model="importFile"
|
||||
class="pb-5"
|
||||
variant="underlined"
|
||||
:disabled="importType === undefined"
|
||||
:error-messages="importType === undefined ? 'Settings type not selected' : ''"
|
||||
:accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="importFile === null"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="handleSettingsImport"
|
||||
>
|
||||
<v-icon start class="open-icon"> mdi-import </v-icon>
|
||||
<span class="open-label">Import Settings</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openExportSettingsPrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-export </v-icon>
|
||||
<span class="open-label">Export Settings</span>
|
||||
</v-btn>
|
||||
<a
|
||||
ref="exportSettings"
|
||||
style="color: black; text-decoration: none; display: none"
|
||||
:href="`http://${address}/api/settings/photonvision_config.zip`"
|
||||
download="photonvision-settings.zip"
|
||||
target="_blank"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="openExportLogsPrompt"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-download </v-icon>
|
||||
<span class="open-label">Download logs</span>
|
||||
|
||||
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
|
||||
<a
|
||||
ref="exportLogFile"
|
||||
style="color: black; text-decoration: none; display: none"
|
||||
:href="`http://${address}/api/utils/photonvision-journalctl.txt`"
|
||||
download="photonvision-journalctl.txt"
|
||||
target="_blank"
|
||||
/>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
color="buttonPassive"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="useStateStore().showLogModal = true"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-eye </v-icon>
|
||||
<span class="open-label">View logs</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
color="error"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="() => (showFactoryReset = true)"
|
||||
>
|
||||
<v-icon start class="open-icon" size="large"> mdi-trash-can-outline </v-icon>
|
||||
<span class="open-icon"> Factory Reset PhotonVision </span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<pv-delete-modal
|
||||
v-model="showFactoryReset"
|
||||
title="Factory Reset PhotonVision"
|
||||
description="This will delete all settings and configurations stored on this device, including network settings. This action cannot be undone."
|
||||
expected-confirmation-text="Delete Everything"
|
||||
:on-confirm="nukePhotonConfigDirectory"
|
||||
:on-backup="openExportSettingsPrompt"
|
||||
delete-text="Factory reset"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn {
|
||||
width: 100%;
|
||||
}
|
||||
@media only screen and (max-width: 351px) {
|
||||
.open-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.open-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,276 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
|
||||
import { computed } from "vue";
|
||||
|
||||
interface MetricItem {
|
||||
header: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const generalMetrics = computed<MetricItem[]>(() => {
|
||||
const stats = [
|
||||
{ header: "Version", value: useSettingsStore().general.version || "Unknown" },
|
||||
{ header: "Hardware Model", value: useSettingsStore().general.hardwareModel || "Unknown" },
|
||||
{ header: "Platform", value: useSettingsStore().general.hardwarePlatform || "Unknown" },
|
||||
{ header: "GPU Acceleration", value: useSettingsStore().general.gpuAcceleration || "Unknown" }
|
||||
];
|
||||
|
||||
if (!useSettingsStore().network.networkingDisabled) {
|
||||
stats.push({ header: "IP Address", value: useSettingsStore().metrics.ipAddress || "Unknown" });
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
// @ts-expect-error This uses Intl.DurationFormat which is newly implemented and not available in TS.
|
||||
const durationFormatter = new Intl.DurationFormat("en", { style: "narrow" });
|
||||
const platformMetrics = computed<MetricItem[]>(() => {
|
||||
const metrics = useSettingsStore().metrics;
|
||||
const stats = [
|
||||
{
|
||||
header: "CPU Temp",
|
||||
value: metrics.cpuTemp === undefined || metrics.cpuTemp == -1 ? "Unknown" : `${metrics.cpuTemp.toFixed(1)}°C`
|
||||
},
|
||||
{
|
||||
header: "CPU Usage",
|
||||
value: metrics.cpuUtil === undefined ? "Unknown" : `${metrics.cpuUtil.toFixed(1)}%`
|
||||
},
|
||||
{
|
||||
header: "CPU Memory Usage",
|
||||
value:
|
||||
metrics.ramUtil && metrics.ramMem && metrics.ramUtil >= 0 && metrics.ramMem >= 0
|
||||
? `${metrics.ramUtil} of ${metrics.ramMem} MiB`
|
||||
: "Unknown"
|
||||
},
|
||||
{
|
||||
header: "Uptime",
|
||||
value: (() => {
|
||||
const seconds = metrics.uptime;
|
||||
if (seconds === undefined) return "Unknown";
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
return durationFormatter.format({
|
||||
days: days,
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
seconds: secs
|
||||
});
|
||||
})()
|
||||
},
|
||||
{
|
||||
header: "Disk Usage",
|
||||
value: metrics.diskUtilPct === undefined ? "Unknown" : `${metrics.diskUtilPct.toFixed(1)}%`
|
||||
},
|
||||
{
|
||||
header: "Network Traffic",
|
||||
value:
|
||||
metrics.sentBitRate === undefined || metrics.recvBitRate === undefined
|
||||
? "Missing"
|
||||
: `↑${(metrics.sentBitRate / 1e6).toFixed(3).padStart(7, "\u00A0")} Mbps | ↓${(metrics.recvBitRate / 1e6).toFixed(3).padStart(7, "\u00A0")} Mbps`
|
||||
}
|
||||
];
|
||||
|
||||
if (metrics.npuUsage && metrics.npuUsage.length > 0) {
|
||||
stats.push({
|
||||
header: "NPU Usage",
|
||||
value: metrics.npuUsage?.map((usage, index) => `Core${index} ${usage}%`).join(", ") || "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.gpuMem && metrics.gpuMemUtil && metrics.gpuMem > 0 && metrics.gpuMemUtil > 0) {
|
||||
stats.push({
|
||||
header: "GPU Memory Usage",
|
||||
value: `${metrics.gpuMemUtil} of ${metrics.gpuMem} MiB`
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.cpuThr) {
|
||||
stats.push({
|
||||
header: "CPU Throttling",
|
||||
value: metrics.cpuThr.toString()
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "medium"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="mb-3 rounded-12" color="surface">
|
||||
<v-card-title style="display: flex; justify-content: space-between">
|
||||
<span>Metrics</span>
|
||||
<span class="metrics-update-time">
|
||||
Last Update: <span>{{ formattedDate.format(useSettingsStore().lastMetricsUpdate) }}</span>
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0 pb-3">
|
||||
<v-card-subtitle class="pa-0" style="font-size: 16px">General</v-card-subtitle>
|
||||
<v-table class="metrics-table mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="(item, itemIndex) in generalMetrics"
|
||||
:key="itemIndex"
|
||||
class="metric-item metric-item-title"
|
||||
:class="{
|
||||
tl: itemIndex === 0,
|
||||
tr: itemIndex === generalMetrics.length - 1,
|
||||
t: 0 < itemIndex && itemIndex < generalMetrics.length - 1
|
||||
}"
|
||||
>
|
||||
{{ item.header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
v-for="(item, itemIndex) in generalMetrics"
|
||||
:key="itemIndex"
|
||||
class="metric-item"
|
||||
:class="{
|
||||
bl: itemIndex === 0,
|
||||
br: itemIndex === generalMetrics.length - 1,
|
||||
b: 0 < itemIndex && itemIndex < generalMetrics.length - 1
|
||||
}"
|
||||
>
|
||||
{{ item.value }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-4">
|
||||
<v-card-subtitle class="pa-0 pb-1" style="font-size: 16px">Hardware</v-card-subtitle>
|
||||
<v-table class="metrics-table mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="(item, itemIndex) in platformMetrics"
|
||||
:key="itemIndex"
|
||||
class="metric-item metric-item-title"
|
||||
:class="{
|
||||
tl: itemIndex === 0,
|
||||
tr: itemIndex === platformMetrics.length - 1,
|
||||
t: 0 < itemIndex && itemIndex < platformMetrics.length - 1
|
||||
}"
|
||||
>
|
||||
{{ item.header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
v-for="(item, itemIndex) in platformMetrics"
|
||||
:key="itemIndex"
|
||||
class="metric-item"
|
||||
:class="{
|
||||
bl: itemIndex === 0,
|
||||
br: itemIndex === platformMetrics.length - 1,
|
||||
b: 0 < itemIndex && itemIndex < platformMetrics.length - 1
|
||||
}"
|
||||
>
|
||||
<span v-if="useSettingsStore().metrics.cpuUtil !== undefined">{{ item.value }}</span>
|
||||
<span v-else>---</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.metrics-table {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
.metrics-update-time {
|
||||
font-family: monospace !important;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
$stats-table-border: rgba(255, 255, 255, 0.5);
|
||||
$stats-table-inner: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.t {
|
||||
border-top: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
border-bottom: 1px solid $stats-table-inner !important;
|
||||
}
|
||||
|
||||
.b {
|
||||
border-bottom: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
}
|
||||
|
||||
.tl {
|
||||
border-top: 1px solid $stats-table-border;
|
||||
border-left: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
border-bottom: 1px solid $stats-table-inner !important;
|
||||
border-top-left-radius: 5px;
|
||||
}
|
||||
|
||||
.tr {
|
||||
border-top: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
border-bottom: 1px solid $stats-table-inner !important;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.bl {
|
||||
border-bottom: 1px solid $stats-table-border;
|
||||
border-left: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
|
||||
.br {
|
||||
border-bottom: 1px solid $stats-table-border;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
font-size: 16px !important;
|
||||
padding: 1px 15px 1px 10px;
|
||||
border-right: 1px solid $stats-table-border;
|
||||
font-weight: normal;
|
||||
color: white !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.metric-item-title {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.v-table {
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0.55em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--v-theme-accent));
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
photon-client/src/components/settings/MetricsChart.vue
Normal file
236
photon-client/src/components/settings/MetricsChart.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import * as echarts from "echarts";
|
||||
import { onMounted, ref, onBeforeUnmount, watch } from "vue";
|
||||
import { useTheme } from "vuetify";
|
||||
|
||||
// Color - original (adjusted)
|
||||
// blue - 59, 130, 246 (r: 92, g: 154, b: 255)
|
||||
// purple - 154, 100, 180 (r: 167, g: 104, b: 196)
|
||||
// green - 65, 181, 127 (r: 75, g: 209, b: 147)
|
||||
// red - 238, 102, 102 (r: 238, g: 102, b: 102)
|
||||
const colors = {
|
||||
"blue-LightTheme": { r: 255, g: 216, b: 67 },
|
||||
"blue-DarkTheme": { r: 92, g: 154, b: 255 },
|
||||
"purple-LightTheme": { r: 255, g: 216, b: 67 },
|
||||
"purple-DarkTheme": { r: 167, g: 104, b: 196 },
|
||||
"red-LightTheme": { r: 255, g: 216, b: 67 },
|
||||
"red-DarkTheme": { r: 238, g: 102, b: 102 },
|
||||
"green-LightTheme": { r: 255, g: 216, b: 67 },
|
||||
"green-DarkTheme": { r: 75, g: 209, b: 147 }
|
||||
};
|
||||
const DEFAULT_COLOR = "blue";
|
||||
|
||||
const typeLabels = {
|
||||
percentage: "%",
|
||||
temperature: "°C",
|
||||
mb: " Mb/s"
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
const chartRef = ref(null);
|
||||
let chart: echarts.ECharts | null = null;
|
||||
|
||||
const getOptions = (data: ChartData[] = []) => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
title: {
|
||||
show: false
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: (params: any) => {
|
||||
const p = params[0];
|
||||
const append = typeLabels[props.type];
|
||||
const fmsLimitLabel = "FMS Limit - 7.000 Mb/s";
|
||||
|
||||
// prettier-ignore
|
||||
let tooltip = "<div style=\"text-align: right;\">";
|
||||
const seriesData = `${new Date(p.value[0]).toLocaleTimeString([], { hour12: false })} - ${p.value[1].toFixed(props.type === "mb" ? 3 : 2)}${append}`;
|
||||
|
||||
if (props.type === "mb") {
|
||||
if (p.value[1] >= 7) tooltip += seriesData + `<br/>${fmsLimitLabel}`;
|
||||
else tooltip += fmsLimitLabel + `<br/>${seriesData}`;
|
||||
} else tooltip += seriesData;
|
||||
|
||||
return `${tooltip}</div>`;
|
||||
},
|
||||
backgroundColor: theme.themes.value[theme.global.name.value].colors.background,
|
||||
textStyle: {
|
||||
color: theme.themes.value[theme.global.name.value].colors.onBackground
|
||||
},
|
||||
axisPointer: {
|
||||
animation: false
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
left: 0,
|
||||
right: 50,
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: "time",
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#ffffff18"
|
||||
}
|
||||
},
|
||||
splitNumber: 4,
|
||||
min: now - 55 * 1000,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.global.name.value === "LightTheme" ? "#aaa" : "#777"
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
align: "left",
|
||||
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd",
|
||||
formatter: (value: number) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
position: "right",
|
||||
min:
|
||||
props.min ??
|
||||
function (value) {
|
||||
return Math.max(0, (value.min - 10) | 0);
|
||||
},
|
||||
max:
|
||||
props.max ??
|
||||
function (value) {
|
||||
return (value.max + 10) | 0;
|
||||
},
|
||||
splitNumber: 2,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: "#ffffff18"
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd"
|
||||
}
|
||||
},
|
||||
series: getSeries(data),
|
||||
animation: false
|
||||
};
|
||||
};
|
||||
|
||||
const getSeries = (data: ChartData[] = []) => {
|
||||
const color = colors[`${props.color ?? DEFAULT_COLOR}-${theme.global.name.value}`];
|
||||
return [
|
||||
{
|
||||
type: "line",
|
||||
showSymbol: false,
|
||||
data: data.map((d) => [d.time, d.value]),
|
||||
markLine:
|
||||
props.type === "mb"
|
||||
? {
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
color: "red",
|
||||
width: 1
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [{ yAxis: 7 }]
|
||||
}
|
||||
: null,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
color:
|
||||
theme.global.name.value === "LightTheme"
|
||||
? theme.themes.value[theme.global.name.value].colors.primary
|
||||
: `rgb(${color.r}, ${color.g}, ${color.b})`
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color:
|
||||
theme.global.name.value === "LightTheme"
|
||||
? `${theme.themes.value[theme.global.name.value].colors.primary}40`
|
||||
: `rgba(${color.r}, ${color.g}, ${color.b}, 0.15)`
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color:
|
||||
theme.global.name.value === "LightTheme"
|
||||
? `${theme.themes.value[theme.global.name.value].colors.primary}40`
|
||||
: `rgba(${color.r}, ${color.g}, ${color.b}, 0.15)`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
interface ChartData {
|
||||
time: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// Type options: "percentage", "temperature", "mb"
|
||||
const props = defineProps<{
|
||||
data: ChartData[];
|
||||
type: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
color?: string;
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
chart = echarts.init(chartRef.value);
|
||||
chart.setOption(getOptions(props.data));
|
||||
|
||||
window.addEventListener("resize", resizeChart);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", resizeChart);
|
||||
chart?.dispose();
|
||||
});
|
||||
|
||||
function resizeChart() {
|
||||
chart?.resize();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(data) => {
|
||||
chart?.setOption(getOptions(data));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chartRef"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
width: calc(100% + 20px);
|
||||
height: 100px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,11 @@ export interface NTConnectionStatus {
|
||||
clients?: number;
|
||||
}
|
||||
|
||||
interface NetworkUsageEntry {
|
||||
time: number;
|
||||
usage: number;
|
||||
}
|
||||
|
||||
interface StateStore {
|
||||
backendConnected: boolean;
|
||||
websocket?: AutoReconnectingWebsocket;
|
||||
@@ -24,6 +29,7 @@ interface StateStore {
|
||||
sidebarFolded: boolean;
|
||||
logMessages: LogMessage[];
|
||||
currentCameraUniqueName: string;
|
||||
networkUsageHistory: NetworkUsageEntry[];
|
||||
|
||||
backendResults: Record<number, PipelineResult>;
|
||||
multitagResultBuffer: Record<string, MultitagResult[]>;
|
||||
@@ -64,6 +70,7 @@ export const useStateStore = defineStore("state", {
|
||||
localStorage.getItem("sidebarFolded") === null ? false : localStorage.getItem("sidebarFolded") === "true",
|
||||
logMessages: [],
|
||||
currentCameraUniqueName: Object.keys(cameraStore.cameras)[0],
|
||||
networkUsageHistory: [],
|
||||
|
||||
backendResults: {
|
||||
0: {
|
||||
|
||||
@@ -10,16 +10,60 @@ import { NetworkConnectionType } from "@/types/SettingTypes";
|
||||
import { useStateStore } from "@/stores/StateStore";
|
||||
import axios from "axios";
|
||||
import type { WebsocketSettingsUpdate } from "@/types/WebsocketDataTypes";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface GeneralSettingsStore {
|
||||
general: GeneralSettings;
|
||||
network: NetworkSettings;
|
||||
lighting: LightingSettings;
|
||||
metrics: MetricData;
|
||||
lastMetricsUpdate: Date;
|
||||
currentFieldLayout;
|
||||
}
|
||||
|
||||
interface MetricsEntry {
|
||||
time: number;
|
||||
metrics: MetricData;
|
||||
}
|
||||
|
||||
class MetricsHistory {
|
||||
private MAX_METRIC_HISTORY = 60;
|
||||
private UPDATE_INTERVAL_MS = 900;
|
||||
|
||||
private buffer: (MetricsEntry | undefined)[];
|
||||
private size: number;
|
||||
private index = 0;
|
||||
private count = 0;
|
||||
private lastUpdate = 0;
|
||||
|
||||
constructor(size = this.MAX_METRIC_HISTORY) {
|
||||
this.size = size;
|
||||
this.buffer = new Array<MetricsEntry | undefined>(size);
|
||||
}
|
||||
|
||||
update(value: MetricsEntry): boolean {
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdate < this.UPDATE_INTERVAL_MS) return false;
|
||||
|
||||
this.lastUpdate = now;
|
||||
this.buffer[this.index] = value;
|
||||
this.index = (this.index + 1) % this.size;
|
||||
this.count = Math.min(this.count + 1, this.size);
|
||||
return true;
|
||||
}
|
||||
|
||||
getHistory(): MetricsEntry[] {
|
||||
const result: MetricsEntry[] = new Array(this.count);
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
const idx = (this.index - this.count + i + this.size) % this.size;
|
||||
result[i] = this.buffer[idx]!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const metricsHistoryBuffer = new MetricsHistory();
|
||||
export const metricsHistorySnapshot = ref<MetricsEntry[]>([]);
|
||||
|
||||
export const useSettingsStore = defineStore("settings", {
|
||||
state: (): GeneralSettingsStore => ({
|
||||
general: {
|
||||
@@ -76,8 +120,7 @@ export const useSettingsStore = defineStore("settings", {
|
||||
width: 8.2296
|
||||
},
|
||||
tags: []
|
||||
},
|
||||
lastMetricsUpdate: new Date()
|
||||
}
|
||||
}),
|
||||
getters: {
|
||||
gpuAccelerationEnabled(): boolean {
|
||||
@@ -89,7 +132,6 @@ export const useSettingsStore = defineStore("settings", {
|
||||
},
|
||||
actions: {
|
||||
updateMetricsFromWebsocket(data: Required<MetricData>) {
|
||||
this.lastMetricsUpdate = new Date();
|
||||
this.metrics = {
|
||||
cpuTemp: data.cpuTemp || undefined,
|
||||
cpuUtil: data.cpuUtil || undefined,
|
||||
@@ -106,6 +148,9 @@ export const useSettingsStore = defineStore("settings", {
|
||||
sentBitRate: data.sentBitRate || undefined,
|
||||
recvBitRate: data.recvBitRate || undefined
|
||||
};
|
||||
if (metricsHistoryBuffer.update({ time: Date.now(), metrics: this.metrics })) {
|
||||
metricsHistorySnapshot.value = metricsHistoryBuffer.getHistory();
|
||||
}
|
||||
},
|
||||
updateGeneralSettingsFromWebsocket(data: WebsocketSettingsUpdate) {
|
||||
this.general = {
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import MetricsCard from "@/components/settings/MetricsCard.vue";
|
||||
import DeviceControlCard from "@/components/settings/DeviceControlCard.vue";
|
||||
import ObjectDetectionCard from "@/components/settings/ObjectDetectionCard.vue";
|
||||
import GlobalSettingsCard from "@/components/settings/GlobalSettingsCard.vue";
|
||||
import LightingControlCard from "@/components/settings/LEDControlCard.vue";
|
||||
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
|
||||
import ApriltagControlCard from "@/components/settings/ApriltagControlCard.vue";
|
||||
import DeviceCard from "@/components/settings/DeviceCard.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-3">
|
||||
<MetricsCard />
|
||||
<DeviceControlCard />
|
||||
<DeviceCard />
|
||||
<GlobalSettingsCard />
|
||||
<ObjectDetectionCard v-if="useSettingsStore().general.supportedBackends.length > 0" />
|
||||
<LightingControlCard v-if="useSettingsStore().lighting.supported" />
|
||||
<Suspense>
|
||||
<!-- Allows us to import three js when it's actually needed -->
|
||||
<ApriltagControlCard />
|
||||
|
||||
<template #fallback> Loading... </template>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user