mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-27 02:01:40 +00:00
[photon-client] Log Viewer Improvements (#1385)
Fixes the following issues with the client log viewer: - Inconsistent and excessive spacing between log entries - Lack of responsiveness to window size or scaling Adds the following features to the log viewer: - Auto-scroll if scrolled to the bottom - Ability to clear logs on button click - Search function to filter logs - Displays the time the frontend captured a log and displays that timestamp in hh::mm::ss in the log viewer - Allows logs to be filtered to be after a certain time - General styling refinements to increase usability --------- Co-authored-by: Sriman Achanta <68172138+srimanachanta@users.noreply.github.com>
This commit is contained in:
@@ -63,7 +63,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="stream-container">
|
||||
<img :id="id" crossorigin="anonymous" :src="streamSrc" :alt="streamDesc" :style="streamStyle" ref="mjpgStream" />
|
||||
<img :id="id" ref="mjpgStream" crossorigin="anonymous" :src="streamSrc" :alt="streamDesc" :style="streamStyle" />
|
||||
<div class="stream-overlay" :style="overlayStyle">
|
||||
<pv-icon
|
||||
icon-name="mdi-camera-image"
|
||||
|
||||
24
photon-client/src/components/app/photon-log-entry.vue
Normal file
24
photon-client/src/components/app/photon-log-entry.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { LogLevel, type LogMessage } from "@/types/SettingTypes";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ source: LogMessage }>();
|
||||
|
||||
const logColorClass = computed<string>(() => {
|
||||
switch (props.source.level) {
|
||||
case LogLevel.ERROR:
|
||||
return "red--text";
|
||||
case LogLevel.WARN:
|
||||
return "yellow--text";
|
||||
case LogLevel.INFO:
|
||||
return "light-blue--text";
|
||||
case LogLevel.DEBUG:
|
||||
return "white--text";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="logColorClass">[{{ source.timestamp.toTimeString().split(" ")[0] }}] {{ source.message }}</div>
|
||||
</template>
|
||||
@@ -1,40 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { computed, inject, ref, watch } from "vue";
|
||||
import { LogLevel, type LogMessage } from "@/types/SettingTypes";
|
||||
import { useStateStore } from "@/stores/StateStore";
|
||||
|
||||
const selectedLogLevels = ref<LogLevel[]>([LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO]);
|
||||
|
||||
const logs = computed<LogMessage[]>(() =>
|
||||
useStateStore().logMessages.filter((message) => selectedLogLevels.value.includes(message.level))
|
||||
);
|
||||
import LogEntry from "@/components/app/photon-log-entry.vue";
|
||||
import VirtualList from "vue-virtual-scroll-list";
|
||||
|
||||
const backendHost = inject<string>("backendHost");
|
||||
|
||||
const getLogColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
return "red";
|
||||
case LogLevel.WARN:
|
||||
return "yellow";
|
||||
case LogLevel.INFO:
|
||||
return "green";
|
||||
case LogLevel.DEBUG:
|
||||
return "white";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const searchQuery = ref("");
|
||||
const timeInput = ref<string>();
|
||||
const autoScroll = ref(true);
|
||||
const logList = ref();
|
||||
const logKeeps = ref(40);
|
||||
const exportLogFile = ref();
|
||||
const selectedLogLevels = ref({
|
||||
[LogLevel.ERROR]: true,
|
||||
[LogLevel.WARN]: true,
|
||||
[LogLevel.INFO]: true,
|
||||
[LogLevel.DEBUG]: false
|
||||
});
|
||||
|
||||
const logs = computed<LogMessage[]>(() =>
|
||||
useStateStore()
|
||||
.logMessages.filter(
|
||||
(message) =>
|
||||
selectedLogLevels.value[message.level] &&
|
||||
message.message.toLowerCase().includes(searchQuery.value?.toLowerCase() || "") &&
|
||||
(timeInput.value === undefined ||
|
||||
message.timestamp >=
|
||||
new Date().setHours(
|
||||
parseInt(timeInput.value.substring(0, 2)),
|
||||
parseInt(timeInput.value.substring(3, 5)),
|
||||
parseInt(timeInput.value.substring(6, 8))
|
||||
))
|
||||
)
|
||||
.map((item, index) => ({ ...item, index: index }))
|
||||
);
|
||||
|
||||
watch(logs, () => {
|
||||
if (!logList.value) return;
|
||||
|
||||
// Dynamic list render size based on console size
|
||||
logKeeps.value = Math.ceil(logList.value.$el.clientHeight / 17.5) + 20;
|
||||
|
||||
const bottomOffset = Math.abs(
|
||||
logList.value.$el.scrollHeight - logList.value.$el.scrollTop - logList.value.$el.clientHeight
|
||||
);
|
||||
autoScroll.value = bottomOffset < 50;
|
||||
|
||||
if (autoScroll.value) logList.value.scrollToBottom();
|
||||
});
|
||||
|
||||
const getLogLevelFromIndex = (index: number): string => {
|
||||
return LogLevel[index];
|
||||
};
|
||||
|
||||
const exportLogFile = ref();
|
||||
|
||||
const handleLogExport = () => {
|
||||
exportLogFile.value.click();
|
||||
};
|
||||
|
||||
const handleLogClear = () => {
|
||||
useStateStore().logMessages = [];
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
switch (e.key) {
|
||||
case "`":
|
||||
@@ -46,20 +74,16 @@ document.addEventListener("keydown", (e) => {
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="useStateStore().showLogModal" width="1500" dark>
|
||||
<v-card dark class="pt-3" color="primary" flat>
|
||||
<v-row class="heading-container pl-6 pr-6">
|
||||
<v-col>
|
||||
<v-card-title>View Program Logs</v-card-title>
|
||||
<v-card dark class="dialog-container pa-6" color="primary" flat>
|
||||
<!-- Logs header -->
|
||||
<v-row class="no-gutters pb-3">
|
||||
<v-col cols="4">
|
||||
<v-card-title class="pa-0">Program Logs</v-card-title>
|
||||
</v-col>
|
||||
<v-col class="align-self-center">
|
||||
<v-btn
|
||||
color="secondary"
|
||||
style="margin-left: auto; max-width: 500px; width: 100%"
|
||||
depressed
|
||||
@click="handleLogExport"
|
||||
>
|
||||
<v-icon left class="open-icon"> mdi-download </v-icon>
|
||||
<span class="open-label">Download Current Log</span>
|
||||
<v-col class="align-self-center pl-3" style="text-align: right">
|
||||
<v-btn text color="white" @click="handleLogExport">
|
||||
<v-icon left class="menu-icon"> mdi-download </v-icon>
|
||||
<span class="menu-label">Download</span>
|
||||
|
||||
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
|
||||
<a
|
||||
@@ -70,58 +94,110 @@ document.addEventListener("keydown", (e) => {
|
||||
target="_blank"
|
||||
/>
|
||||
</v-btn>
|
||||
<v-btn text color="white" @click="handleLogClear">
|
||||
<v-icon left class="menu-icon"> mdi-trash-can-outline </v-icon>
|
||||
<span class="menu-label">Clear Client Logs</span>
|
||||
</v-btn>
|
||||
<v-btn text color="white" @click="() => (useStateStore().showLogModal = false)">
|
||||
<v-icon left class="menu-icon"> mdi-close </v-icon>
|
||||
<span class="menu-label">Close</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="pr-6 pl-6">
|
||||
<v-btn-toggle v-model="selectedLogLevels" dark multiple class="fill mb-4 overflow-x-auto">
|
||||
<v-btn v-for="level in [0, 1, 2, 3]" :key="level" color="secondary" class="fill">
|
||||
{{ getLogLevelFromIndex(level) }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-card-text v-if="logs.length === 0" style="font-size: 18px; font-weight: 600">
|
||||
There are no logs to show
|
||||
</v-card-text>
|
||||
<v-virtual-scroll v-else :items="logs" item-height="50" height="600">
|
||||
<template #default="{ item }">
|
||||
<div :class="[getLogColor(item.level) + '--text', 'log-item']">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="white" text @click="() => (useStateStore().showLogModal = false)"> Close </v-btn>
|
||||
</v-card-actions>
|
||||
<div class="dialog-data">
|
||||
<!-- Log view options -->
|
||||
<v-row class="pt-4 pt-md-0 no-gutters">
|
||||
<v-col cols="12" md="5" class="align-self-center">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
dark
|
||||
dense
|
||||
clearable
|
||||
hide-details="auto"
|
||||
prepend-icon="mdi-magnify"
|
||||
color="accent"
|
||||
label="Search"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2" style="display: flex; align-items: center">
|
||||
<input v-model="timeInput" type="time" step="1" class="white--text pl-0 pl-md-8" />
|
||||
<v-btn icon class="ml-3" @click="timeInput = undefined">
|
||||
<v-icon>mdi-close-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5" class="pr-3">
|
||||
<v-row class="no-gutters">
|
||||
<v-col v-for="level in [0, 1, 2, 3]" :key="level">
|
||||
<v-row dense align="center">
|
||||
<v-col cols="6" md="8" style="text-align: right">
|
||||
{{ getLogLevelFromIndex(level) }}
|
||||
</v-col>
|
||||
<v-col cols="6" md="4">
|
||||
<v-switch v-model="selectedLogLevels[level]" dark color="#ffd843" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Log entry list display -->
|
||||
<div class="log-display">
|
||||
<v-card-text v-if="!logs.length" style="font-size: 18px; font-weight: 150; height: 100%; text-align: center">
|
||||
No available logs
|
||||
</v-card-text>
|
||||
<virtual-list
|
||||
v-else
|
||||
ref="logList"
|
||||
style="height: 100%; overflow-y: auto"
|
||||
data-key="index"
|
||||
:data-sources="logs"
|
||||
:data-component="LogEntry"
|
||||
:estimate-size="35"
|
||||
:keeps="logKeeps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn-toggle.fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
<style scoped lang="scss">
|
||||
.dialog-container {
|
||||
height: 90vh;
|
||||
min-height: 300px !important;
|
||||
}
|
||||
|
||||
.v-btn-toggle.fill > .v-btn {
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
.dialog-data {
|
||||
/* Dialog size - dialog padding - header - divider */
|
||||
height: calc(max(90vh, 300px) - 48px - 48px - 1px);
|
||||
}
|
||||
@media only screen and (max-width: 512px) {
|
||||
.heading-container {
|
||||
flex-direction: column;
|
||||
padding-bottom: 14px;
|
||||
|
||||
.log-display {
|
||||
/* Dialog data size - options */
|
||||
height: calc(100% - 66px);
|
||||
padding: 10px;
|
||||
background-color: #232c37 !important;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 960px) {
|
||||
.log-display {
|
||||
/* Dialog data size - options */
|
||||
height: calc(100% - 118px);
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 312px) {
|
||||
.open-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.open-label {
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -132,7 +132,7 @@ const downloadCalibBoard = () => {
|
||||
charucoImage.src = CharucoImage;
|
||||
doc.addImage(charucoImage, "PNG", 0.25, 1.5, 8, 8);
|
||||
|
||||
doc.text(`8 x 8 | 1in & 0.75in`, paperWidth - 1, 1.0, {
|
||||
doc.text("8 x 8 | 1in & 0.75in", paperWidth - 1, 1.0, {
|
||||
maxWidth: (paperWidth - 2.0) / 2,
|
||||
align: "right"
|
||||
});
|
||||
@@ -274,8 +274,8 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
|
||||
:disabled="isCalibrating"
|
||||
/>
|
||||
<pv-select
|
||||
v-model="tagFamily"
|
||||
v-show="boardType == CalibrationBoardTypes.Charuco"
|
||||
v-model="tagFamily"
|
||||
label="Tag Family"
|
||||
tooltip="Dictionary of aruco markers on the charuco board"
|
||||
:select-cols="7"
|
||||
@@ -291,8 +291,8 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
|
||||
:label-cols="5"
|
||||
/>
|
||||
<pv-number-input
|
||||
v-model="markerSizeIn"
|
||||
v-show="boardType == CalibrationBoardTypes.Charuco"
|
||||
v-model="markerSizeIn"
|
||||
label="Marker Size (in)"
|
||||
tooltip="Size of the tag markers in inches must be smaller than pattern spacing"
|
||||
:disabled="isCalibrating"
|
||||
@@ -316,8 +316,8 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
|
||||
:label-cols="5"
|
||||
/>
|
||||
<pv-switch
|
||||
v-model="useOldPattern"
|
||||
v-show="boardType == CalibrationBoardTypes.Charuco"
|
||||
v-model="useOldPattern"
|
||||
label="Old OpenCV Pattern"
|
||||
:disabled="isCalibrating"
|
||||
tooltip="If enabled, Photon will use the old OpenCV pattern for calibration."
|
||||
|
||||
@@ -303,7 +303,7 @@ const handleSettingsImport = () => {
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn color="secondary" @click="openExportLogsPrompt">
|
||||
<v-icon left class="open-icon"> mdi-download </v-icon>
|
||||
<span class="open-label">Download Current Log</span>
|
||||
<span class="open-label">Download logs</span>
|
||||
|
||||
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
|
||||
<a
|
||||
@@ -318,7 +318,7 @@ const handleSettingsImport = () => {
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn color="secondary" @click="useStateStore().showLogModal = true">
|
||||
<v-icon left class="open-icon"> mdi-eye </v-icon>
|
||||
<span class="open-label">Show log viewer</span>
|
||||
<span class="open-label">View program logs</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
Reference in New Issue
Block a user