feat(history): add support for Moonraker sensor history_fields (#1884)

This commit is contained in:
Stefan Dej
2024-05-25 20:51:46 +02:00
committed by GitHub
parent 5cfef22fa9
commit 8bccd7e73e
15 changed files with 199 additions and 470 deletions

View File

@@ -1,170 +0,0 @@
<template>
<v-dialog :value="show" :max-width="600" persistent @keydown.esc="close">
<panel
:title="$t('History.JobDetails')"
:icon="mdiUpdate"
card-class="history-detail-dialog"
:margin-bottom="false">
<template #buttons>
<v-btn icon tile @click="close">
<v-icon>{{ mdiCloseThick }}</v-icon>
</v-btn>
</template>
<v-card-text class="px-0">
<overlay-scrollbars style="height: 350px" class="px-6">
<history-details-dialog-entry v-for="field in fields" :key="field.key" :job="job" :field="field" />
</overlay-scrollbars>
</v-card-text>
</panel>
</v-dialog>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { ServerHistoryStateJob } from '@/store/server/history/types'
import { mdiCloseThick, mdiUpdate } from '@mdi/js'
import { formatFilesize, formatPrintTime } from '@/plugins/helpers'
import { TranslateResult } from 'vue-i18n'
export interface HistoryDetailsField {
key: string
label: string | TranslateResult
metadata?: boolean
unit?: string
format?: (value: any) => string | TranslateResult
}
@Component
export default class HistoryDetailsDialog extends Mixins(BaseMixin) {
mdiCloseThick = mdiCloseThick
mdiUpdate = mdiUpdate
@Prop({ type: Boolean, required: true }) show!: boolean
@Prop({ type: Object, required: true }) job!: ServerHistoryStateJob
get fields(): HistoryDetailsField[] {
return [
{
key: 'filename',
label: this.$t('History.Filename'),
},
{
key: 'size',
label: this.$t('History.Filesize'),
metadata: true,
format: (value: number) => formatFilesize(value),
},
{
key: 'modified',
label: this.$t('History.LastModified'),
metadata: true,
format: (value: number) => this.formatDateTime(value * 1000),
},
{
key: 'status',
label: this.$t('History.Status'),
format: (value: string) =>
this.$te(`History.StatusValues.${value}`, 'en') ? this.$t(`History.StatusValues.${value}`) : value,
},
{
key: 'end_time',
label: this.$t('History.EndTime'),
format: (value: number) => this.formatDateTime(value * 1000),
},
{
key: 'estimated_time',
label: this.$t('History.EstimatedTime'),
metadata: true,
format: (value: number) => formatPrintTime(value),
},
{
key: 'print_duration',
label: this.$t('History.PrintDuration'),
metadata: true,
format: (value: number) => formatPrintTime(value),
},
{
key: 'total_duration',
label: this.$t('History.TotalDuration'),
metadata: true,
format: (value: number) => formatPrintTime(value),
},
{
key: 'filament_weight_total',
label: this.$t('History.EstimatedFilamentWeight'),
metadata: true,
unit: 'g',
format: (value: number) => value?.toFixed(2),
},
{
key: 'filament_total',
label: this.$t('History.EstimatedFilament'),
metadata: true,
unit: 'mm',
format: (value: number) => value?.toFixed(0),
},
{
key: 'filament_used',
label: this.$t('History.FilamentUsed'),
metadata: true,
unit: 'mm',
format: (value: number) => value?.toFixed(0),
},
{
key: 'first_layer_extr_temp',
label: this.$t('History.FirstLayerExtTemp'),
metadata: true,
unit: '°C',
},
{
key: 'first_layer_bed_temp',
label: this.$t('History.FirstLayerBedTemp'),
metadata: true,
unit: '°C',
},
{
key: 'first_layer_height',
label: this.$t('History.FirstLayerHeight'),
metadata: true,
unit: 'mm',
},
{
key: 'layer_height',
label: this.$t('History.LayerHeight'),
metadata: true,
unit: 'mm',
},
{
key: 'object_height',
label: this.$t('History.ObjectHeight'),
metadata: true,
unit: 'mm',
},
{
key: 'slicer',
label: this.$t('History.Slicer'),
metadata: true,
},
{
key: 'slicer_version',
label: this.$t('History.SlicerVersion'),
metadata: true,
},
]
}
close() {
this.$emit('close')
}
}
</script>
<style scoped>
::v-deep .history-details-dialog-entry + .history-details-dialog-entry {
margin-top: 1em;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.theme--light ::v-deep .history-details-dialog-entry {
border-top-color: rgba(0, 0, 0, 0.12);
}
</style>

View File

@@ -106,6 +106,22 @@ export default class HistoryListPanelDetailsDialog extends Mixins(BaseMixin) {
},
]
if ('auxiliary_data' in this.job) {
this.job.auxiliary_data?.forEach((data) => {
let value = data.value.toString()
if (!Array.isArray(data.value)) {
value = `${Math.round(data.value * 1000) / 1000} ${data.units}`
}
if (value === '') value = '--'
entries.push({
name: data.description,
value,
exists: true,
})
})
}
return entries.filter((entry) => entry.exists)
}

View File

@@ -300,6 +300,11 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
//@ts-ignore
let value = col.value in item ? item[col.value] : null
if (value === null) value = col.value in item.metadata ? item.metadata[col.value] : null
if (col.value.startsWith('history_field_')) {
const fieldName = col.value.replace('history_field_', '')
const field = item.auxiliary_data?.find((field: any) => field.name === fieldName)
if (field && !Array.isArray(field.value)) return `${Math.round(field.value * 1000) / 1000} ${field.units}`
}
if (value === null) return '--'
if (col.value === 'slicer') value += '<br />' + item.metadata.slicer_version

View File

@@ -1,226 +0,0 @@
<template>
<tr
v-longpress:600="(e) => showContextMenu(e)"
:class="trClasses"
@contextmenu="showContextMenu($event)"
@click="clickRow">
<td class="pr-0">
<v-simple-checkbox v-ripple :value="isSelected" class="pa-0 mr-0" @click.stop="select(!isSelected)" />
</td>
<td class="px-0 text-center" style="width: 32px">
<v-icon v-if="!job.exists" class="text--disabled">{{ mdiFileCancel }}</v-icon>
<v-tooltip v-else-if="smallThumbnail" top :disabled="!bigThumbnail">
<template #activator="{ on, attrs }">
<vue-load-image>
<img
slot="image"
:src="smallThumbnail"
width="32"
height="32"
:alt="job.filename"
v-bind="attrs"
v-on="on" />
<div slot="preloader">
<v-progress-circular indeterminate color="primary" />
</div>
<div slot="error">
<v-icon>{{ mdiFile }}</v-icon>
</div>
</vue-load-image>
</template>
<span><img :src="bigThumbnail" width="250" :alt="job.filename" /></span>
</v-tooltip>
<v-icon v-else>{{ mdiFile }}</v-icon>
</td>
<td>{{ job.filename }}</td>
<td class="text-right text-no-wrap">
<template v-if="job.note ?? false">
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-icon small class="mr-2" v-bind="attrs" v-on="on">
{{ mdiNotebook }}
</v-icon>
</template>
<span v-html="job.note.replaceAll('\n', '<br />')" />
</v-tooltip>
</template>
<v-tooltip top>
<template #activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
<v-icon small :color="statusColor" :disabled="!job.exists">{{ statusIcon }}</v-icon>
</span>
</template>
<span>{{ statusText }}</span>
</v-tooltip>
</td>
<history-list-row-cell v-for="col in tableFields" :key="col.value" :job="job" :col="col" />
<td v-if="existsSlicerCol" class=" ">
{{ job.metadata?.slicer ?? '--' }}
<small v-if="job.metadata?.slicer_version ?? false">
<br />
{{ job.metadata?.slicer_version }}
</small>
</td>
<v-menu v-model="contextMenuShown" :position-x="contextMenuX" :position-y="contextMenuY" absolute offset-y>
<v-list>
<v-list-item @click="clickRow">
<v-icon class="mr-1">{{ mdiTextBoxSearch }}</v-icon>
{{ $t('History.Details') }}
</v-list-item>
<v-list-item v-if="job.note ?? false" @click="showNoteDialog = true">
<v-icon class="mr-1">{{ mdiNotebookEdit }}</v-icon>
{{ $t('History.EditNote') }}
</v-list-item>
<v-list-item v-else @click="showNoteDialog = true">
<v-icon class="mr-1">{{ mdiNotebookPlus }}</v-icon>
{{ $t('History.AddNote') }}
</v-list-item>
<v-list-item v-if="job.exists" :disabled="printerIsPrinting || !klipperReadyForGui" @click="startPrint">
<v-icon class="mr-1">{{ mdiPrinter }}</v-icon>
{{ $t('History.Reprint') }}
</v-list-item>
<v-list-item class="red--text" @click="showDeleteDialog = true">
<v-icon class="mr-1" color="error">{{ mdiDelete }}</v-icon>
{{ $t('History.Delete') }}
</v-list-item>
</v-list>
</v-menu>
<history-details-dialog :show="showDetailsDialog" :job="job" @close="showDetailsDialog = false" />
<history-note-dialog :show="showNoteDialog" :job="job" @close="showNoteDialog = false" />
<history-delete-job-dialog :show="showDeleteDialog" :job="job" @close="showDeleteDialog = false" />
</tr>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { ServerHistoryStateJob } from '@/store/server/history/types'
import { HistoryListPanelCol } from '@/components/panels/HistoryListPanel.vue'
import {
mdiDelete,
mdiFile,
mdiFileCancel,
mdiNotebook,
mdiNotebookEdit,
mdiNotebookPlus,
mdiPrinter,
mdiTextBoxSearch,
} from '@mdi/js'
import { thumbnailBigMin, thumbnailSmallMax, thumbnailSmallMin } from '@/store/variables'
import HistoryListRowCell from '@/components/panels/History/HistoryListRowCell.vue'
import HistoryDetailsDialog from '@/components/dialogs/HistoryDetailsDialog.vue'
@Component({
components: { HistoryDetailsDialog, HistoryListRowCell },
})
export default class HistoryListRow extends Mixins(BaseMixin) {
mdiDelete = mdiDelete
mdiFile = mdiFile
mdiFileCancel = mdiFileCancel
mdiNotebook = mdiNotebook
mdiNotebookEdit = mdiNotebookEdit
mdiNotebookPlus = mdiNotebookPlus
mdiPrinter = mdiPrinter
mdiTextBoxSearch = mdiTextBoxSearch
@Prop({ type: Object, required: true }) job!: ServerHistoryStateJob
@Prop({ type: Array, required: true }) tableFields!: HistoryListPanelCol[]
@Prop({ type: Boolean, required: true }) isSelected!: boolean
@Prop({ type: Boolean, required: true }) existsSlicerCol!: boolean
contextMenuShown = false
contextMenuX = 0
contextMenuY = 0
showDetailsDialog = false
showDeleteDialog = false
showNoteDialog = false
get trClasses() {
return {
'file-list-cursor': true,
'user-select-none': true,
'text--disabled': !this.job.exists,
}
}
get thumbnails() {
return this.job.metadata?.thumbnails ?? []
}
get smallThumbnail() {
const thumbnail = this.thumbnails.find(
(thumb: any) =>
thumb.width >= thumbnailSmallMin &&
thumb.width <= thumbnailSmallMax &&
thumb.height >= thumbnailSmallMin &&
thumb.height <= thumbnailSmallMax
)
if (!thumbnail) return false
let relative_url = ''
if (this.job.filename.lastIndexOf('/') !== -1) {
relative_url = this.job.filename.substring(0, this.job.filename.lastIndexOf('/') + 1)
}
return `${this.apiUrl}/server/files/gcodes/${encodeURI(relative_url + thumbnail.relative_path)}?timestamp=${this
.job.metadata?.modified}`
}
get bigThumbnail() {
const thumbnail = this.thumbnails.find((thumb: any) => thumb.width >= thumbnailBigMin)
if (!thumbnail) return false
let relative_url = ''
if (this.job.filename.lastIndexOf('/') !== -1) {
relative_url = this.job.filename.substring(0, this.job.filename.lastIndexOf('/') + 1)
}
return `${this.apiUrl}/server/files/gcodes/${encodeURI(relative_url + thumbnail.relative_path)}?timestamp=${this
.job.metadata?.modified}`
}
get statusColor() {
return this.$store.getters['server/history/getPrintStatusIconColor'](this.job.status)
}
get statusIcon() {
return this.$store.getters['server/history/getPrintStatusIcon'](this.job.status)
}
get statusText() {
// If the translation exists, use it
if (this.$te(`History.StatusValues.${this.job.status}`, 'en')) {
return this.$t(`History.StatusValues.${this.job.status}`)
}
// fallback uses the status name
return this.job.status.replace(/_/g, ' ')
}
showContextMenu(e: MouseEvent) {
if (!this.contextMenuShown) {
e?.preventDefault()
this.contextMenuX = e?.clientX || e?.pageX || window.screenX / 2
this.contextMenuY = e?.clientY || e?.pageY || window.screenY / 2
this.$nextTick(() => {
this.contextMenuShown = true
})
}
}
select(value: boolean) {
this.$emit('select', value)
}
clickRow() {
this.showDetailsDialog = true
}
startPrint() {
if (!this.job.exists) return
this.$socket.emit('printer.print.start', { filename: this.job.filename }, { action: 'switchToDashboard' })
}
}
</script>

View File

@@ -1,61 +0,0 @@
<template>
<td :class="cssClass">{{ output }}</td>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { ServerHistoryStateJob } from '@/store/server/history/types'
import { HistoryListHeadertype } from '@/components/panels/HistoryListPanel.vue'
import { formatFilesize, formatPrintTime } from '@/plugins/helpers'
@Component
export default class HistoryListRowCell extends Mixins(BaseMixin) {
@Prop({ type: Object, required: true }) job!: ServerHistoryStateJob
@Prop({ type: Object, required: true }) col!: HistoryListHeadertype
get cssClass() {
if (this.col.outputType === 'date') return ''
return 'text-no-wrap'
}
get value() {
if (this.col.value in this.job) return this.job[this.col.value]
const metadata = this.job.metadata ?? null
if (metadata && this.col.value in metadata) return metadata[this.col.value]
return null
}
get output() {
// return fallback if value is null or 0
if (this.value === null || this.value === 0) return '--'
// direct output of strings or other non-numeric values
if (typeof this.value !== 'number') return this.value
switch (this.col.outputType) {
case 'filesize':
return formatFilesize(this.value)
case 'date':
return this.formatDateTime(this.value * 1000)
case 'time':
return formatPrintTime(this.value)
case 'temp':
return this.value?.toFixed() + ' °C'
case 'length':
if (this.value > 1000) return (this.value / 1000).toFixed(2) + ' m'
return this.value?.toFixed(2) + ' mm'
default:
return this.value
}
}
}
</script>

View File

@@ -409,6 +409,16 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
},
]
this.moonrakerSensors.forEach((sensor) => {
headers.push({
text: sensor.desc,
value: sensor.name,
align: 'left',
configable: true,
visible: false,
})
})
headers.forEach((header) => {
if (header.visible && this.hideColums.includes(header.value)) {
header.visible = false
@@ -420,6 +430,32 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
return headers
}
get moonrakerSensors() {
const config = this.$store.state.server.config?.config ?? {}
const sensors = Object.keys(config).filter((key) => key.startsWith('sensor '))
const historyFields: { desc: string; unit: string; provider: string; name: string; parameter: string }[] = []
sensors.forEach((configName) => {
const sensor = config[configName] ?? {}
Object.keys(sensor)
.filter((key) => key.startsWith('history_field_'))
.forEach((key) => {
const historyField = sensor[key]
historyFields.push({
desc: historyField.desc,
unit: historyField.units,
provider: configName,
parameter: historyField.parameter,
name: key,
})
})
})
return historyFields
}
get tableFields() {
return this.filteredHeaders.filter(
(col: any) => !['filename', 'status'].includes(col.value) && col.value !== ''
@@ -544,6 +580,12 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
row.push('status')
this.tableFields.forEach((col) => {
if (col.value.startsWith('history_field_')) {
const sensorName = col.value.replace('history_field_', '')
row.push(sensorName)
return
}
row.push(col.value)
})
@@ -612,18 +654,9 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
row.push('job')
row.push(job.status)
this.tableFields
.filter((header) => header.value !== 'slicer')
.forEach((col) => {
row.push(this.outputValue(col, job, csvSeperator))
})
if (this.tableFields.find((header) => header.value === 'slicer')?.visible) {
let slicerString = 'slicer' in job.metadata && job.metadata.slicer ? job.metadata.slicer : '--'
if ('slicer_version' in job.metadata && job.metadata.slicer_version)
slicerString += ' ' + job.metadata.slicer_version
row.push(slicerString)
}
this.tableFields.forEach((col) => {
row.push(this.outputValue(col, job, csvSeperator))
})
content.push(row)
})
@@ -634,7 +667,7 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
const csvContent =
'data:text/csv;charset=utf-8,' +
content.map((entry) =>
entry.map((field) => (field.indexOf(csvSeperator) === -1 ? field : `"${field}"`)).join(csvSeperator)
entry.map((field) => (field?.indexOf(csvSeperator) === -1 ? field : `"${field}"`)).join(csvSeperator)
).join('\n')
const link = document.createElement('a')
@@ -651,6 +684,36 @@ export default class HistoryListPanel extends Mixins(BaseMixin) {
let value = col.value in job ? job[col.value] : null
if (value === null) value = col.value in job.metadata ? job.metadata[col.value] : null
if (col.value === 'slicer') {
let slicerString = 'slicer' in job.metadata && job.metadata.slicer ? job.metadata.slicer : '--'
if ('slicer_version' in job.metadata && job.metadata.slicer_version)
slicerString += ' ' + job.metadata.slicer_version
if (csvSeperator !== null && value.includes(csvSeperator)) return '"' + slicerString + '"'
return slicerString
}
if (col.value.startsWith('history_field_')) {
const sensorName = col.value.replace('history_field_', '')
const sensor = job.auxiliary_data?.find((sensor) => sensor.name === sensorName)
let value = sensor?.value?.toString()
// return value, when it is not an array
if (sensor && !Array.isArray(sensor.value)) {
value = sensor.value?.toLocaleString(this.browserLocale, { useGrouping: false }) ?? 0
}
// return empty string, when value is null
if (!value) return '--'
// escape fields with the csvSeperator in the content
if (csvSeperator !== null && value?.includes(csvSeperator)) return `"${value}"`
return value
}
switch (col.outputType) {
case 'date':
return this.formatDateTime(value * 1000)

View File

@@ -50,6 +50,15 @@ export interface ServerHistoryStateJob {
status: string
start_time: number
total_duration: number
auxiliary_data?: ServerHistoryStateJobAuxiliaryData[]
}
export interface ServerHistoryStateJobAuxiliaryData {
description: string
name: string
provider: string
units: string
value: number | number[]
}
export interface HistoryListRowJob extends ServerHistoryStateJob {

View File

@@ -12,6 +12,7 @@ import { timelapse } from '@/store/server/timelapse'
import { jobQueue } from '@/store/server/jobQueue'
import { announcements } from '@/store/server/announcements'
import { spoolman } from '@/store/server/spoolman'
import { sensor } from '@/store/server/sensor'
// create getDefaultState
export const getDefaultState = (): ServerState => {
@@ -62,5 +63,6 @@ export const server: Module<ServerState, any> = {
jobQueue,
announcements,
spoolman,
sensor,
},
}

View File

@@ -0,0 +1,26 @@
import Vue from 'vue'
import { ActionTree } from 'vuex'
import { ServerSensorState } from '@/store/server/sensor/types'
import { RootState } from '@/store/types'
export const actions: ActionTree<ServerSensorState, RootState> = {
reset({ commit }) {
commit('reset')
},
init() {
Vue.$socket.emit('server.sensors.list', {}, { action: 'server/sensor/getSensors' })
},
getSensors({ commit, dispatch }, payload) {
commit('setSensors', payload.sensors)
dispatch('socket/removeInitModule', 'server/sensor/init', { root: true })
},
updateSensors({ commit }, payload) {
Object.keys(payload).forEach((key) => {
commit('updateSensor', { key, value: payload[key] })
})
},
}

View File

@@ -0,0 +1,5 @@
import { GetterTree } from 'vuex'
import { ServerSensorState } from '@/store/server/sensor/types'
// eslint-disable-next-line
export const getters: GetterTree<ServerSensorState, any> = {}

View File

@@ -0,0 +1,23 @@
import { Module } from 'vuex'
import { ServerSensorState } from '@/store/server/sensor/types'
import { actions } from '@/store/server/sensor/actions'
import { mutations } from '@/store/server/sensor/mutations'
import { getters } from '@/store/server/sensor/getters'
export const getDefaultState = (): ServerSensorState => {
return {
sensors: {},
}
}
// initial state
const state = getDefaultState()
// eslint-disable-next-line
export const sensor: Module<ServerSensorState, any> = {
namespaced: true,
state,
getters,
actions,
mutations,
}

View File

@@ -0,0 +1,18 @@
import Vue from 'vue'
import { getDefaultState } from './index'
import { MutationTree } from 'vuex'
import { ServerSensorState } from '@/store/server/sensor/types'
export const mutations: MutationTree<ServerSensorState> = {
reset(state) {
Object.assign(state, getDefaultState())
},
setSensors(state, payload) {
Vue.set(state, 'sensors', payload)
},
updateSensor(state, payload) {
Vue.set(state.sensors, payload.key, payload.value)
},
}

View File

@@ -0,0 +1,14 @@
export interface ServerSensorState {
sensors: {
[key: string]: ServerSensorStateSensor
}
}
export interface ServerSensorStateSensor {
friendly_name: string
id: string
type: string
values: {
[key: string]: number
}
}

View File

@@ -135,6 +135,10 @@ export const actions: ActionTree<SocketState, RootState> = {
dispatch('server/spoolman/getActiveSpoolId', payload.params[0], { root: true })
break
case 'notify_sensor_update':
dispatch('server/sensor/updateSensors', payload.params[0], { root: true })
break
default:
window.console.debug(payload)
}

View File

@@ -35,6 +35,7 @@ export const initableServerComponents = [
'jobQueue',
'announcements',
'spoolman',
'sensor',
]
/*