[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:
Devon Doyle
2024-08-31 18:22:07 -04:00
committed by GitHub
parent 169595e56e
commit c38b50911d
8 changed files with 187 additions and 79 deletions

View File

@@ -17,6 +17,7 @@
"three": "^0.160.0",
"vue": "^2.7.14",
"vue-router": "^3.6.5",
"vue-virtual-scroll-list": "^2.3.5",
"vuetify": "^2.7.1"
},
"devDependencies": {
@@ -5341,6 +5342,11 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.6.5.tgz",
"integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ=="
},
"node_modules/vue-virtual-scroll-list": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.5.tgz",
"integrity": "sha512-YFK6u5yltqtAOfTBcij/KGAS2SoZvzbNIAf9qTULauPObEp53xj22tDuohrrM2vNkgoD5kejXICIUBt2Q4ZDqQ=="
},
"node_modules/vuetify": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.7.1.tgz",

View File

@@ -24,6 +24,7 @@
"three": "^0.160.0",
"vue": "^2.7.14",
"vue-router": "^3.6.5",
"vue-virtual-scroll-list": "^2.3.5",
"vuetify": "^2.7.1"
},
"devDependencies": {

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ export enum LogLevel {
export interface LogMessage {
level: LogLevel;
message: string;
timestamp: Date;
}
export interface Resolution {