mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-22 01:11:40 +00:00
511 lines
18 KiB
Vue
511 lines
18 KiB
Vue
@ -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 = async () => {
|
|
if (await axiosPost("/utils/restartProgram", "restart PhotonVision")) {
|
|
forceReloadPage();
|
|
}
|
|
};
|
|
const restartDevice = async () => {
|
|
if (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
|
|
});
|
|
if (
|
|
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"
|
|
});
|
|
}
|
|
}
|
|
})
|
|
) {
|
|
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 () => {
|
|
if (await 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>
|
|
<Suspense>
|
|
<!-- Allows us to import echarts when it's actually needed -->
|
|
<MetricsChart id="chart" :data="cpuUsageData" type="percentage" :min="0" :max="100" color="blue" />
|
|
<template #fallback> Loading... </template>
|
|
</Suspense>
|
|
</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>
|
|
<Suspense>
|
|
<!-- Allows us to import echarts when it's actually needed -->
|
|
<MetricsChart id="chart" :data="cpuMemoryUsageData" type="percentage" :min="0" :max="100" color="purple" />
|
|
<template #fallback> Loading... </template>
|
|
</Suspense>
|
|
</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>
|
|
<Suspense>
|
|
<!-- Allows us to import echarts when it's actually needed -->
|
|
<MetricsChart id="chart" :data="cpuTempData" type="temperature" color="red" />
|
|
<template #fallback> Loading... </template>
|
|
</Suspense>
|
|
</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>
|
|
<Suspense>
|
|
<!-- Allows us to import echarts when it's actually needed -->
|
|
<MetricsChart id="chart" :data="networkUsageData" type="mb" :min="0" :max="10" color="green" />
|
|
<template #fallback> Loading... </template>
|
|
</Suspense>
|
|
</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>
|