Web: show job details column

This may be a nice moment to reconsider using Pinia as a data store, as
we now have two views (job table + job details) that should share a data
set.
This commit is contained in:
Sybren A. Stüvel 2022-04-12 15:28:18 +02:00
parent d650ff5dcf
commit 316ba6953b
4 changed files with 159 additions and 26 deletions

View File

@ -1,10 +1,10 @@
<template> <template>
<header>Flamenco</header> <header>Flamenco</header>
<div class="col-1"> <div class="col-1">
<jobs-table ref="jobsTable" :apiClient="apiClient" /> <jobs-table ref="jobsTable" :apiClient="apiClient" @activeJobChange="onActiveJobChanged" />
</div> </div>
<div class="col-2"> <div class="col-2">
<job-details :apiClient="apiClient" /> <job-details :apiClient="apiClient" :jobSummary="activeJobSummary" />
</div> </div>
<div class="col-3"> <div class="col-3">
<task-details :apiClient="apiClient" /> <task-details :apiClient="apiClient" />
@ -33,10 +33,19 @@ export default {
apiClient: new ApiClient(urls.api()), apiClient: new ApiClient(urls.api()),
websocketURL: urls.ws(), websocketURL: urls.ws(),
messages: [], messages: [],
activeJobSummary: {},
}; };
}, },
mounted() { }, mounted() { },
methods: { methods: {
// UI component event handlers:
onActiveJobChanged(jobSummary) {
console.log("Selected:", jobSummary);
this.activeJobSummary = jobSummary;
},
// SocketIO event handlers:
sendMessage(message) { sendMessage(message) {
this.$refs.jobsListener.sendBroadcastMessage("typer", message); this.$refs.jobsListener.sendBroadcastMessage("typer", message);
}, },

View File

@ -1,49 +1,132 @@
<template> <template>
<div class="job-details">
<h2 class="column-title">Job Details</h2> <h2 class="column-title">Job Details</h2>
<div class="job-details">
<table class="details">
<tr class="field-id">
<th>ID</th>
<td>{{ jobData.id }}</td>
</tr>
<tr class="field-name">
<th>Name</th>
<td>{{ jobData.name }}</td>
</tr>
<tr class="field-status">
<th>Status</th>
<td>{{ jobData.status }}</td>
</tr>
<tr class="field-type">
<th>Type</th>
<td>{{ jobData.type }}</td>
</tr>
<tr class="field-priority">
<th>Prio</th>
<td>{{ jobData.priority }}</td>
</tr>
<tr class="field-created">
<th>Created</th>
<td>{{ datetime.relativeTime(jobData.created) }}</td>
</tr>
<tr class="field-updated">
<th>Updated</th>
<td>{{ datetime.relativeTime(jobData.updated) }}</td>
</tr>
</table>
<dl class="metadata">
</dl>
</div> </div>
</template> </template>
<script lang="js"> <script lang="js">
import { DateTime } from "luxon"; import * as datetime from "../datetime";
import { import {
JobsApi, JobsApi,
} from '../manager-api' } from '../manager-api'
export default { export default {
props: ["apiClient"], props: [
"apiClient", // Flamenco Manager API client.
// Object, subset of job info, should at least contain an 'id' key. This ID
// determines the job that's shown here. The rest of the fields are used to
// initialise the details until the full job has been fetched from the API.
"jobSummary",
],
data: () => { data: () => {
return { return {
jobData: {},
datetime: datetime,
}; };
}, },
mounted() { mounted() {
// Allow testing from the JS console: // Allow testing from the JS console:
window.jobDetailsVue = this; window.jobDetailsVue = this;
}, },
watch: {
jobSummary(newSummary, oldSummary) {
console.log("Updating job details:", JSON.parse(JSON.stringify(newSummary)));
this.jobData = newSummary;
// TODO: Fetch the rest of the job.
},
},
methods: { methods: {
onReconnected() { onReconnected() {
// If the connection to the backend was lost, we have likely missed some // If the connection to the backend was lost, we have likely missed some
// updates. Just fetch the data and start from scratch. // updates. Just fetch the data and start from scratch.
this.fetchJob(); this.fetchJob();
}, },
fetchAllJob() { fetchJob() {
if (this.apiClient === undefined) { if (!this.apiClient) {
throw "no apiClient set on JobsTable component"; throw "no apiClient set on JobDetails component";
}
if (!this.jobSummary || !this.jobSummary.id) {
// no job selected, which is fine.
this.clearJobDetails();
return "";
} }
const jobsApi = new JobsApi(this.apiClient); const jobsApi = new JobsApi(this.apiClient);
const jobID = ""; // TODO: get from outer scope. const jobID = this.jobSummary.id;
jobsApi.fetchJob(jobID).then(this.onJobFetched, function (error) { jobsApi.fetchJob(jobID).then(this.onJobFetched, function (error) {
// TODO: error handling. // TODO: error handling.
console.error(error); console.error(error);
}); });
return jobID;
}, },
onJobFetched(data) { onJobFetched(data) {
console.log("Job fetched:", data); console.log("Job fetched:", data);
}, },
clearJobDetails() {
this.jobData = {};
},
} }
}; };
</script> </script>
<style scoped> <style scoped>
.job-details {
font-size: smaller;
font-family: 'Noto Mono', monospace;
}
tr:hover {
background-color: lightgrey;
}
tr.field-id td {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
th {
font-weight: bold;
text-align: right;
vertical-align: top;
}
</style> </style>

View File

@ -1,17 +1,17 @@
<template> <template>
<div class="job-list" id="flamenco_job_list"> <div class="job-list" id="flamenco_job_list"></div>
</div>
</template> </template>
<script lang="js"> <script lang="js">
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { DateTime } from "luxon"; import * as datetime from "../datetime";
import { import {
JobsApi, JobsApi,
} from '../manager-api' } from '../manager-api'
export default { export default {
emits: ["activeJobChange"],
props: ["apiClient"], props: ["apiClient"],
data: () => { data: () => {
const options = { const options = {
@ -28,17 +28,10 @@ export default {
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" }, sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" },
formatter(cell, formatterParams) { // eslint-disable-line no-unused-vars formatter(cell, formatterParams) { // eslint-disable-line no-unused-vars
const cellValue = cell.getData().updated; const cellValue = cell.getData().updated;
let updated = null; // TODO: if any "{amount} {units} ago" shown, the table should be
if (cellValue instanceof Date) { // refreshed every few {units}, so that it doesn't show any stale "4
updated = DateTime.fromJSDate(cellValue); // seconds ago" for days.
} else { return datetime.relativeTime(cellValue);
updated = DateTime.fromISO(cellValue);
}
const now = DateTime.local();
const ageInDays = now.diff(updated).as('days');
if (ageInDays > 14)
return updated.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY);
return updated.toRelative();
} }
}, },
], ],
@ -46,7 +39,8 @@ export default {
{ column: "updated", dir: "desc" }, { column: "updated", dir: "desc" },
], ],
height: "80%", height: "80%",
data: [], data: [], // Will be filled via a Flamenco API request.
selectable: 1, // Only allow a single row to be selected at a time.
}; };
return { return {
options: options, options: options,
@ -58,6 +52,7 @@ export default {
// jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO()}); // jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO()});
window.jobsTableVue = this; window.jobsTableVue = this;
this.tabulator = new Tabulator('#flamenco_job_list', this.options); this.tabulator = new Tabulator('#flamenco_job_list', this.options);
this.tabulator.on("rowSelected", this.onRowSelected);
this.fetchAllJobs(); this.fetchAllJobs();
}, },
methods: { methods: {
@ -71,7 +66,7 @@ export default {
tab.setSort(tab.getSorters()); // This triggers re-sorting. tab.setSort(tab.getSorters()); // This triggers re-sorting.
}, },
fetchAllJobs() { fetchAllJobs() {
if (this.apiClient === undefined) { if (!this.apiClient) {
throw "no apiClient set on JobsTable component"; throw "no apiClient set on JobsTable component";
} }
const jobsApi = new JobsApi(this.apiClient); const jobsApi = new JobsApi(this.apiClient);
@ -83,6 +78,7 @@ export default {
}, },
onJobsFetched(data) { onJobsFetched(data) {
this.tabulator.setData(data.jobs); this.tabulator.setData(data.jobs);
this.restoreRowSelection();
}, },
processJobUpdate(jobUpdate) { processJobUpdate(jobUpdate) {
// updateData() will only overwrite properties that are actually set on // updateData() will only overwrite properties that are actually set on
@ -103,13 +99,31 @@ export default {
console.error(error); console.error(error);
}); });
}, },
// Selection handling.
onRowSelected(row) {
this.storeRowSelection();
const rowData = row.getData();
this.$emit("activeJobChange", rowData);
},
storeRowSelection() {
const selectedData = this.tabulator.getSelectedData();
const selectedJobIDs = selectedData.map((row) => row.id);
localStorage.setItem("selectedJobIDs", selectedJobIDs);
},
restoreRowSelection() {
const selectedJobIDs = localStorage.getItem('selectedJobIDs');
if (!selectedJobIDs) {
return;
}
this.tabulator.selectRow(selectedJobIDs);
},
} }
}; };
</script> </script>
<style scoped> <style scoped>
.job-list { .job-list {
border: thick solid fuchsia;
font-family: 'Noto Mono', monospace; font-family: 'Noto Mono', monospace;
font-size: smaller; font-size: smaller;
} }

27
web/app/src/datetime.js Normal file
View File

@ -0,0 +1,27 @@
import { DateTime } from "luxon";
const relativeTimeDefaultOptions = {
thresholdDays: 14,
format: DateTime.DATE_MED_WITH_WEEKDAY,
}
// relativeTime parses the timestamp (can be ISO-formatted string or JS Date
// object) and returns it in string form. The returned string is either "xxx
// time ago" if it's a relatively short time ago, or the formatted absolute time
// otherwise.
export function relativeTime(timestamp, options) {
let parsedTimestamp = null;
if (timestamp instanceof Date) {
parsedTimestamp = DateTime.fromJSDate(timestamp);
} else {
parsedTimestamp = DateTime.fromISO(timestamp);
}
if (!options) options = relativeTimeDefaultOptions;
const now = DateTime.local();
const ageInDays = now.diff(parsedTimestamp).as('days');
if (ageInDays > options.format)
return parsedTimestamp.toLocaleString(options.format);
return parsedTimestamp.toRelative();
}