Device metrics and control reorganization (#2144)

This commit is contained in:
Devon Doyle
2026-01-11 13:25:49 -05:00
committed by GitHub
parent 5aefb2957d
commit 8e9fe38477
10 changed files with 815 additions and 595 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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: {

View File

@@ -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 = {

View File

@@ -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>