2026-01-11 13:25:49 -05:00
@ - 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 ( ) ;
2026-01-26 23:11:25 -05:00
const restartProgram = async ( ) => {
if ( await axiosPost ( "/utils/restartProgram" , "restart PhotonVision" ) ) {
forceReloadPage ( ) ;
}
2026-01-11 13:25:49 -05:00
} ;
2026-01-26 23:11:25 -05:00
const restartDevice = async ( ) => {
if ( await axiosPost ( "/utils/restartDevice" , "restart the device" ) ) {
forceReloadPage ( ) ;
}
2026-01-11 13:25:49 -05:00
} ;
const address = inject < string > ( "backendHost" ) ;
const offlineUpdate = ref ( ) ;
const openOfflineUpdatePrompt = ( ) => {
offlineUpdate . value . click ( ) ;
} ;
2026-02-18 01:24:46 -06:00
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 ( ) => {
2026-01-11 13:25:49 -05:00
const files = offlineUpdate . value . files ;
if ( files . length === 0 ) return ;
2026-02-18 01:24:46 -06:00
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 ) => {
2026-01-11 13:25:49 -05:00
const formData = new FormData ( ) ;
2026-02-18 01:24:46 -06:00
formData . append ( "jarData" , file ) ;
2026-01-11 13:25:49 -05:00
useStateStore ( ) . showSnackbarMessage ( {
message : "New Software Upload in Progress..." ,
color : "secondary" ,
timeout : - 1
} ) ;
2026-01-26 23:11:25 -05:00
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"
} ) ;
}
2026-01-11 13:25:49 -05:00
}
2026-01-26 23:11:25 -05:00
} )
) {
useStateStore ( ) . showSnackbarMessage ( {
message : "Installing uploaded software..." ,
color : "secondary" ,
timeout : - 1
} ) ;
forceReloadPage ( ) ;
}
2026-01-11 13:25:49 -05:00
} ;
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 ) ;
2026-01-26 23:11:25 -05:00
const nukePhotonConfigDirectory = async ( ) => {
if ( await axiosPost ( "/utils/nukeConfigDirectory" , "delete the config directory" ) ) {
forceReloadPage ( ) ;
}
2026-01-11 13:25:49 -05:00
} ;
interface MetricItem {
header : string ;
value ? : string ;
}
const generalMetrics = computed < MetricItem [ ] > ( ( ) => {
const stats = [
{ header : "Version" , value : useSettingsStore ( ) . general . version || "Unknown" } ,
2026-02-18 01:24:46 -06:00
{ header : "Image Version" , value : useSettingsStore ( ) . general . imageVersion || "Unknown" } ,
2026-01-11 13:25:49 -05:00
{ 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 / 1 e6 ) . 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 ) / 1 e6
} ) ) ;
} ) ;
< / 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"
2026-02-18 01:24:46 -06:00
@ change = "handleOfflineUpdateRequest"
2026-01-11 13:25:49 -05:00
/ >
< / 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 >
2026-02-02 11:06:48 -05:00
< 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 >
2026-01-11 13:25:49 -05:00
< / 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 >
2026-02-02 11:06:48 -05:00
< 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 >
2026-01-11 13:25:49 -05:00
< / 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 >
2026-02-02 11:06:48 -05:00
< 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 >
2026-01-11 13:25:49 -05:00
< / 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 < / s p a n
>
< / div >
2026-02-02 11:06:48 -05:00
< 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 >
2026-01-11 13:25:49 -05:00
< / 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 >
2026-02-18 01:24:46 -06:00
< 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 . value . 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 >
2026-01-11 13:25:49 -05:00
< 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 % - 12 px ) ! important ;
}
@ media only screen and ( max - width : 351 px ) {
. open - icon {
margin : 0 ! important ;
}
. open - label {
display : none ;
}
}
< / style >