mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-20 00:51:41 +00:00
Fixes a bug where offline update did not start after confirming the dev-version warning dialog. The confirm action was using an incorrect file reference in template context, so the selected JAR was not passed correctly to the upload handler. The dialog closed, but no upload request was sent. This change corrects the confirm handler so the selected file is passed properly and the upload/install flow starts as expected.
595 lines
21 KiB
Vue
595 lines
21 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 offlineUpdateRegex = new RegExp("photonvision-((?:dev-)?v[\\w.-]+)-((?:linux|win|mac)\\w+)\\.jar");
|
|
const majorVersionRegex = new RegExp("(?:dev-)?(\\d+)\\.\\d+\\.\\d+");
|
|
|
|
const offlineUpdateDialog = ref({ show: false, confirmString: "" });
|
|
|
|
const handleOfflineUpdateRequest = async () => {
|
|
const files = offlineUpdate.value.files;
|
|
if (files.length === 0) return;
|
|
|
|
const match = files[0].name.match(offlineUpdateRegex);
|
|
if (!match) {
|
|
useStateStore().showSnackbarMessage({
|
|
message: "Selected file does not match expected naming convention.",
|
|
color: "error"
|
|
});
|
|
return;
|
|
}
|
|
|
|
const version = match[1] as string;
|
|
const arch = match[2] as string;
|
|
|
|
const currentVersion = useSettingsStore().general.imageVersion;
|
|
const currentArch = useSettingsStore().general.wpilibArch;
|
|
|
|
const versionMajor = version.match(majorVersionRegex)?.[1];
|
|
const currentVersionMajor = currentVersion?.match(majorVersionRegex)?.[1];
|
|
|
|
const versionMatch = currentVersion ? versionMajor === currentVersionMajor : false;
|
|
const dev = version.includes("dev");
|
|
|
|
if (currentArch && arch !== currentArch) {
|
|
useStateStore().showSnackbarMessage({
|
|
message: `Selected file architecture (${arch}) does not match device architecture (${currentArch}).`,
|
|
color: "error"
|
|
});
|
|
return;
|
|
} else if (versionMatch && !dev) {
|
|
handleOfflineUpdate(files[0]);
|
|
} else if (!versionMatch && !dev) {
|
|
offlineUpdateDialog.value = {
|
|
show: true,
|
|
confirmString: `You are attempting to update from PhotonVision ${currentVersion} on image ${useSettingsStore().general.imageVersion} to ${version} from a different FRC year. These versions may be incompatible. Are you sure you want to proceed?`
|
|
};
|
|
} else if (versionMatch && dev) {
|
|
offlineUpdateDialog.value = {
|
|
show: true,
|
|
confirmString:
|
|
"You are attempting to update to a dev version. This could result in instability. Are you sure you want to proceed?"
|
|
};
|
|
} else if (!versionMatch && dev) {
|
|
offlineUpdateDialog.value = {
|
|
show: true,
|
|
confirmString: `You are attempting to update to a dev version, from PhotonVision ${currentVersion} on image ${useSettingsStore().general.imageVersion} to ${version} from a different FRC year. These versions may be incompatible, and you may experience instability. Are you sure you want to proceed?`
|
|
};
|
|
}
|
|
};
|
|
|
|
const handleOfflineUpdate = async (file: File) => {
|
|
const formData = new FormData();
|
|
formData.append("jarData", file);
|
|
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: "Image Version", value: useSettingsStore().general.imageVersion || "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="handleOfflineUpdateRequest"
|
|
/>
|
|
</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>
|
|
|
|
<v-dialog v-model="offlineUpdateDialog.show" :width="700" dark>
|
|
<v-card color="surface" flat>
|
|
<v-card-title style="display: flex; justify-content: center"> Offline Update </v-card-title>
|
|
<v-card-text class="pt-0 pb-10px">
|
|
<span> {{ offlineUpdateDialog.confirmString }} </span>
|
|
</v-card-text>
|
|
<v-card-text class="pt-10px">
|
|
<v-row class="align-center text-white">
|
|
<v-col cols="12">
|
|
<v-btn
|
|
color="buttonActive"
|
|
width="100%"
|
|
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
|
@click="
|
|
offlineUpdateDialog.show = false;
|
|
handleOfflineUpdate(offlineUpdate.files[0]);
|
|
"
|
|
>
|
|
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
|
|
<span class="open-label"> Confirm Update </span>
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</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>
|