Files
dougal-software/lib/www/client/source/src/views/SequenceList.vue
D. Berge c8b2047483 Refactor client-side access checks.
Go from a Vuex based to a mixin based approach.
2025-07-12 11:31:38 +02:00

895 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container fluid>
<v-card>
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>Sequences</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter"
single-line
clearable
hint="Filter by sequence, line, date or remarks"
></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-menu
v-model="contextMenuShow"
:close-on-content-click="false"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<v-list dense v-if="contextMenuItem">
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess()">
<v-list-item-title>Reshoot</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess()">
<v-list-item-title>Reshoot with overlap</v-list-item-title>
</v-list-item>
<v-list-item
:href="`/projects/${$route.params.project}/graphs/sequence/${contextMenuItem.sequence}`"
@click="contextMenuShow=false"
>
<v-list-item-title>View graphics</v-list-item-title>
</v-list-item>
<v-list-group>
<template v-slot:activator>
<v-list-item-title>Download report</v-list-item-title>
</template>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fvnd.seis%2Bjson&download`"
title="Download as a Multiseis-compatible Seis+JSON file."
@click="contextMenuShow=false"
>
<v-list-item-title>Seis+JSON</v-list-item-title>
</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fgeo%2Bjson&download`"
title="Download as a QGIS-compatible GeoJSON file"
@click="contextMenuShow=false"
>
<v-list-item-title>GeoJSON</v-list-item-title>
</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fjson&download`"
title="Download as a generic JSON file"
@click="contextMenuShow=false"
>
<v-list-item-title>JSON</v-list-item-title>
</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=text%2Fhtml&download`"
title="Download as an HTML formatted file"
@click="contextMenuShow=false"
>
<v-list-item-title>HTML</v-list-item-title>
</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fpdf&download`"
title="Download as a Portable Document File"
@click="contextMenuShow=false"
>
<v-list-item-title>PDF</v-list-item-title>
</v-list-item>
</v-list-group>
<!-- ASAQC transfer queue actions -->
<!-- Item is not in queue -->
<v-list-item
v-if="writeaccess() && !contextMenuItemInTransferQueue"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
<v-list-item-title>Send to ASAQC</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon small>mdi-tray-plus</v-icon>
</v-list-item-icon>
</v-list-item>
<!-- Item queued, not yet sent -->
<v-list-item two-line
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'queued'"
@click="removeFromTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
<v-list-item-title class="red--text">Cancel sending to ASAQC</v-list-item-title>
<v-list-item-subtitle class="info--text">
Queued since: {{contextMenuItemInTransferQueue.created_on}}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-icon>
<v-icon small color="red">mdi-tray-remove</v-icon>
</v-list-item-icon>
</v-list-item>
<!-- Item already sent -->
<v-list-item two-line
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'sent'"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
<v-list-item-title>Resend to ASAQC</v-list-item-title>
<v-list-item-subtitle class="success--text">
Last sent on: {{ contextMenuItemInTransferQueue.created_on }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-icon>
<v-icon small>mdi-tray-plus</v-icon>
</v-list-item-icon>
</v-list-item>
<!-- Item sending was cancelled -->
<v-list-item two-line
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'cancelled'"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
<v-list-item-title>Send to ASAQC</v-list-item-title>
<v-list-item-subtitle class="info--text">
Last send cancelled on: {{contextMenuItemInTransferQueue.updated_on}}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-icon>
<v-icon small>mdi-tray-plus</v-icon>
</v-list-item-icon>
</v-list-item>
</v-list>
</v-menu>
<v-data-table
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="sequenceCount"
item-key="sequence"
:item-class="(item) => activeItem == item ? 'blue accent-1 elevation-3' : ''"
:search="filter"
x-custom-filter="customFilter"
:loading="sequencesLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
show-expand
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length" class="pa-0">
<v-container fluid class="pa-0">
<v-row no-gutters class="d-flex flex-column flex-sm-row">
<v-col cols="6" class="d-flex flex-column">
<v-card outlined class="flex-grow-1">
<v-card-title>
Acquisition remarks
<template v-if="writeaccess()">
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
<v-btn
class="ml-3"
icon
small
title="Cancel edit"
:disabled="sequencesLoading"
@click="edit.value = item.remarks; edit = null"
>
<v-icon small>mdi-close</v-icon>
</v-btn>
<v-btn v-if="edit.value != item.remarks"
icon
small
title="Save edits"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
</v-btn>
</template>
<v-btn v-else-if="edit === null"
class="ml-3"
icon
small
title="Edit"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
</v-card-title>
<v-card-subtitle>
</v-card-subtitle>
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
<v-textarea
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
</v-card-text>
<v-card-text v-else v-html="$options.filters.markdown(item.remarks)">
</v-card-text>
</v-card>
<v-card outlined class="flex-grow-1" v-if="item.remarks_final !== null">
<v-card-title>
Processing remarks
<template v-if="writeaccess()">
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
<v-btn
class="ml-3"
icon
small
title="Cancel edit"
:disabled="sequencesLoading"
@click="edit.value = item.remarks_final; edit = null"
>
<v-icon small>mdi-close</v-icon>
</v-btn>
<v-btn v-if="edit.value != item.remarks_final"
icon
small
title="Save edits"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
</v-btn>
</template>
<v-btn v-else-if="edit === null"
class="ml-3"
icon
small
title="Edit"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks_final')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
</v-card-title>
<v-card-subtitle>
</v-card-subtitle>
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
<v-textarea
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
</v-card-text>
<v-card-text v-html="$options.filters.markdown(item.remarks_final)">
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" class="d-flex">
<v-card outlined class="flex-grow-1">
<v-card-title>
Source files
</v-card-title>
<v-card-subtitle>
</v-card-subtitle>
<v-card-text>
<v-list>
<v-list-group value="true" v-if="item.raw_files">
<template v-slot:activator>
<v-list-item-title>
Raw files
<span class="grey--text text--lighten-1">
{{item.raw_files.length}}
</span>
</v-list-item-title>
</template>
<v-list-item v-for="(path, index) in item.raw_files"
key="index"
link
title="Download file"
:href="`/api/files${path}`"
>
{{ basename(path) }}
<v-list-item-action>
<v-icon right small>mdi-cloud-download</v-icon>
</v-list-item-action>
</v-list-item>
</v-list-group>
<v-list-group value="true" v-if="item.final_files">
<template v-slot:activator>
<v-list-item-title>
Final files
<span class="grey--text text--lighten-1">
{{item.final_files.length}}
</span>
</v-list-item-title>
</template>
<v-list-item v-for="(path, index) in item.final_files"
key="index"
title="Download file"
:href="`/api/files${path}`"
>
{{ basename(path) }}
<v-list-item-action>
<v-icon right small>mdi-cloud-download</v-icon>
</v-list-item-action>
</v-list-item>
</v-list-group>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</td>
</template>
<template v-slot:item.sequence="{value}">
<div style="white-space:nowrap;">
{{value}}
<a
:href="`/projects/${$route.params.project}/log/sequence/${value}`"
title="View the event log for this sequence"
>
<v-icon
small
right
color="blue"
>mdi-format-list-bulleted-type</v-icon>
</a>
<a
:href="`/projects/${$route.params.project}/sequences/${value}`"
title="View the shotlog for this sequence"
>
<v-icon
small
right
color="teal"
>mdi-format-list-numbered</v-icon>
</a>
</div>
</template>
<template v-slot:item.line="{value}">
<b>{{value}}</b>
</template>
<template v-slot:item.fsp_final="{value}">
<b v-if="value">{{value}}</b>
</template>
<template v-slot:item.lsp_final="{value}">
<b v-if="value">{{value}}</b>
</template>
<template v-slot:item.status="{value, item}">
<span :class="{'success--text': value=='final', 'warning--text': value=='raw', 'error--text': value=='ntbp'}">
{{ value == "final" ? "Processed" : value == "raw" ? item.raw_files ? "Acquired" : "In acquisition" : value == "ntbp" ? "NTBP" : `Unknown (${status})` }}
<v-icon small :title="`Sent to ASAQC on ${queuedItem(item.sequence).updated_on}`"
color="success"
v-if="queuedItem(item.sequence).status == 'sent'"
>mdi-upload</v-icon>
<v-icon small
title="Queued for sending to ASAQC"
v-else-if="queuedItem(item.sequence).status == 'queued'"
>mdi-upload-outline</v-icon>
<v-icon small
:title="`ASAQC transfer cancelled at ${queuedItem(item.sequence).updated_on}`"
v-else-if="queuedItem(item.sequence).status == 'cancelled'"
>mdi-upload-off-outline</v-icon>
<v-icon small
color="warning"
:title="`ASAQC transfer failed at ${queuedItem(item.sequence).updated_on}`"
v-else-if="queuedItem(item.sequence).status == 'failed'"
>mdi-upload-off</v-icon>
</span>
</template>
<template v-slot:item.duration="{item: {duration: value}}">
{{
value
?
"" +
(value.days
? value.days + "d "
: "") +
String(value.hours || 0).padStart(2, "0") +
":" + String(value.minutes || 0).padStart(2, "0") +
":" + String(value.seconds || 0).padStart(2, "0")
: "N/A"
}}
</template>
<template v-slot:item.duration_final="{item: {duration_final: value}}">
<b>{{
value
?
"" +
(value.days
? value.days + "d "
: "") +
String(value.hours || 0).padStart(2, "0") +
":" + String(value.minutes || 0).padStart(2, "0") +
":" + String(value.seconds || 0).padStart(2, "0")
: "N/A"
}}</b>
</template>
<template v-slot:item.ts0="{value}">
<span v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</span>
</template>
<template v-slot:item.ts1="{value}">
<span v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</span>
</template>
<template v-slot:item.ts0_final="{value}">
<b v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</b>
</template>
<template v-slot:item.ts1_final="{value}">
<b v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</b>
</template>
<template v-slot:item.missing_shots="{value}">
<span :class="value && 'warning--text'">{{ value }}</span>
</template>
<template v-slot:item.length="props">
<span>{{ Math.round(props.value) }} m</span>
</template>
<template v-slot:item.azimuth="{value}">
<span>{{ value.toFixed? value.toFixed(2) : value }} °</span>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</template>
<style scoped>
td span {
white-space: nowrap;
}
.status-raw {
color: orange;
}
.status-final {
color: green;
}
.status-ntbp {
color: red;
}
tr :nth-child(5), tr :nth-child(8), tr :nth-child(11), tr :nth-child(14) {
opacity: 0.7;
}
</style>
<script>
import { mapActions, mapGetters } from 'vuex';
import { basename } from 'path';
import throttle from '@/lib/throttle';
import AccessMixin from '@/mixins/access';
export default {
name: "SequenceList",
mixins: [
AccessMixin
],
data () {
return {
headers: [
{
value: 'data-table-expand'
},
{
value: "sequence",
text: "Sequence"
},
{
value: "status",
text: "Status"
},
{
value: "line",
text: "Line"
},
{
value: "fsp",
text: "FSP",
align: "end"
},
{
value: "fsp_final",
text: "FPSP",
align: "end"
},
{
value: "lsp_final",
text: "LPSP",
align: "end"
},
{
value: "lsp",
text: "LSP",
align: "end"
},
{
value: "duration_final",
text: "Prime duration",
align: "end"
},
{
value: "duration",
text: "Total duration",
align: "end"
},
{
value: "ts0",
text: "Start time",
align: "end"
},
{
value: "ts0_final",
text: "FPSP time",
align: "end"
},
{
value: "ts1_final",
text: "LPSP time",
align: "end"
},
{
value: "ts1",
text: "End time",
align: "end"
},
{
value: "num_points",
text: "Shots acquired",
align: "end"
},
{
value: "missing_shots",
text: "Shots missed",
align: "end"
},
{
value: "length",
text: "Length",
align: "end"
},
{
value: "azimuth",
text: "Azimuth",
align: "end"
}
],
expanded: [],
items: [],
filter: "",
options: {},
sequenceCount: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
itemsPerPage: 25,
// Planner related stuff
preplots: null,
plannerConfig: null,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null,
// ASAQC transfer queue
queuedItems: []
}
},
computed: {
contextMenuItemInTransferQueue () {
return this.queuedItems.find(i => i.payload.sequence == this.contextMenuItem.sequence);
},
...mapGetters(['user', 'sequencesLoading', 'sequences'])
},
watch: {
options: {
handler () {
this.fetchSequences();
},
deep: true
},
async sequences () {
await this.fetchSequences();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.sequence == oldVal.sequence);
if (item && item[oldVal.key] != oldVal.value) {
if (await this.saveItem(oldVal)) {
item[oldVal.key] = oldVal.value;
} else {
this.edit = oldVal;
}
}
}
},
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchSequences();
}
},
itemsPerPage (newVal, oldVal) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
},
user (newVal, oldVal) {
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
}
},
methods: {
contextMenu (e, {item}) {
e.preventDefault();
this.contextMenuShow = false;
this.contextMenuX = e.clientX;
this.contextMenuY = e.clientY;
this.contextMenuItem = item;
this.$nextTick( () => this.contextMenuShow = true );
},
async getReshootEndpoints (item, overlap) {
const urlPreplot = `/project/${this.$route.params.project}/line`;
const urlPlannerConfig = `/project/${this.$route.params.project}/configuration/planner`;
if (!this.preplots) {
this.preplots = await this.api([urlPreplot]);
}
if (!this.plannerConfig) {
this.plannerConfig = await this.api([urlPlannerConfig]) || {
overlapBefore: 0,
overlapAfter: 0
};
}
const preplot = this.preplots.find(l => l.line == item.line);
const incr = item.fsp <= item.lsp;
const lim0 = incr
? Math.max : Math.min;
const lim1 = incr
? Math.min : Math.max;
const dir = incr ? 1 : -1;
const sp0 = overlap
? lim0((item.fsp_final || item.fsp) - this.plannerConfig.overlapBefore * dir, preplot.fsp)
: lim0(item.fsp_final || item.fsp, preplot.fsp);
const sp1 = overlap
? lim1((item.lsp_final || item.lsp) + this.plannerConfig.overlapAfter * dir, preplot.lsp)
: lim1(item.lsp_final || item.lsp, preplot.lsp);
return {sp0, sp1}
},
async addToPlan (overlap=false) {
const { sp0, sp1 } = await this.getReshootEndpoints(this.contextMenuItem, overlap);
const payload = {
line: this.contextMenuItem.line,
fsp: sp0,
lsp: sp1,
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`,
meta: {
is_reshoot: true,
original_sequence: this.contextMenuItem.sequence
}
}
console.log("Plan", payload);
const url = `/project/${this.$route.params.project}/plan`;
const init = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
}
await this.api([url, init]);
},
async addToTransferQueue () {
const payload = [
{
project: this.$route.params.project,
sequence: this.contextMenuItem.sequence
}
];
const url = `/queue/outgoing/asaqc`;
const init = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
}
const callback = (err, res) => {
if (res && res.ok) {
const text = `Sequence ${this.contextMenuItem.sequence} queued for sending to ASAQC`;
this.showSnack([text, "info"]);
}
};
await this.api([url, init, callback]);
},
async removeFromTransferQueue () {
const item_id = this.contextMenuItemInTransferQueue.item_id;
if (item_id) {
const url = `/queue/outgoing/asaqc/${item_id}`;
const init = {
method: "DELETE",
headers: { "Content-Type": "application/json" }
}
const callback = (err, res) => {
if (res && res.ok) {
const text = `Cancelled sending of sequence ${this.contextMenuItem.sequence} to ASAQC`;
this.showSnack([text, "primary"]);
}
};
this.api([url, init, callback]);
} else {
this.showSnack(["No item ID in transfer queue", "error"]);
}
},
queuedItem (sequence) {
return this.queuedItems.find(i => i.payload.sequence == sequence) || {};
},
editItem (item, key) {
this.edit = {
sequence: item.sequence,
key,
value: item[key]
}
},
async saveItem (edit) {
if (!edit) return;
try {
const url = `/project/${this.$route.params.project}/sequence/${edit.sequence}`;
const init = {
method: "PATCH",
body: {
[edit.key]: edit.value
}
};
let res;
await this.api([url, init, (e, r) => res = r]);
return res && res.ok;
} catch (err) {
return false;
}
},
setActiveItem (item) {
this.activeItem = this.activeItem == item
? null
: item;
},
async getNumLines () {
const projectInfo = await this.api([`/project/${this.$route.params.project}`]);
this.num_rows = projectInfo.sequences;
},
async fetchSequences (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getSequences([this.$route.params.project, options]);
this.items = res.sequences;
this.sequenceCount = res.count;
},
async getQueuedItems () {
const callback = async () => {
const url = `/queue/outgoing/asaqc/project/${this.$route.params.project}`;
this.queuedItems = Object.freeze(await this.api([url]) || []);
}
throttle(callback, this.getQueuedItems, 100, 500);
},
basename (path, ext) {
return basename(path, ext);
},
customFilter (value, search, item) {
if (!search) return true;
const number = Number(search);
if (!isNaN(number)) {
if (item.sequence == number) return true;
if (search.length > 3) {
const searchShots = [ "line", "fsp", "lsp", "fsp_final", "lsp_final" ].some( k =>
item[k] == number
);
if (searchShots) return true;
}
}
if (search.length > 2) {
const searchDates = [ "ts0", "ts1" ].some(k => {
const i = item[k].indexOf(search);
return i >= 0 && i < 10;
});
if (searchDates) return true;
}
if ((item.remarks||"").indexOf(search) != -1) return true;
if ((item.remarks_final||"").indexOf(search) != -1) return true;
if (item.status.indexOf(search.toLowerCase()) == 0) return true;
return false;
},
...mapActions(["api", "showSnack", "getSequences"])
},
mounted () {
this.fetchSequences();
this.getNumLines();
this.getQueuedItems();
}
}
</script>