Compare commits

..

120 Commits

Author SHA1 Message Date
D. Berge
cf887b7852 Upgrade Plotly 2023-10-31 19:49:40 +01:00
D. Berge
c201229891 Add a link from the event log to the shotlog 2023-10-31 19:47:18 +01:00
D. Berge
7ac997cd7d Add a link from the shotlog to the event log 2023-10-31 19:46:40 +01:00
D. Berge
08e6c4a2de Restyle sequence list links to shot and event logs 2023-10-31 19:46:07 +01:00
D. Berge
2c21f8f7ef Add some graphics to the shotlog 2023-10-31 19:15:43 +01:00
D. Berge
a76aefe418 Add graphing component for shotpoint timing visualisations.
It operates in one of these modes:

* facet="bars" (default): shows a barplot.

* facet="lines": shows a lineplot.

* facet="area": shows a lineplot where the area between the
  line(s) and y=0 is filled with a colour.
2023-10-31 19:11:09 +01:00
D. Berge
8d825fc53b Add graphing component for inline/crossline visualisations.
The component takes a list of shots and operates in one of these
modes:

* facet="scatter" (default): shows a scatterplot of every shot
  where x is the crossline and y are the inline errors.

* facet="crossline": shows a line graph depicting the crossline
  error along the line, x is the shotpoint and y is the crossline
  error.

* facet="2dhist": shows the crossline error as a 2D histogram.
  The z value is the density (number of samples in the bin) and
  x and y are the bin centres.

* facet="c-o": provided that the shot data comes from a final
  sequence, shows the difference between final and raw positions
  along the inline / crossline axes.
2023-10-31 19:04:19 +01:00
D. Berge
b039a5f1fd Expand unpack() to be more expressive 2023-10-31 19:03:10 +01:00
D. Berge
5c1218e95e Add link to shotlog from sequence list 2023-10-31 10:34:51 +01:00
D. Berge
1bb5e2a41d Implement the SequenceSummary component as a shotlog 2023-10-31 10:33:56 +01:00
D. Berge
1576b121e6 Force dev frontend to run on IPv4 2023-10-29 20:46:11 +01:00
D. Berge
a06cdde449 Fix mapGetters() in ProjectList 2023-10-29 20:38:58 +01:00
D. Berge
121131e910 Add control to filter out archived projects in ProjectList 2023-10-29 20:38:58 +01:00
D. Berge
9136e9655d Return the archived project configuration value.
This value indicates whether the project should receive updates
from external sources.
2023-10-29 20:38:58 +01:00
D. Berge
c646944886 Add download control for all events to Log view.
The log until now offered a download control only in sequence
view mode. With this change, download is available (albeit not
in all formats) for the entire log.

To download events for a selection of dates (constrained by day,
week or month) the user should use the Calendar view instead.
2023-10-29 20:38:58 +01:00
D. Berge
0e664fc095 Add download control to Calendar view.
Will download the event log for the currently selected calendar
period (day, week, month, …) in a choice of formats.
2023-10-29 20:38:58 +01:00
D. Berge
1498891004 Update API specification 2023-10-29 20:38:58 +01:00
D. Berge
89cb237f8d Use setContentDisposition() 2023-10-29 20:38:58 +01:00
D. Berge
3386c57670 Add setContentDisposition() utility function.
It checks if a request has a `filename` search parameter and if
so, set the Content-Disposition response header to attachment
with the provided filename.
2023-10-29 20:38:58 +01:00
D. Berge
7285de5ec4 Import projectConfiguration getter into Log view.
So that we can get hold of `events.presetRemarks`.
2023-10-29 20:38:58 +01:00
D. Berge
a95059f5e5 Change navigation bar aesthetics 2023-10-29 20:38:58 +01:00
D. Berge
1ac81c34ce Add structured values support to <dougal-event-edit/> 2023-10-29 20:38:58 +01:00
D. Berge
22387ba215 Add <dougal-event-select/> component.
This is a refactoring of <dougal-event-edit/> focusing on the
preset remark selection combo box and context menu with the
addition of support for structured values via the
<dougal-event-properties/> component.
2023-10-29 20:38:58 +01:00
D. Berge
b77d41e952 Add <dougal-event-properties/> component.
It provides an input form for structured values.
2023-10-29 20:38:58 +01:00
D. Berge
aeecb7db7d Replace hard-coded navigation bar with dynamic alternative.
Navigation bars should be coded as their own component and
added to the meta section of the Vue Router's route(s) in which
they are to be used.
2023-10-29 20:38:58 +01:00
D. Berge
ac9a683135 Add <v-app-bar/> extension info to router.
The idea is for the <dougal-navigation/> component to dynamically
load the extension, if any, defined in the route's meta attribute.
2023-10-29 20:38:58 +01:00
D. Berge
17a58f1396 Create new <v-app-bar/> extension component.
Intended to be used in the v-slot:extension slot of <v-app-bar/>.
2023-10-29 20:38:58 +01:00
D. Berge
b2a97a1987 Fix typo in SQL query.
Fixes #284.
2023-10-29 20:38:58 +01:00
D. Berge
f684e3e8d6 Track changes to projects list.
The main application component listens for project events and
updates the Vuex store when a project related event is seen.
2023-10-29 20:38:58 +01:00
D. Berge
219425245f Add Vuex projects module.
Not to be confused with the `project` module.

`projects`: lists all available projects
`project`: lists details for one project
2023-10-29 20:38:58 +01:00
D. Berge
31419e860e Update API specification 2023-10-28 11:21:43 +02:00
D. Berge
65481d3086 Return the group project configuration value.
This value (expected to be an array of strings) can be used to
link related projects.

Closes #186.
2023-10-28 11:15:26 +02:00
D. Berge
d64a1fcee7 Merge branch '280-consolidate-handling-of-project-data-in-the-project-component-rather-than-in-individual-tab' into 'devel'
Resolve "Consolidate handling of project data in the Project component rather than in individual tab components"

Closes #280

See merge request wgp/dougal/software!48
2023-10-25 14:28:06 +00:00
D. Berge
2365789d48 Merge branch '281-modify-planner-endpoint-s' into 'devel'
Resolve "Modify planner endpoint(s)"

Closes #281

See merge request wgp/dougal/software!49
2023-10-25 14:26:23 +00:00
D. Berge
4c2a2617a1 Adapt Project component to Vuex use for fetching data.
The Project component is now responsible for fetching and
updating the data used by most project tabs, with the
exception of ProjectSummary, QC, Graphs and Map. It is
also the only one listening for server events and reacting
to them.

Individual tabs are still responsible for sending data to
the server, at least for the time being.
2023-10-25 16:19:18 +02:00
D. Berge
5021888d03 Adapt Log component to Vuex use for fetching data 2023-10-25 16:18:41 +02:00
D. Berge
bf633f7fdf Refactor Calendar component.
- adapts it to Vuex use for fetching data
- displays extra events in 4-day and day views
- allows classifying by event label in 4-day and day views
2023-10-25 16:16:01 +02:00
D. Berge
847f49ad7c Adapt SequenceList component to Vuex use for fetching data 2023-10-25 16:15:17 +02:00
D. Berge
171feb9dd2 Adapt Plan component to Vuex use for fetching data 2023-10-25 16:14:45 +02:00
D. Berge
503a0de12f Adapt LineList component to Vuex use for fetching data 2023-10-25 16:13:56 +02:00
D. Berge
cf89a43f64 Add project configuration to Vuex store 2023-10-25 16:11:24 +02:00
D. Berge
680e376ed1 Add Vuex sequence module 2023-10-25 16:11:24 +02:00
D. Berge
a26974670a Add Vuex plan module 2023-10-25 16:11:24 +02:00
D. Berge
16a6cb59dc Add Vuex line module 2023-10-25 16:11:24 +02:00
D. Berge
829e206831 Add Vuex label module 2023-10-25 09:59:04 +02:00
D. Berge
83244fcd1a Add Vuex event module 2023-10-25 09:51:28 +02:00
D. Berge
d9a6c77d0c Update API description 2023-10-23 19:25:48 +02:00
D. Berge
b5aafe42ad Add YAML support to events GET endpoint 2023-10-23 19:24:03 +02:00
D. Berge
025f3f774d Add YAML and CSV support to project configuration GET endpoint 2023-10-23 19:22:50 +02:00
D. Berge
f26e746c2b Add flatEntries utility 2023-10-23 18:58:37 +02:00
D. Berge
39eaf17121 Update API description 2023-10-23 18:48:05 +02:00
D. Berge
1bb06938b1 Add CSV export handler to main event log endpoint.
Closes #245.
2023-10-23 17:28:30 +02:00
D. Berge
851369a0b4 Invalidate planner endpoint cache when setting remarks 2023-10-23 14:58:41 +02:00
D. Berge
5065d62443 Update planner endpoint documentation 2023-10-23 14:57:27 +02:00
D. Berge
2d1e1e9532 Modify return payload of planner endpoint.
Previous:

[
  { sequence: …},
  { sequence: …},
  …
]

Current:

{
  remarks: "…",
  sequences: [
    { sequence: …},
    { sequence: …},
    …
  ]
}
2023-10-23 14:53:32 +02:00
D. Berge
051049581a Merge branch '278-rewrite-events-queue' into 'devel'
Resolve "Rewrite events queue"

Closes #278

See merge request wgp/dougal/software!46
2023-10-17 10:28:21 +00:00
D. Berge
da5ae18b0b Merge branch '269-support-requesting-a-partial-update-from-the-events-log-endpoint' into devel 2023-10-17 12:27:31 +02:00
D. Berge
ac9353c101 Add database upgrade file 31. 2023-10-17 12:27:06 +02:00
D. Berge
c4c5c44bf1 Add comment 2023-10-17 12:20:19 +02:00
D. Berge
d3659ebf02 Merge branch '269-support-requesting-a-partial-update-from-the-events-log-endpoint' into 'devel'
Resolve "Support requesting a partial update from the events log endpoint"

Closes #269

See merge request wgp/dougal/software!47
2023-10-17 10:18:41 +00:00
D. Berge
6b5070e634 Add event changes API endpoint description 2023-10-17 12:15:41 +02:00
D. Berge
09ff96ceee Add events change API endpoint 2023-10-17 11:15:36 +02:00
D. Berge
f231acf109 Add events change middleware 2023-10-17 11:15:06 +02:00
D. Berge
e576e1662c Add library function returning event changes after given epoch 2023-10-17 11:13:58 +02:00
D. Berge
6a21ddd1cd Rewrite events listener and handlers.
The events listener now uses a proper self-consuming queue and
the event handlers have been rewritten accordingly.

The way this works is that running init() on the handlers
library instantiates the handlers and returns two higher-order
functions, prepare() and despatch(). A call to the latter of
these is appended to the queue with each new incoming event.

The handlers have access to a context object (ctx) which may be
used to persist data between calls and/or exchange data between
handlers. This is used notably to give the handlers access to
project configurations, which are themselves refreshed by a
project configuration change handler (DetectProjectConfigurationChange).
2023-10-14 20:53:42 +02:00
D. Berge
c1e35b2459 Cache project configuration details.
This avoids requesting the project configurations on every single
incoming message. A listener refreshes the data on configuration
changes.
2023-10-14 20:11:18 +02:00
D. Berge
eee2a96029 Modify logging statements 2023-10-14 20:10:46 +02:00
D. Berge
6f5e5a4d20 Fix bug for shortcut when there is only one candidate project 2023-10-14 20:09:07 +02:00
D. Berge
9e73cb7e00 Clean up on SIGINT, SIGHUP signals 2023-10-14 20:07:19 +02:00
D. Berge
d7ab4eec7c Run some tasks periodically from the main process.
This reduces reliance on crontab jobs.
2023-10-14 20:06:38 +02:00
D. Berge
cdd96a4bc7 Don't bother trying to kill the child process on exit.
As the exit signal handler does not allow asynchronous tasks and
besides, killing the parent should kill its children too.
2023-10-14 20:02:54 +02:00
D. Berge
39a21766b6 Exit on start up errors 2023-10-14 20:02:04 +02:00
D. Berge
0e33c18b5c Replace console.log() with debug library calls 2023-10-14 19:57:57 +02:00
D. Berge
7f411ac7dd Add queue libraries.
A basic queue implementation and one that consumes its items
automatically until empty.
2023-10-14 19:56:56 +02:00
D. Berge
ed1da11c9d Add helper function to purge notifications 2023-10-14 19:54:34 +02:00
D. Berge
66ec28dd83 Refactor DB notifications listener to support large payloads.
The listener will automatically retrieve the full payload
before passing it on to event handlers.
2023-10-14 18:33:41 +02:00
D. Berge
b928d96774 Add database upgrade file 30. 2023-10-14 18:29:28 +02:00
D. Berge
73335f9c1e Merge branch '136-add-line-change-time-log-pseudoevent' into 'devel'
Resolve "Add line change time log pseudoevent"

Closes #136

See merge request wgp/dougal/software!45
2023-10-04 12:50:49 +00:00
D. Berge
7b6b81dbc5 Add more debugging statements 2023-10-04 14:50:12 +02:00
D. Berge
2e11c574c2 Throw rather than return.
Otherwise the finally {} block won't run.
2023-10-04 14:49:35 +02:00
D. Berge
d07565807c Do not retry immediately 2023-10-04 14:49:09 +02:00
D. Berge
6eccbf215a There should be no need to await.
That is because the queue handler will, in theory, only ever
process one event at a time.
2023-09-30 21:29:15 +02:00
D. Berge
8abc05f04e Remove dead code 2023-09-30 21:29:15 +02:00
D. Berge
8f587467f9 Add comment 2023-09-30 21:29:15 +02:00
D. Berge
3d7a91c7ff Rewrite ReportLineChangeTime 2023-09-30 21:29:15 +02:00
D. Berge
3fd408074c Support passing array in opts.sequences to event.list() 2023-09-30 21:29:15 +02:00
D. Berge
f71cbd8f51 Add unique utility function 2023-09-30 21:29:15 +02:00
D. Berge
915df8ac16 Add handler for creation of line change time events 2023-09-30 21:29:15 +02:00
D. Berge
d5ecb08a2d Allow switching to event entry by time.
A ‘Timed’ button is shown when a new (not edited) event is in
the event entry dialogue and the event has sequence and/or
point values. Pressing the button deletes the sequence/point
information and sets the date and time fields to current time.

Fixes #277.
2023-09-30 21:26:32 +02:00
D. Berge
9388cd4861 Make daily_tasks work with new project configuration 2023-09-30 20:36:46 +02:00
D. Berge
180590b411 Mark events as being automatically generated 2023-09-30 01:42:27 +02:00
D. Berge
4ec37539bf Add utils to work with Postgres ranges 2023-09-30 01:41:45 +02:00
D. Berge
8755fe01b6 Refactor events.list.
The SQL has been simplified and the following changes made:

- The `sequence` argument now can only take one individual
  sequence, not a list of sequences.
- A new `sequences` argument is recognised. It takes a list
  of sequences (as a string).
- A new `label` argument is recognised. It takes a label
  name and returns events containing that label.
- A new `jpq` argument is recognised. It takes a JSONPath
  string which is applied to `meta` with jsonb_path_exists(),
  returning any events for which the JSON path expression
  matches.
2023-09-30 01:37:22 +02:00
D. Berge
0bfe54e0c2 Include the meta attribute when posting events 2023-09-30 01:36:18 +02:00
D. Berge
29bc689b84 Merge branch '276-add-soft-start-event-detection' into 'devel'
Resolve "Add soft start event detection"

Closes #276

See merge request wgp/dougal/software!44
2023-09-29 15:02:57 +00:00
D. Berge
65682febc7 Add soft start and full volume events detection 2023-09-29 17:02:03 +02:00
D. Berge
d408665d62 Write meta info to automatic events 2023-09-29 16:49:27 +02:00
D. Berge
64fceb0a01 Merge branch '127-sol-eol-events-not-being-inserted-in-the-log-automatically' into 'devel'
Resolve "SOL / EOL events not being inserted in the log automatically"

Closes #127

See merge request wgp/dougal/software!43
2023-09-29 14:17:46 +00:00
D. Berge
ab58e578c9 Use DEBUG library throughout 2023-09-29 16:16:33 +02:00
D. Berge
0e58b8fa5b Refactor code to identify candidate schemas.
As part of the refactoring, we took into account a slight payload
format change (project configuration details are under the `data`
attribute).
2023-09-29 16:13:35 +02:00
D. Berge
99ac082f00 Use common naming convention both online and offline 2023-09-29 16:11:44 +02:00
D. Berge
4d3fddc051 Merge branch '274-use-new-db-event-notifier-for-event-processing-handlers' into 'devel'
Resolve "Use new DB event notifier for event processing handlers"

Closes #275, #230, and #274

See merge request wgp/dougal/software!42
2023-09-29 14:03:00 +00:00
D. Berge
42456439a9 Remove ad-hoc notifier 2023-09-29 15:59:12 +02:00
D. Berge
ee0c0e7308 Replace ad-hoc notifier with pg-listen based version 2023-09-29 15:59:12 +02:00
D. Berge
998c272bf8 Add var/* to .gitignore 2023-09-29 15:59:12 +02:00
D. Berge
daddd1f0e8 Add script to rewrite packet captures IP and MAC addresses.
Closes #230.
2023-09-29 15:58:59 +02:00
D. Berge
17f20535cb Cope with fragmented UDP packets.
Fixes #275.

Use this as the systemd unit file to run as a service:

[Unit]
Description=Dougal Network Packet Capture
After=network.target remote-fs.target nss-lookup.target

[Service]
ExecStart=/srv/dougal/software/sbin/packet-capture.sh
ExecStop=/bin/kill -s QUIT $MAINPID
Restart=always
User=root
Group=users
Environment=PATH=/usr/bin:/usr/sbin:/usr/local/bin
Environment=INS_HOST=172.31.10.254
WorkingDirectory=/srv/dougal/software/var/
SyslogIdentifier=dougal.pcap

[Install]
WantedBy=multi-user.target
2023-09-29 15:28:11 +02:00
D. Berge
0829ea3ea1 Save a copy of the headers not the original.
Otherwise ExpressJS will complain about trying to modify
headers that have already been sent.
2023-09-24 12:17:16 +02:00
D. Berge
2069d9c3d7 Remove dead code 2023-09-24 12:15:06 +02:00
D. Berge
8a2d526c50 Ignore schema attribute in PATCH payload.
Fixes #273.
2023-09-24 12:14:20 +02:00
D. Berge
8ad96d6f73 Ensure that requiredFields is always defined.
Otherwise, `Object.entries(requiredFields)` may fail.
2023-09-24 11:59:26 +02:00
D. Berge
947faf8c05 Provide default glob specification for map layer imports 2023-09-24 11:34:10 +02:00
D. Berge
a948556455 Fail gracefully if map layer data does not exist.
Fixes #272.
2023-09-24 11:33:32 +02:00
D. Berge
835384b730 Apply path conversion to QC definition files 2023-09-23 22:50:09 +02:00
D. Berge
c5b93794f4 Move path conversion to general utilities 2023-09-23 13:44:53 +02:00
D. Berge
056cd32f0e Merge branch '271-qc-results-not-being-refreshed' into 'devel'
Resolve "QC results not being refreshed"

Closes #271

See merge request wgp/dougal/software!41
2023-09-18 10:08:35 +00:00
D. Berge
49bb413110 Merge branch '270-real-time-interface-stopped-working' into 'devel'
Resolve "Real-time interface stopped working"

Closes #270

See merge request wgp/dougal/software!40
2023-09-18 10:08:27 +00:00
D. Berge
ceccc42050 Don't cache response ETags for QC endpoints 2023-09-18 12:06:38 +02:00
D. Berge
aa3379e1c6 Adapt RTI save function to refactored project configuration in DB 2023-09-18 11:58:55 +02:00
D. Berge
4063af0e25 Merge branch '268-inline-crossline-errors-no-longer-being-calculated' into 'devel'
Resolve "Inline/crossline errors no longer being calculated"

Closes #268

See merge request wgp/dougal/software!39
2023-09-15 18:03:51 +00:00
110 changed files with 5314 additions and 932 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ etc/surveys/*.yaml
!etc/surveys/_*.yaml
etc/ssl/*
etc/config.yaml
var/*

View File

@@ -11,11 +11,9 @@ from datastore import Datastore
if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
print("Connecting to database")
db = Datastore()
surveys = db.surveys()
print("Reading surveys")
for survey in surveys:

View File

@@ -115,7 +115,10 @@ if __name__ == '__main__':
process(layer_name, layer, realprefix)
else:
elif os.path.isdir(realprefix):
if not "globs" in layer:
layer["globs"] = [ "**/*.geojson" ]
for globspec in layer["globs"]:
for physical_filepath in pathlib.Path(realprefix).glob(globspec):

View File

@@ -0,0 +1,164 @@
-- Support notification payloads larger than Postgres' NOTIFY limit.
--
-- New schema version: 0.4.3
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects the public schema only.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This creates a new table where large notification payloads are stored
-- temporarily and from which they might be recalled by the notification
-- listeners. It also creates a purge_notifications() procedure used to
-- clean up old notifications from the notifications log and finally,
-- modifies notify() to support these changes. When a large payload is
-- encountered, the payload is stored in the notify_payloads table and
-- a trimmed down version containing a notification_id is sent to listeners
-- instead. Listeners can then query notify_payloads to retrieve the full
-- payloads. It is the application layer's responsibility to delete old
-- notifications.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_schema () AS $outer$
BEGIN
RAISE NOTICE 'Updating public schema';
-- We need to set the search path because some of the trigger
-- functions reference other tables in survey schemas assuming
-- they are in the search path.
EXECUTE format('SET search_path TO public');
CREATE TABLE IF NOT EXISTS public.notify_payloads (
id SERIAL,
tstamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload text NOT NULL DEFAULT '',
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS notify_payload_tstamp ON notify_payloads (tstamp);
CREATE OR REPLACE FUNCTION public.notify() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
channel text := TG_ARGV[0];
pid text;
payload text;
notification text;
payload_id integer;
BEGIN
SELECT projects.pid INTO pid FROM projects WHERE schema = TG_TABLE_SCHEMA;
payload := json_build_object(
'tstamp', CURRENT_TIMESTAMP,
'operation', TG_OP,
'schema', TG_TABLE_SCHEMA,
'table', TG_TABLE_NAME,
'old', row_to_json(OLD),
'new', row_to_json(NEW),
'pid', pid
)::text;
IF octet_length(payload) < 1000 THEN
PERFORM pg_notify(channel, payload);
ELSE
-- We need to find another solution
-- FIXME Consider storing the payload in a temporary memory table,
-- referenced by some form of autogenerated ID. Then send the ID
-- as the payload and then it's up to the user to fetch the original
-- payload if interested. This needs a mechanism to expire older payloads
-- in the interest of conserving memory.
INSERT INTO notify_payloads (payload) VALUES (payload) RETURNING id INTO payload_id;
notification := json_build_object(
'tstamp', CURRENT_TIMESTAMP,
'operation', TG_OP,
'schema', TG_TABLE_SCHEMA,
'table', TG_TABLE_NAME,
'pid', pid,
'payload_id', payload_id
)::text;
PERFORM pg_notify(channel, notification);
RAISE INFO 'Payload over limit';
END IF;
RETURN NULL;
END;
$$;
CREATE PROCEDURE public.purge_notifications (age_seconds numeric DEFAULT 120) AS $$
DELETE FROM notify_payloads WHERE EXTRACT(epoch FROM CURRENT_TIMESTAMP - tstamp) > age_seconds;
$$ LANGUAGE sql;
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.4.3' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.4.2' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
-- This upgrade modified the `public` schema only, not individual
-- project schemas.
CALL pg_temp.upgrade_schema();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_schema ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.3"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.4.3"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,104 @@
-- Add event_log_changes function
--
-- New schema version: 0.4.4
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects all schemas in the database.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This adds a function event_log_changes which returns the subset of
-- events from event_log_full which have been modified on or after a
-- given timestamp.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_survey_schema (schema_name text) AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', schema_name;
-- We need to set the search path because some of the trigger
-- functions reference other tables in survey schemas assuming
-- they are in the search path.
EXECUTE format('SET search_path TO %I,public', schema_name);
CREATE OR REPLACE FUNCTION event_log_changes(ts0 timestamptz)
RETURNS SETOF event_log_full
LANGUAGE sql
AS $$
SELECT *
FROM event_log_full
WHERE lower(validity) > ts0 OR upper(validity) IS NOT NULL AND upper(validity) > ts0
ORDER BY lower(validity);
$$;
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.4.4' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.4.3' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
FOR row IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'survey_%'
ORDER BY schema_name
LOOP
CALL pg_temp.upgrade_survey_schema(row.schema_name);
END LOOP;
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_survey_schema (schema_name text);
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.4"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.4.4"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -18,7 +18,7 @@
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"plotly.js-dist": "^2.5.0",
"plotly.js-dist": "^2.27.0",
"suncalc": "^1.8.0",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",
@@ -10317,9 +10317,9 @@
}
},
"node_modules/plotly.js-dist": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.11.1.tgz",
"integrity": "sha512-TubG71bBueWRMkQQMGlG8/0q753x5LLXzEHieUt9s0M/nD7WpnjC7sEwH+flhN6dOLqQ2wGlOb8Z4A3ah4IDWg=="
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.27.0.tgz",
"integrity": "sha512-SuuIF6zpJpW+9ssgXghMdcaJ9mp06SATykpxBVSrX9PpX9YZpCg6oo/79WvjYUVHf33QreJ4csAJU3o5Ll32cQ=="
},
"node_modules/pnp-webpack-plugin": {
"version": "1.7.0",
@@ -23254,9 +23254,9 @@
}
},
"plotly.js-dist": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.11.1.tgz",
"integrity": "sha512-TubG71bBueWRMkQQMGlG8/0q753x5LLXzEHieUt9s0M/nD7WpnjC7sEwH+flhN6dOLqQ2wGlOb8Z4A3ah4IDWg=="
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.27.0.tgz",
"integrity": "sha512-SuuIF6zpJpW+9ssgXghMdcaJ9mp06SATykpxBVSrX9PpX9YZpCg6oo/79WvjYUVHf33QreJ4csAJU3o5Ll32cQ=="
},
"pnp-webpack-plugin": {
"version": "1.7.0",

View File

@@ -16,7 +16,7 @@
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"plotly.js-dist": "^2.5.0",
"plotly.js-dist": "^2.27.0",
"suncalc": "^1.8.0",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",

View File

@@ -35,7 +35,7 @@
</style>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import DougalNavigation from './components/navigation';
import DougalFooter from './components/footer';
@@ -53,7 +53,8 @@ export default {
computed: {
snackText () { return this.$store.state.snack.snackText },
snackColour () { return this.$store.state.snack.snackColour }
snackColour () { return this.$store.state.snack.snackColour },
...mapGetters(["serverEvent"])
},
watch: {
@@ -75,17 +76,25 @@ export default {
if (!newVal) {
this.$store.commit('setSnackText', "");
}
},
async serverEvent (event) {
if (event.channel == "project" && event.payload?.schema == "public") {
// Projects changed in some way or another
await this.refreshProjects();
}
}
},
methods: {
...mapActions(["setCredentials"])
...mapActions(["setCredentials", "refreshProjects"])
},
mounted () {
async mounted () {
// Local Storage values are always strings
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
this.setCredentials()
await this.setCredentials();
this.refreshProjects();
}
};

View File

@@ -0,0 +1,48 @@
<template>
<v-tabs :value="tab" show-arrows>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'DougalAppBarExtensionProject',
data() {
return {
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
]
};
},
computed: {
page () {
return this.$route.path.split(/\/+/)[3];
},
tab () {
return this.tabs.findIndex(t => t.href == this.page);
},
},
methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
}
}
}
</script>

View File

@@ -44,7 +44,7 @@
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="tsDate"
:disabled="!!(sequence || point || entrySequence || entryPoint)"
:disabled="!!(entrySequence || entryPoint)"
label="Date"
suffix="UTC"
prepend-icon="mdi-calendar"
@@ -64,7 +64,7 @@
<v-col>
<v-text-field
v-model="tsTime"
:disabled="!!(sequence || point || entrySequence || entryPoint)"
:disabled="!!(entrySequence || entryPoint)"
label="Time"
suffix="UTC"
prepend-icon="mdi-clock-outline"
@@ -123,29 +123,11 @@
<v-row dense>
<v-col cols="12">
<v-combobox
ref="remarks"
v-model="entryRemarks"
:disabled="loading"
:search-input.sync="entryRemarksInput"
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
<dougal-event-select
v-bind.sync="entryRemarks"
:preset-remarks="presetRemarks"
@update:labels="(v) => this.entryLabels = v"
></dougal-event-select>
</v-col>
</v-row>
@@ -256,6 +238,15 @@
>
Cancel
</v-btn>
<v-btn v-if="!id && (entrySequence || entryPoint)"
color="info"
text
title="Enter an event by time"
@click="timed"
>
<v-icon left small>mdi-clock-outline</v-icon>
Timed
</v-btn>
<v-spacer></v-spacer>
<v-btn
:disabled="!canSave"
@@ -281,6 +272,7 @@
<script>
import { mapActions } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventSelect from '@/components/event-select';
function stringSort (a, b) {
return a == b
@@ -299,6 +291,7 @@ function flattenRemarks(items, keywords=[], labels=[]) {
if (!item.items) {
result.push({
text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]),
keywords
})
@@ -333,7 +326,8 @@ export default {
name: 'DougalEventEdit',
components: {
DougalContextMenu
DougalContextMenu,
DougalEventSelect
},
props: {
@@ -345,6 +339,7 @@ export default {
sequence: { type: Number },
point: { type: Number },
remarks: { type: String },
meta: { type: Object },
labels: { type: Array, default: () => [] },
latitude: { type: Number },
longitude: { type: Number },
@@ -362,18 +357,11 @@ export default {
entrySequence: null,
entryPoint: null,
entryRemarks: null,
entryRemarksInput: null,
entryLatitude: null,
entryLongitude: null
}),
computed: {
remarksAvailable () {
return this.entryRemarksInput == this.entryRemarks?.text ||
this.entryRemarksInput == this.entryRemarks
? []
: flattenRemarks(this.presetRemarks);
},
allSelected () {
return this.entryLabels.length === this.items.length
@@ -385,11 +373,6 @@ export default {
return true;
}
// The user is editing the remarks
if (this.entryRemarksText != this.entryRemarksInput) {
return true;
}
// Selected label set distinct from input labels
if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) {
return true;
@@ -493,11 +476,8 @@ export default {
this.entrySequence = this.sequence;
this.entryPoint = this.point;
this.entryRemarks = this.remarks;
this.entryLabels = [...(this.labels??[])];
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
this.makeEntryRemarks();
}
},
@@ -568,22 +548,13 @@ export default {
};
},
searchRemarks (item, queryText, itemText) {
const needle = queryText.toLowerCase();
const text = item.text.toLowerCase();
const keywords = item.keywords.map(i => i.toLowerCase());
const labels = item.labels.map(i => i.toLowerCase());
return text.includes(needle) ||
keywords.some(i => i.includes(needle)) ||
labels.some(i => i.includes(needle));
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.entryRemarks = event;
this.remarksMenu = false;
makeEntryRemarks () {
this.entryRemarks = {
template: null,
schema: {},
values: [],
...this.meta?.structured_values,
text: this.remarks
}
},
@@ -632,6 +603,14 @@ export default {
}
},
timed () {
const tstamp = (new Date()).toISOString();
this.entrySequence = null;
this.entryPoint = null;
this.tsDate = tstamp.substr(0, 10);
this.tsTime = tstamp.substr(11, 8);
},
close () {
this.entryLabels = this.selectedLabels.map(this.labelToItem)
this.$emit("input", false);
@@ -640,14 +619,24 @@ export default {
save () {
// In case the focus goes directly from the remarks field
// to the Save button.
if (this.entryRemarksInput != this.entryRemarksText) {
this.entryRemarks = this.entryRemarksInput;
let meta;
if (this.entryRemarks.values?.length) {
meta = {
structured_values: {
template: this.entryRemarks.template,
schema: this.entryRemarks.schema,
values: this.entryRemarks.values
}
};
}
const data = {
id: this.id,
remarks: this.entryRemarksText,
labels: this.entryLabels
labels: this.entryLabels,
meta
};
/* NOTE This is the purist way.

View File

@@ -0,0 +1,142 @@
<template>
<v-card flat>
<v-card-subtitle v-text="text">
</v-card-subtitle>
<v-card-text style="max-height:350px;overflow:scroll;">
<v-form>
<template v-for="key in fieldKeys">
<template v-if="schema[key].enum">
<v-select v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
></v-select>
<v-select v-else :key="key"
v-model="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
></v-select>
</template>
<template v-else>
<v-text-field v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
type="number"
:min="schema[key].minimum"
:max="schema[key].maximum"
:step="schema[key].multiplier"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
>
</v-text-field>
<v-text-field v-else-if="schema[key].type == 'string'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
<v-checkbox v-else-if="schema[key].type == 'boolean'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@change="updateFieldValue(key, $event)"
>
</v-checkbox>
<v-text-field v-else :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
</template>
</template>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalEventPropertiesEdit",
components: {
},
props: {
value: String,
template: String,
schema: Object,
values: Array
},
data () {
return {
}
},
computed: {
fieldKeys () {
return Object.entries(this.schema).sort((a, b) => a[1].title > b[1].title ? 1 : -1).map(i => i[0]);
},
fieldValues () {
const keys = Object.keys(this.schema ?? this.values);
return Object.fromEntries(
keys.map( (k, idx) =>
[ k, this.values?.[idx] ?? this.schema[k].default ]));
},
/*
fields () {
// TODO Remove this and rename fields → schema
return this.schema;
},
*/
text () {
if (this.template) {
const rx = /{{([a-z_][a-z0-9_]*)}}/ig;
return this.template.replace(rx, (match, p1) => this.fieldValues[p1] ?? "(n/a)");
}
}
},
watch: {
values () {
this.$emit("input", this.text);
},
template () {
this.$emit("input", this.text);
},
schema () {
this.$emit("input", this.text);
}
},
methods: {
updateFieldValue(key, ev) {
const values = {...this.fieldValues};
values[key] = ev;
this.$emit("update:values", Object.values(values));
}
},
mount () {
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<v-combobox
ref="remarks"
:value="text"
@input="handleComboBox"
:search-input.sync="entryRemarksInput"
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
<v-expansion-panels v-if="haveProperties"
class="px-8"
:value="0"
>
<v-expansion-panel>
<v-expansion-panel-header>Properties</v-expansion-panel-header>
<v-expansion-panel-content>
<dougal-event-properties-edit
:value="text"
@input="$emit('update:text', $event)"
:template="template"
:schema="schema"
:values="values"
@update:values="$emit('update:values', $event)"
>
</dougal-event-properties-edit>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventPropertiesEdit from '@/components/event-properties';
export default {
name: "DougalEventSelect",
components: {
DougalContextMenu,
DougalEventPropertiesEdit
},
props: {
text: String,
template: String,
schema: Object,
values: Array,
presetRemarks: Array
},
data () {
return {
entryRemarksInput: null,
remarksMenu: false,
}
},
computed: {
remarksAvailable () {
return this.entryRemarksInput == this.text
? []
: this.flattenRemarks(this.presetRemarks);
},
haveProperties () {
for (const key in this.schema) {
return true;
}
return false;
}
},
watch: {
},
methods: {
flattenRemarks (items, keywords=[], labels=[]) {
const result = [];
if (items) {
for (const item of items) {
if (!item.items) {
result.push({
text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]),
keywords
})
} else {
const k = [...keywords, item.text];
const l = [...labels, ...(item.labels??[])];
result.push(...this.flattenRemarks(item.items, k, l))
}
}
}
return result;
},
searchRemarks (item, queryText, itemText) {
const needle = queryText.toLowerCase();
const text = item.text.toLowerCase();
const keywords = item.keywords.map(i => i.toLowerCase());
const labels = item.labels.map(i => i.toLowerCase());
return text.includes(needle) ||
keywords.some(i => i.includes(needle)) ||
labels.some(i => i.includes(needle));
},
handleComboBox (event) {
if (typeof event == "object") {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
} else {
this.$emit("update:text", event);
this.$emit("update:template", null);
this.$emit("update:properties", null);
this.$emit("update:labels", []);
}
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
this.remarksMenu = false;
}
},
},
mount () {
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
}
}
</script>

View File

@@ -0,0 +1,290 @@
<template>
<div ref="graph"
class="graph-container"
></div>
</template>
<style scoped>
.graph-container {
width: 100%;
height: 100%;
}
</style>
<script>
import Plotly from 'plotly.js-dist';
import unpack from '@/lib/unpack.js';
export default {
name: "DougalGraphProjectSequenceInlineCrossline",
props: {
items: Array,
gunDataFormat: { type: String, default: "smsrc" },
facet: { type: String, default: "scatter" }
},
data () {
return {
plotted: false,
resizeObserver: null
};
},
computed: {
config () {
switch (this.facet) {
case "scatter":
default:
return {
editable: false,
displayLogo: false
};
}
},
layout () {
const base = {
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
}
};
switch (this.facet) {
case "scatter":
return {
...base,
autocolorscale: true,
title: {text: `Preplot deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)"
},
yaxis: {
title: "Inline (m)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "crossline":
return {
...base,
autocolorscale: true,
title: {text: `Crossline deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m)</span>`},
xaxis: {
title: "Shotpoint"
},
yaxis: {
title: "Crossline (m)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "2dhist":
return {
...base,
showlegend: true,
title: {text: `Preplot deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)",
showgrid: true,
zeroline: true
},
yaxis: {
title: "Inline (m)",
showgrid: true,
zeroline: true
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "c-o":
return {
...base,
showlegend: true,
title: {text: `Final vs raw <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)",
showgrid: true,
zeroline: true
},
yaxis: {
title: "Inline (m)",
showgrid: true,
zeroline: true
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
}
},
data () {
if (!this.items?.length) {
return [];
}
let x, y, avg_x, avg_y, std_x, std_y;
const items = this.items.sort( (a, b) => a.point - b.point );
const meta = unpack(items, "meta");
const src_number = unpack(unpack(unpack(meta, "raw"), this.gunDataFormat), "src_number");
if (this.facet == "c-o") {
const _items = items.filter(i => i.errorfinal && i.errorraw);
const εf = unpack(unpack(_items, "errorfinal"), "coordinates");
const εr = unpack(unpack(_items, "errorraw"), "coordinates");
x = εf.map( (f, idx) => f[0] - εr[idx][0] )
y = εf.map( (f, idx) => f[1] - εr[idx][1] )
} else {
const coords = unpack(unpack(items, ((row) => row?.errorfinal ? row.errorfinal : row.errorraw)), "coordinates");
x = unpack(coords, 0);
y = unpack(coords, 1);
}
// No chance of overflow
avg_x = (x.reduce((acc, cur) => acc + cur, 0) / x.length).toFixed(2);
avg_y = (y.reduce((acc, cur) => acc + cur, 0) / y.length).toFixed(2);
std_x = Math.sqrt(x.reduce((acc, cur) => (cur-avg_x)**2 + acc, 0) / x.length).toFixed(2);
std_y = Math.sqrt(y.reduce((acc, cur) => (cur-avg_y)**2 + acc, 0) / y.length).toFixed(2);
if (this.facet == "scatter") {
const data = [{
type: "scatter",
mode: "markers",
x,
y,
meta: { avg_x, avg_y, std_x, std_y},
transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
}];
return data;
} else if (this.facet == "crossline") {
const s = unpack(items, "point");
const data = [{
type: "scatter",
x: s,
y: x,
meta: { avg_x, avg_y, std_x, std_y},
_transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
}];
return data;
} else if (this.facet == "2dhist" || this.facet == "c-o") {
const bottomValue = this.$vuetify.theme.isDark
? ['0.0', 'rgba(0,0,0,0)']
: ['0.0', 'rgb(165,0,38)'];
const topValue = this.$vuetify.theme.isDark
? ['1.0', 'rgb(49,54,149)']
: ['1.0', 'rgba(0,0,0,0)'];
const colourscale = this.facet == "c-o"
? [bottomValue, [0.1, 'rgb(0,0,0)'], [0.9, 'rgb(255,255,255)'], topValue]
: [
bottomValue,
['0.111111111111', 'rgb(215,48,39)'],
['0.222222222222', 'rgb(244,109,67)'],
['0.333333333333', 'rgb(253,174,97)'],
['0.444444444444', 'rgb(254,224,144)'],
['0.555555555556', 'rgb(224,243,248)'],
['0.666666666667', 'rgb(171,217,233)'],
['0.777777777778', 'rgb(116,173,209)'],
['0.888888888889', 'rgb(69,117,180)'],
topValue
];
const data = [{
type: "histogram2dcontour",
ncontours: 20,
colorscale: colourscale,
showscale: false,
reversescale: !this.$vuetify.theme.isDark,
contours: {
coloring: this.facet == "c-o" ? "fill" : "heatmap",
},
x,
y,
meta: { avg_x, avg_y, std_x, std_y}
}];
return data;
}
}
},
watch: {
items (cur, prev) {
if (cur != prev) {
this.plot();
}
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
methods: {
plot () {
Plotly.newPlot(this.$refs.graph, this.data, this.layout, this.config);
this.plotted = true;
},
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
},
beforeDestroy () {
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.$refs.graph);
}
}
}
</script>

View File

@@ -0,0 +1,196 @@
<template>
<div ref="graph"
class="graph-container"
></div>
</template>
<style scoped>
.graph-container {
width: 100%;
height: 100%;
}
</style>
<script>
import Plotly from 'plotly.js-dist';
import unpack from '@/lib/unpack.js';
export default {
name: "DougalGraphProjectSequenceShotpointTiming",
props: {
items: Array,
gunDataFormat: { type: String, default: "smsrc" },
facet: { type: String, default: "bars" }
},
data () {
return {
plotted: false,
resizeObserver: null
};
},
computed: {
config () {
return {
editable: false,
displayLogo: false
};
},
layout () {
return {
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
title: {text: "Shotpoint timing %{data[0].meta.subtitle}"},
xaxis: {
title: "Shotpoint"
},
yaxis: {
title: "Time (s)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
},
data () {
const items = this.items.map(i => {
return {
point: i.point,
tstamp: new Date(i.tstamp)
}
}).sort( (a, b) => a.tstamp - b.tstamp );
const x = [...unpack(items, "point")];
const y = items.map( (i, idx, ary) => (ary[idx+1]?.tstamp - i.tstamp)/1000 );
const src_number = unpack(this.items, ["meta", "raw", this.gunDataFormat, "src_number"]);
// We're dealing with intervals not points
x.pop(); y.pop(); src_number.pop();
const meta = {};
const stats = this.stats(x, y, src_number);
// We need to do the subtitle here rather than in layout as layout knows nothing
// about the number of arrays
if (stats.src_ids.length == 1) {
meta.subtitle = `<span style="font-size:smaller;">(μ = ${stats.avg.all.toFixed(2)} ±${stats.std.all.toFixed(2)} s)</span>`;
} else {
meta.subtitle = `<span style="font-size:smaller;">(μ = ${stats.avg.all.toFixed(2)} ±${stats.std.all.toFixed(2)} s)</span>`;
const per_source = [];
for (const key in stats.avg) {
if (key == "all") continue;
const s = `μ<sub>${key}</sub> = ${stats.avg[key].toFixed(2)} ±${stats.std[key].toFixed(2)} s`;
per_source.push(s);
}
meta.subtitle += `<br><span style="font-size:smaller;">` + per_source.join("; ") + "</span>";
}
const trace0 = {
type: "bar",
x,
y,
transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
meta
};
switch (this.facet) {
case "lines":
trace0.type = "scatter";
break;
case "area":
trace0.type = "scatter";
trace0.fill = "tozeroy";
break;
case "bars":
default:
// Nothing
}
return [trace0]
}
},
watch: {
items (cur, prev) {
if (cur != prev) {
this.plot();
}
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
methods: {
plot () {
Plotly.newPlot(this.$refs.graph, this.data, this.layout, this.config);
this.plotted = true;
},
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
},
stats (x, y, src_number) {
const avg = {};
const std = {};
const avg_all = (y.reduce((acc, cur) => acc + cur, 0) / y.length);
const std_all = Math.sqrt(y.reduce((acc, cur) => (cur-avg_all)**2 + acc, 0) / y.length);
avg.all = avg_all;
std.all = std_all;
const src_ids = new Set(src_number);
for (const src of src_ids) {
const v = y.filter((i, idx) => src_number[idx] == src);
const μ = (v.reduce((acc, cur) => acc + cur, 0) / v.length);
const σ = Math.sqrt(v.reduce((acc, cur) => (cur-μ)**2 + acc, 0) / v.length);
avg[src] = μ;
std[src] = σ;
}
return { avg, std, src_ids };
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
},
beforeDestroy () {
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.$refs.graph);
}
}
}
</script>

View File

@@ -2,8 +2,8 @@
<div class="line-status" v-if="sequences.length == 0">
<slot name="empty"></slot>
</div>
<div class="line-status" v-else-if="sequenceHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence"
<div class="line-status" v-else-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence" v-if="sequenceHref"
class="sequence"
:class="sequence.status"
:style="style(sequence)"
@@ -11,15 +11,41 @@
:to="sequenceHref(sequence)"
>
</router-link>
<router-link v-for="sequence in plannedSequences" :key="sequence.sequence" v-if="plannedSequenceHref"
class="sequence planned"
:style="style(sequence)"
:title="title(sequence, 'planned')"
:to="plannedSequenceHref(sequence)"
>
</router-link>
<router-link v-for="(line, key) in pendingReshoots" :key="key" v-if="pendingReshootHref"
class="sequence reshoot"
:style="style(line)"
:title="title(line, 'reshoot')"
:to="pendingReshootHref(line)"
>
</router-link>
</div>
<div class="line-status" v-else>
<div v-for="sequence in sequences"
<div v-for="sequence in sequences" :key="sequence.sequence"
class="sequence"
:class="sequence.status"
:style="style(sequence)"
:title="title(sequence)"
>
</div>
<div v-for="sequence in plannedSequences" :key="sequence.sequence"
class="sequence planned"
:style="style(sequence)"
:title="title(sequence, 'planned')"
>
</div>
<div v-for="(line, key) in pendingReshoots" :key="key"
class="sequence reshoot"
:style="style(line)"
:title="title(line, 'reshoot')"
>
</div>
</div>
</template>
@@ -48,6 +74,8 @@
background-color blue
&.planned
background-color magenta
&.reshoot
background repeating-linear-gradient(-45deg, rgba(255,0,255,0.302), brown 5px, rgba(247, 247, 247, 0.1) 5px, rgba(242, 241, 241, 0.08) 10px), repeating-linear-gradient(45deg, rgba(255,0,255,0.302), brown 5px, rgba(247, 247, 247, 0.1) 5px, rgba(242, 241, 241, 0.08) 10px)
</style>
<script>
@@ -58,7 +86,11 @@ export default {
props: {
preplot: Object,
sequences: Array,
"sequence-href": Function
"sequence-href": Function,
"planned-sequences": Array,
"planned-sequence-href": Function,
"pending-reshoots": Array,
"pending-reshoot-href": Function
},
methods: {
@@ -68,13 +100,13 @@ export default {
? s.fsp_final
: s.status == "ntbp"
? (s.fsp_final || s.fsp)
: s.fsp; /* status == "raw" */
: s.fsp; /* status == "raw" or planned sequence or pending reshoot */
const lsp = s.status == "final"
? s.lsp_final
: s.status == "ntbp"
? (s.lsp_final || s.lsp)
: s.lsp; /* status == "raw" */
: s.lsp; /* status == "raw" or planned sequence or pending reshoot */
const pp0 = Math.min(this.preplot.fsp, this.preplot.lsp);
const pp1 = Math.max(this.preplot.fsp, this.preplot.lsp);
@@ -91,20 +123,24 @@ export default {
return values;
},
title (s) {
const status = s.status == "final"
? "Final"
: s.status == "raw"
? "Acquired"
: s.status == "ntbp"
? "NTBP"
: s.status == "planned"
? "Planned"
: s.status;
title (s, type) {
if (s.status || type == "planned") {
const status = s.status == "final"
? "Final"
: s.status == "raw"
? "Acquired"
: s.status == "ntbp"
? "NTBP"
: type == "planned"
? "Planned"
: s.status;
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()
return `Sequence ${s.sequence} ${status} (${s.fsp_final || s.fsp}${s.lsp_final || s.lsp})${remarks}`;
return `Sequence ${s.sequence} ${status} (${s.fsp_final || s.fsp}${s.lsp_final || s.lsp})${remarks}`;
} else if (type == "reshoot") {
return `Pending reshoot (${s.fsp}${s.lsp})${s.remarks? "\n"+s.remarks : ""}`;
}
}
}

View File

@@ -3,6 +3,7 @@
<v-app-bar
app
clipped-left
elevation="1"
>
<v-img src="/wgp-logo.png"
contain
@@ -71,17 +72,10 @@
</v-menu>
<!--
<v-btn small text class="ml-2" title="Log out" link to="/?logout=1">
<v-icon small>mdi-logout</v-icon>
</v-btn>
-->
</template>
</template>
<template v-slot:extension v-if="$route.matched.find(i => i.name == 'Project')">
<v-tabs :value="tab" show-arrows align-with-title>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
<template v-slot:extension v-if="appBarExtension">
<div :is="appBarExtension"></div>
</template>
</v-app-bar>
@@ -95,24 +89,17 @@ export default {
data() {
return {
drawer: false,
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
],
path: []
};
},
computed: {
tab () {
return this.tabs.findIndex(t => t.href == this.$route.path.split(/\/+/)[3]);
appBarExtension () {
return this.$route.matched
.filter(i => i.meta?.appBarExtension)
.map(i => i.meta.appBarExtension)
.pop()?.component;
},
...mapGetters(['user', 'loading'])
@@ -131,9 +118,6 @@ export default {
},
methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
},
breadcrumbs () {
this.path = this.$route.matched

View File

@@ -1,4 +1,33 @@
/** Unpacks attributes from array items.
*
* At it simplest, given an array of objects,
* the call unpack(rows, "x") returns an array
* of the "x" attribute of every item in rows.
*
* `key` may also be:
*
* - a function with the signature
* (Object) => any
* the result of applying the function to
* the object will be used as the unpacked
* value.
*
* - an array of strings, functions or other
* arrays. In this case, it does a recursive
* fold operation. NOTE: it mutates `key`.
*
*/
export default function unpack(rows, key) {
return rows && rows.map( row => row[key] );
if (typeof key === "function") {
return rows && rows.map( row => key(row) );
} else if (Array.isArray(key)) {
const car = key.shift();
if (key.length) {
return unpack(unpack(rows, car), key);
} else {
return unpack(rows, car);
}
} else {
return rows && rows.map( row => row?.[key] );
}
};

View File

@@ -16,7 +16,7 @@ import Log from '../views/Log.vue'
import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
Vue.use(VueRouter)
@@ -100,7 +100,10 @@ Vue.use(VueRouter)
text: (ctx) => ctx.$store.state.project.projectName || "…",
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`
}
]
],
appBarExtension: {
component: DougalAppBarExtensionProject
}
},
children: [
{

View File

@@ -4,7 +4,13 @@ import Vuex from 'vuex'
import api from './modules/api'
import user from './modules/user'
import snack from './modules/snack'
import projects from './modules/projects'
import project from './modules/project'
import event from './modules/event'
import label from './modules/label'
import sequence from './modules/sequence'
import plan from './modules/plan'
import line from './modules/line'
import notify from './modules/notify'
Vue.use(Vuex)
@@ -14,7 +20,13 @@ export default new Vuex.Store({
api,
user,
snack,
projects,
project,
event,
label,
sequence,
plan,
line,
notify
}
})

View File

@@ -0,0 +1,129 @@
/** Fetch events from server
*/
async function refreshEvents ({commit, dispatch, state, rootState}, [modifiedAfter] = []) {
if (!modifiedAfter) {
modifiedAfter = state.timestamp;
}
if (state.loading) {
commit('abortEventsLoading');
}
commit('setEventsLoading');
const pid = rootState.project.projectId;
const url = modifiedAfter
? `/project/${pid}/event/changes/${(new Date(modifiedAfter)).toISOString()}?unique=t`
: `/project/${pid}/event`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
if (modifiedAfter) {
commit('setModifiedEvents', res);
} else {
commit('setEvents', res);
}
commit('setEventsTimestamp');
}
commit('clearEventsLoading');
}
/** Return a subset of events from state.events
*/
async function getEvents ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text, label}]) {
let filteredEvents = [...state.events];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredEvents.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredEvents.reverse();
}
});
}
if (sequence) {
filteredEvents = filteredEvents.filter( event => event.sequence == sequence );
}
if (date0 && date1) {
filteredEvents = filteredEvents.filter( event =>
event.tstamp.substr(0, 10) >= date0 && event.tstamp.substr(0, 10) <= date1
);
} else if (date0) {
filteredEvents = filteredEvents.filter( event => event.tstamp.substr(0, 10) == date0 );
}
if (text) {
const tstampFilter = (value, search, item) => {
return textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
tstamp: tstampFilter,
sequence: numberFilter,
point: numberFilter,
remarks: textFilter,
labels: (value, search, item) => value.some(label => textFilter(label, search, item))
};
filteredEvents = filteredEvents.filter ( event => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(event[key], text, event)) {
return true;
}
}
return false;
});
}
if (label) {
filteredEvents = filteredEvents.filter( event => event.labels?.includes(label) );
}
const count = filteredEvents.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredEvents = filteredEvents.slice(offset, offset+itemsPerPage);
}
return {events: filteredEvents, count};
}
export default { refreshEvents, getEvents };

View File

@@ -0,0 +1,14 @@
function events (state) {
return state.events;
}
function eventCount (state) {
return state.events?.length ?? 0;
}
function eventsLoading (state) {
return !!state.loading;
}
export default { events, eventCount, eventsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,73 @@
function setEvents (state, events) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.events = Object.freeze(events);
}
/** Selectively replace / insert / delete events
* from state.events.
*
* modifiedEvents is the result of
* /api/project/:project/event/changes?unique=t
*/
function setModifiedEvents (state, modifiedEvents) {
const events = [...state.events];
for (let evt of modifiedEvents) {
const idx = events.findIndex(i => i.id == evt.id);
if (idx != -1) {
if (evt.is_deleted) {
events.splice(idx, 1);
} else {
delete evt.is_deleted;
events.splice(idx, 1, evt);
}
} else {
if (!evt.is_deleted) {
delete evt.is_deleted;
events.unshift(evt);
}
}
}
setEvents(state, events);
}
function setEventsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
function clearEventsLoading (state) {
state.loading = null;
}
function setEventsTimestamp (state, timestamp = new Date()) {
if (timestamp === true) {
const tstamp = state.events
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setEventsETag (state, etag) {
state.etag = etag;
}
function abortEventsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setEvents,
setModifiedEvents,
setEventsLoading,
clearEventsLoading,
abortEventsLoading,
setEventsTimestamp,
setEventsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
events: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,106 @@
/** Fetch labels from server
*/
async function refreshLabels ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortLabelsLoading');
}
commit('setLabelsLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/label`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setLabels', res);
commit('setLabelsTimestamp');
}
commit('clearLabelsLoading');
}
/** Return a subset of labels from state.labels.
*
* Note that, unlike other actions in the get* family,
* the return value is not isomorphic to the state.
*
* While state.labels is an object, getLabels() returns
* an array with each item have the shape:
*
* { label: "labelName", view: {…}, model: {…} }
*
* This is intended to be useful, for instance, for a table
* of labels.
*/
async function getLabels ({commit, dispatch, state}, [projectId, {sortBy, sortDesc, itemsPerPage, page, text, label}]) {
let filteredLabels = Object.entries(state.labels).map(i => {
return {
label: i[0],
...i[1]
}
});
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredLabels.sort( (el0, el1) => {
const a = key == "label" ? el0[0] : el0[1].view[key];
const b = key == "label" ? el1[0] : el1[1].view[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredLabels.reverse();
}
});
}
if (label) {
filteredLabels = filteredLabels.filter( label => label.label == label );
}
if (text) {
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
label: numberFilter,
description: textFilter,
};
filteredLabels = filteredLabels.filter ( item => {
return textFilter(item.label, text, item) ?? textFilter(item.view.description, text, item);
});
}
const count = filteredLabels.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredLabels = filteredLabels.slice(offset, offset+itemsPerPage);
}
return {labels: filteredLabels, count};
}
export default { refreshLabels, getLabels };

View File

@@ -0,0 +1,22 @@
function labels (state) {
return state.labels;
}
/** Return labels that can be added by users.
*
* As opposed to system labels.
*/
function userLabels (state) {
return Object.fromEntries(Object.entries(state.labels).filter(i => i[1].model.user));
}
function labelCount (state) {
return state.labels?.length ?? 0;
}
function labelsLoading (state) {
return !!state.loading;
}
export default { labels, userLabels, labelCount, labelsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setLabels (state, labels) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.labels = Object.freeze(labels);
}
function setLabelsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearLabelsLoading (state) {
state.loading = null;
}
function setLabelsTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the labels
// result or in the database schema, but we could add
// one.
if (timestamp === true) {
const tstamp = state.labels
.map( i => i.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setLabelsETag (state, etag) {
state.etag = etag;
}
function abortLabelsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setLabels,
setLabelsLoading,
clearLabelsLoading,
setLabelsTimestamp,
setLabelsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
labels: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,117 @@
/** Fetch lines from server
*/
async function refreshLines ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortLinesLoading');
}
commit('setLinesLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/line`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setLines', res);
commit('setLinesTimestamp');
}
commit('clearLinesLoading');
}
/** Return a subset of lines from state.lines
*/
async function getLines ({commit, dispatch, state}, [projectId, {line, fsp, lsp, incr, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredLines = [...state.lines];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredLines.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredLines.reverse();
}
});
}
if (line) {
filteredLines = filteredLines.filter( line => line.line == line );
}
if (fsp) {
filteredLines = filteredLines.filter( line => line.fsp == fsp );
}
if (lsp) {
filteredLines = filteredLines.filter( line => line.lsp == lsp );
}
if (text) {
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const incrFilter = (value, search, item) => {
const inc = /^(incr(ement)?|↑|\+)/i;
const dec = /^(decr(ement)?|↓|-)/i;
return (inc.test(search) && value) || (dec.test(search) && !value)
}
const searchFunctions = {
line: numberFilter,
fsp: numberFilter,
lsp: numberFilter,
remarks: textFilter,
incr: incrFilter,
ntba: (value, search, item) => text.toLowerCase() == "ntba" && value
};
filteredLines = filteredLines.filter ( line => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(line[key], text, line)) {
return true;
}
}
return false;
});
}
const count = filteredLines.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredLines = filteredLines.slice(offset, offset+itemsPerPage);
}
return {lines: filteredLines, count};
}
export default { refreshLines, getLines };

View File

@@ -0,0 +1,14 @@
function lines (state) {
return state.lines;
}
function lineCount (state) {
return state.lines?.length ?? 0;
}
function linesLoading (state) {
return !!state.loading;
}
export default { lines, lineCount, linesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setLines (state, lines) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.lines = Object.freeze(lines);
}
function setLinesLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearLinesLoading (state) {
state.loading = null;
}
function setLinesTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the lines
// result or in the database schema, but we could perhaps add
// one.
if (timestamp === true) {
const tstamp = state.lines
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setLinesETag (state, etag) {
state.etag = etag;
}
function abortLinesLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setLines,
setLinesLoading,
clearLinesLoading,
setLinesTimestamp,
setLinesETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
lines: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,114 @@
/** Fetch sequences from server
*/
async function refreshPlan ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortPlanLoading');
}
commit('setPlanLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/plan`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setPlan', res);
commit('setPlanTimestamp');
}
commit('clearPlanLoading');
}
/** Return a subset of sequences from state.sequences
*/
async function getPlannedSequences ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredPlannedSequences = [...state.sequences];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredPlannedSequences.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredPlannedSequences.reverse();
}
});
}
if (sequence) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence => sequence.sequence == sequence );
}
if (date0 && date1) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence =>
sequence.ts0.substr(0, 10) >= date0 && sequence.ts1.substr(0, 10) <= date1
);
} else if (date0) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence => sequence.ts0.substr(0, 10) == date0 || sequence.ts1.substr(0, 10) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return textFilter(value.toISOString(), search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
sequence: numberFilter,
line: numberFilter,
remarks: textFilter,
ts0: tstampFilter,
ts1: tstampFilter
};
filteredPlannedSequences = filteredPlannedSequences.filter ( sequence => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(sequence[key], text, sequence)) {
return true;
}
}
return false;
});
}
const count = filteredPlannedSequences.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredPlannedSequences = filteredPlannedSequences.slice(offset, offset+itemsPerPage);
}
return {sequences: filteredPlannedSequences, count};
}
export default { refreshPlan, getPlannedSequences };

View File

@@ -0,0 +1,18 @@
function planRemarks (state) {
return state.remarks;
}
function plannedSequences (state) {
return state.sequences;
}
function plannedSequenceCount (state) {
return state.sequences?.length ?? 0;
}
function plannedSequencesLoading (state) {
return !!state.loading;
}
export default { planRemarks, plannedSequences, plannedSequenceCount, plannedSequencesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,59 @@
function transform (item) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
return item;
}
// ATTENTION: This relies on the new planner endpoint
// as per issue #281.
function setPlan (state, plan) {
// We don't need or want the planned sequences array to be reactive
state.sequences = Object.freeze(plan.sequences.map(transform));
state.remarks = plan.remarks;
}
function setPlanLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearPlanLoading (state) {
state.loading = null;
}
function setPlanTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the plan
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.plan
.map( item => item.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setPlanETag (state, etag) {
state.etag = etag;
}
function abortPlanLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setPlan,
setPlanLoading,
clearPlanLoading,
setPlanTimestamp,
setPlanETag
};

View File

@@ -0,0 +1,9 @@
const state = () => ({
sequences: Object.freeze([]),
remarks: null,
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -1,10 +1,11 @@
async function getProject ({commit, dispatch}, projectId) {
const res = await dispatch('api', [`/project/${String(projectId).toLowerCase()}`]);
const res = await dispatch('api', [`/project/${String(projectId).toLowerCase()}/configuration`]);
if (res) {
commit('setProjectName', res.name);
commit('setProjectId', res.pid);
commit('setProjectId', res.id?.toLowerCase());
commit('setProjectSchema', res.schema);
commit('setProjectConfiguration', res);
const recentProjects = JSON.parse(localStorage.getItem("recentProjects") || "[]")
recentProjects.unshift(res);
localStorage.setItem("recentProjects", JSON.stringify(recentProjects.slice(0, 3)));
@@ -12,6 +13,7 @@ async function getProject ({commit, dispatch}, projectId) {
commit('setProjectName', null);
commit('setProjectId', null);
commit('setProjectSchema', null);
commit('setProjectConfiguration', {});
}
}

View File

@@ -11,4 +11,8 @@ function projectSchema (state) {
return state.projectSchema;
}
export default { projectId, projectName, projectSchema };
function projectConfiguration (state) {
return state.projectConfiguration;
}
export default { projectId, projectName, projectSchema, projectConfiguration };

View File

@@ -11,4 +11,8 @@ function setProjectSchema (state, schema) {
state.projectSchema = schema;
}
export default { setProjectId, setProjectName, setProjectSchema };
function setProjectConfiguration (state, configuration) {
state.projectConfiguration = Object.freeze(configuration);
}
export default { setProjectId, setProjectName, setProjectSchema, setProjectConfiguration };

View File

@@ -1,7 +1,8 @@
const state = () => ({
projectId: null,
projectName: null,
projectSchema: null
projectSchema: null,
projectConfiguration: {}
});
export default state;

View File

@@ -0,0 +1,120 @@
/** Fetch projects from server
*/
async function refreshProjects ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortProjectsLoading');
}
commit('setProjectsLoading');
const pid = rootState.project.projectId;
const url = `/project`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setProjects', res);
commit('setProjectsTimestamp');
}
commit('clearProjectsLoading');
}
/** Return a subset of projects from state.projects
*/
async function getProjects ({commit, dispatch, state}, [{pid, name, schema, group, sortBy, sortDesc, itemsPerPage, page, text}] = [{}]) {
let filteredProjects = [...state.projects];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredProjects.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredProjects.reverse();
}
});
}
if (pid) {
filteredProjects = filteredProjects.filter( project => project.pid == pid );
}
if (name) {
filteredProjects = filteredProjects.filter( project => project.name.toLowerCase().includes(name.toLowerCase()) );
}
if (schema) {
filteredProjects = filteredProjects.filter( project => project.schema.toLowerCase().includes(schema.toLowerCase()) );
}
if (group) {
filteredProjects = filteredProjects.filter( project => project.groups.find(g => g.toLowerCase() == group.toLowerCase()) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return search?.length >= 5 && textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const arrayFilter = (elementFilter) => {
return (value, search, item) => value.some(element => elementFilter(element, search, item));
};
const searchFunctions = {
pid: textFilter,
name: textFilter,
group: arrayFilter(textFilter)
};
filteredProjects = filteredProjects.filter ( project => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(project[key], text, project)) {
return true;
}
}
return false;
});
}
const count = filteredProjects.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredProjects = filteredProjects.slice(offset, offset+itemsPerPage);
}
return {projects: filteredProjects, count};
}
export default { refreshProjects, getProjects };

View File

@@ -0,0 +1,18 @@
function projects (state) {
return state.projects;
}
function projectGroups (state) {
return [...new Set(state.projects.map(i => i.groups).flat())].sort();
}
function projectCount (state) {
return state.projects?.length ?? 0;
}
function projectsLoading (state) {
return !!state.loading;
}
export default { projects, projectGroups, projectCount, projectsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,48 @@
function setProjects (state, projects) {
// We don't need or want the array to be reactive
state.projects = Object.freeze(projects);
}
function setProjectsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearProjectsLoading (state) {
state.loading = null;
}
function setProjectsTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the projects
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.projects
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setProjectsETag (state, etag) {
state.etag = etag;
}
function abortProjectsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setProjects,
setProjectsLoading,
clearProjectsLoading,
setProjectsTimestamp,
setProjectsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
projects: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,122 @@
/** Fetch sequences from server
*/
async function refreshSequences ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortSequencesLoading');
}
commit('setSequencesLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/sequence?files=true`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setSequences', res);
commit('setSequencesTimestamp');
}
commit('clearSequencesLoading');
}
/** Return a subset of sequences from state.sequences
*/
async function getSequences ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredSequences = [...state.sequences];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredSequences.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredSequences.reverse();
}
});
}
if (sequence) {
filteredSequences = filteredSequences.filter( sequence => sequence.sequence == sequence );
}
if (date0 && date1) {
filteredSequences = filteredSequences.filter( sequence =>
(sequence.ts0_final ?? sequence.ts0)?.substr(0, 10) >= date0 &&
(sequence.ts1_final ?? sequence.ts1)?.substr(0, 10) <= date1
);
} else if (date0) {
filteredSequences = filteredSequences.filter( sequence => (sequence.ts0_final ?? sequence.ts0)?.substr(0, 10) == date0 || (sequence.ts1_final ?? sequence.ts1)?.substr(0, 10) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return search?.length >= 5 && textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
ts0: tstampFilter,
ts1: tstampFilter,
ts0_final: tstampFilter,
ts1_final: tstampFilter,
sequence: numberFilter,
line: numberFilter,
fsp: numberFilter,
lsp: numberFilter,
fsp_final: numberFilter,
fsp_final: numberFilter,
remarks: textFilter,
remarks_final: textFilter
};
filteredSequences = filteredSequences.filter ( sequence => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(sequence[key], text, sequence)) {
return true;
}
}
return false;
});
}
const count = filteredSequences.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredSequences = filteredSequences.slice(offset, offset+itemsPerPage);
}
return {sequences: filteredSequences, count};
}
export default { refreshSequences, getSequences };

View File

@@ -0,0 +1,14 @@
function sequences (state) {
return state.sequences;
}
function sequenceCount (state) {
return state.sequences?.length ?? 0;
}
function sequencesLoading (state) {
return !!state.loading;
}
export default { sequences, sequenceCount, sequencesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setSequences (state, sequences) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.sequences = Object.freeze(sequences);
}
function setSequencesLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearSequencesLoading (state) {
state.loading = null;
}
function setSequencesTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the sequences
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.sequences
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setSequencesETag (state, etag) {
state.etag = etag;
}
function abortSequencesLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setSequences,
setSequencesLoading,
clearSequencesLoading,
setSequencesTimestamp,
setSequencesETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
sequences: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -37,8 +37,72 @@
</v-btn>
<v-toolbar-title v-if="$refs.calendar">
{{ $refs.calendar.title }}
<span
class="secondary--text"
style="font-size:small;"
>
({{downloadableItemCount}} log entries)
</span>
</v-toolbar-title>
<v-menu class="ml-5" v-if="calendarDates">
<template v-slot:activator="{on, attrs}">
<v-btn
class="ml-5"
small
:title="`Download events for the period ${calendarDates.start} ${calendarDates.end}`"
v-on="on"
v-bind="attrs"
>
<span class="d-none d-lg-inline">Download as</span>
<v-icon right small>mdi-cloud-download</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
:href="downloadUrl({mime: 'application/vnd.seis+json', filename: `event-log-${calendarDates.start}-${calendarDates.end}-multiseis.json`})"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet supported
<v-list-item
:href="downloadUrl({mime: 'application/geo+json'})"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="downloadUrl({mime: 'application/json', filename: `event-log-${calendarDates.start}-${calendarDates.end}.json`})"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/yaml', filename: `event-log-${calendarDates.start}-${calendarDates.end}.yaml`})"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'text/csv', sortBy: 'tstamp', sortDesc: false, filename: `event-log-${calendarDates.start}-${calendarDates.end}.csv`})"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet supportd
<v-list-item
:href="downloadUrl({mime: 'text/html'})"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/pdf'})"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn v-if="categoriesAvailable"
small
class="mx-4"
v-model="useCategories"
@click="useCategories = !useCategories"
>Labels {{useCategories ? "On" : "Off"}}</v-btn>
<v-menu bottom right>
<template v-slot:activator="{ on, attrs }">
<v-btn
@@ -72,16 +136,23 @@
<v-calendar
ref="calendar"
v-model="focus"
:events="events"
:events="items"
:event-color="getEventColour"
color="primary"
:type="type"
:type="view"
:locale-first-day-of-year="4"
:weekdays="weekdays"
:show-week="true"
:category-days="categoryDays"
:categories="categories"
@click:date="showLogForDate"
@click:event="showLogForEvent"
></v-calendar>
@change="setSpan"
>
<template v-slot:event="{ event }">
<div style="height:100%;overflow:scroll;" v-html="event.name"></div>
</template>
</v-calendar>
</v-sheet>
</div>
</template>
@@ -97,8 +168,9 @@ export default {
weekdays: [1, 2, 3, 4, 5, 6, 0],
type: "week",
focus: "",
events: [
],
items: [],
useCategories: false,
span: {},
options: {
sortBy: "sequence"
}
@@ -117,28 +189,143 @@ export default {
return labels[this.type];
},
...mapGetters(['loading'])
view () {
return this.useCategories ? "category" : this.type;
},
categoriesAvailable () {
return this.type == "day" || this.type == "4day";
},
categoryDays () {
if (this.useCategories) {
const days = {
month: 30,
week: 7,
"4day": 4,
day: 1
};
return days[this.type];
}
},
visibleItems () {
return this.items.filter(i => {
const end = i.end ?? i.start;
if (i.start > this.span.end) {
return false;
}
if (end < this.span.start) {
return false;
}
return true;
});
},
categories () {
return [...new Set(this.visibleItems.map(i => i.category ?? "General"))];
},
calendarDates () {
// The this.items.length reference is only needed to force recalculation
// of this computed property, as this.$refs is not reactive.
// https://github.com/vuejs/vue/issues/3842
if (this.items.length && this.$refs.calendar) {
return {
start: this.$refs.calendar.renderProps.start.date,
end: this.$refs.calendar.renderProps.end.date
}
}
},
downloadableItemCount () {
return this.events.filter(i => i.tstamp.substr(0, 10) >= this.calendarDates?.start &&
i.tstamp.substr(0, 10) <= this.calendarDates?.end).length;
},
...mapGetters(['sequencesLoading', 'sequences', 'events'])
},
watch: {
sequences () {
const isFirstLoad = !this.items.length;
this.getEvents();
if (isFirstLoad) {
this.setLast();
}
},
events () {
const isFirstLoad = !this.items.length;
this.getEvents();
if (isFirstLoad) {
this.setLast();
}
},
type () {
this.getEvents();
},
categoriesAvailable (value) {
if (!value) {
this.useCategories = false;
}
}
},
methods: {
async getEvents () {
const query = new URLSearchParams(this.options);
const url = `/project/${this.$route.params.project}/sequence?${query.toString()}`;
const finalSequences = await this.api([url]) || [];
this.events = finalSequences.map(s => {
const sequences = this.sequences.map(s => {
const e = {};
//e.start = s.ts0.substring(0,10)+" "+s.ts0.substring(11,19)
//e.end = s.ts1.substring(0,10)+" "+s.ts1.substring(11,19)
e.routerLink = { name: "logBySequence", params: { sequence: s.sequence } };
e.start = new Date(s.ts0);
e.end = new Date(s.ts1);
e.timed = true;
e.colour = "orange";
e.name = `Sequence ${s.sequence}`;
e.name = `<b>Sequence ${s.sequence}</b><br/>Line ${s.line}<br/><abbr title="Shotpoints">SP</abbr> ${s.fgsp ?? s.fsp}${s.lgsp ?? s.lsp}`;
e.category = "Sequence"
return e;
});
const lineChanges = this.events.filter(i => i.meta?.["*ReportLineChangeTime*"]?.value && i.meta?.["*ReportLineChangeTime*"]?.type != "excess").map(i => {
const e = {};
const duration = i.meta?.["*ReportLineChangeTime*"]?.value;
e.end = new Date(i.tstamp);
e.start = new Date(e.end - duration);
e.timed = true;
e.colour = "pink";
e.name = "Line change";
e.category = "Production"
return e;
});
const excludedLabels = [ "FSP", "FGSP", "LSP", "LGSP", "QC" ];
const otherEvents = this.events.filter(i => !excludedLabels.some(l => i.labels.includes(l))).map(i => {
const e = {};
e.start = new Date(i.tstamp);
e.colour = "brown";
e.timed = true;
e.name = this.$options.filters.markdownInline(i.remarks);
e.category = i.labels[0];
return e;
});
this.items = [...sequences];
if (this.type == "day" || this.type == "4day") {
this.items.push(...lineChanges, ...otherEvents);
}
},
getEventColour (event) {
@@ -150,11 +337,15 @@ export default {
},
setFirst () {
this.focus = this.events[this.events.length-1].start;
if (this.items.length) {
this.focus = this.items[this.items.length-1].start;
}
},
setLast () {
this.focus = this.events[0].start;
if (this.items.length) {
this.focus = this.items[0].start;
}
},
prev () {
@@ -175,6 +366,25 @@ export default {
}
},
setSpan (span) {
this.span = {
start: new Date(span.start.date),
end: new Date((new Date(span.end.date)).valueOf() + 86400000)
};
},
downloadUrl (qry) {
if (this.calendarDates) {
const url = new URL(`/api/project/${this.$route.params.project}/event`, document.location.href);
for (const key in qry) {
url.searchParams.set(key, qry[key]);
}
url.searchParams.set("date0", this.calendarDates.start);
url.searchParams.set("date1", this.calendarDates.end);
return url.toString();
}
},
...mapActions(["api"])
@@ -182,9 +392,7 @@ export default {
async mounted () {
await this.getEvents();
if (this.events.length) {
this.setLast();
}
this.setLast();
}
}

View File

@@ -11,6 +11,7 @@
label="Filter"
single-line
clearable
hint="Filter by line number, first or last shotpoint or remarks. Use incr or + / decr or - to show only incrementing / decrementing lines"
></v-text-field>
</v-toolbar>
</v-card-title>
@@ -106,12 +107,14 @@
<v-data-table
:headers="headers"
:items="items"
item-key="line"
:items-per-page.sync="itemsPerPage"
:server-items-length="lineCount"
item-key="line"
:search="filter"
:loading="loading"
:fixed-header="true"
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
:loading="linesLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
:item-class="itemClass"
:show-select="selectOn"
v-model="selectedRows"
@@ -124,6 +127,10 @@
:preplot="item"
:sequences="sequences.filter(s => s.line == item.line)"
:sequence-href="(s) => `/projects/${$route.params.project}/log/sequence/${s.sequence}`"
:planned-sequences="plannedSequences.filter(s => s.line == item.line)"
:planned-sequence-href="() => `/projects/${$route.params.project}/plan`"
:pending-reshoots="null"
:pending-reshoot-href="null"
>
<template v-slot:empty>
<div v-if="!item.ntba" class="sequence" title="Virgin"></div>
@@ -161,7 +168,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="linesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -251,9 +258,10 @@ export default {
items: [],
selectOn: false,
selectedRows: [],
filter: null,
num_lines: null,
sequences: [],
filter: "",
options: {},
lineCount: null,
//sequences: [],
activeItem: null,
edit: null, // {line, key, value}
queuedReload: false,
@@ -273,11 +281,22 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
},
watch: {
options: {
handler () {
this.fetchLines();
},
deep: true
},
async lines () {
await this.fetchLines();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.line == oldVal.line);
@@ -296,39 +315,9 @@ export default {
}
},
async serverEvent (event) {
if (event.payload.pid == this.$route.params.project) {
if (event.channel == "preplot_lines" || event.channel == "preplot_points") {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getLines();
} else {
this.queuedReload = true;
}
} else if ([ "planned_lines", "raw_lines", "final_lines" ].includes(event.channel)) {
if (!this.loading && !this.queuedReload) {
this.getSequences();
} else {
this.queuedReload = true;
}
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getLines();
this.getSequences();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getLines();
this.getSequences();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchLines();
}
},
@@ -468,43 +457,28 @@ export default {
}
},
async getNumLines () {
const projectInfo = await this.api([`/project/${this.$route.params.project}`]);
this.num_lines = projectInfo.lines;
},
async getLines () {
const url = `/project/${this.$route.params.project}/line`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
},
async getSequences () {
const urlS = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([urlS]) || [];
const urlP = `/project/${this.$route.params.project}/plan`;
const planned = await this.api([urlP]) || [];
planned.forEach(i => i.status = "planned");
this.sequences.push(...planned);
},
setActiveItem (item) {
this.activeItem = this.activeItem == item
? null
: item;
},
...mapActions(["api"])
async fetchLines (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getLines([this.$route.params.project, options]);
this.items = res.lines;
this.lineCount = res.count;
},
...mapActions(["api", "getLines"])
},
mounted () {
this.getLines();
this.getNumLines();
this.getSequences();
this.fetchLines();
// Initialise stylesheet
const el = document.createElement("style");

View File

@@ -33,6 +33,17 @@
</span>
</v-toolbar-title>
<a v-if="$route.params.sequence"
class="mr-3"
:href="`/projects/${$route.params.project}/sequences/${$route.params.sequence}`"
title="View the shotlog for this sequence"
>
<v-icon
right
color="teal"
>mdi-format-list-numbered</v-icon>
</a>
<dougal-event-edit v-if="writeaccess"
v-model="eventDialog"
v-bind="editedEvent"
@@ -86,6 +97,49 @@
>PDF</v-list-item>
</v-list>
</v-menu>
<v-menu v-else>
<template v-slot:activator="{on, attrs}">
<v-btn class="ml-5" small v-on="on" v-bind="attrs">
<span class="d-none d-lg-inline">Download as</span>
<v-icon right small>mdi-cloud-download</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fvnd.seis%2Bjson&filename=event-log-multiseis.json`"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fgeo%2Bjson&filename=event-log.geojson`"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fjson&filename=event-log.json`"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fyaml&filename=event-log.yaml`"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fcsv&sortBy=tstamp&sortDesc=false&filename=event-log.csv`"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fhtml&filename=event-log.html`"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fpdf&filename=event-log.pdf`"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-text-field
@@ -93,7 +147,21 @@
append-icon="mdi-magnify"
label="Filter"
single-line
hide-details></v-text-field>
clearable
hide-details>
<template v-slot:prepend-inner>
<v-chip v-if="labelSearch"
class="mr-1"
small
close
@click:close="labelSearch=null"
:color="labels[labelSearch] && labels[labelSearch].view.colour"
:title="labels[labelSearch] && labels[labelSearch].view.description"
:dark="labels[labelSearch] && labels[labelSearch].view.dark"
:light="labels[labelSearch] && labels[labelSearch].view.light"
>{{labelSearch}}</v-chip>
</template>
</v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
@@ -215,13 +283,14 @@
:headers="headers"
:items="rows"
:items-per-page.sync="itemsPerPage"
:server-items-length="eventCount"
item-key="key"
:item-class="itemClass"
sort-by="tstamp"
:sort-desc="true"
:search="filter"
:custom-filter="searchTable"
:loading="loading"
:loading="eventsLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
@click:row="setActiveItem"
@@ -249,12 +318,12 @@
:dark="labels[label] && labels[label].view.dark"
:light="labels[label] && labels[label].view.light"
:key="label"
:href="$route.path+'?label='+encodeURIComponent(label)"
@click="labelSearch=label"
>{{label}}</v-chip>
</span>
<dougal-event-edit-history v-if="entry.has_edits && writeaccess"
:id="entry.id"
:disabled="loading"
:disabled="eventsLoading"
:labels="labels"
></dougal-event-edit-history>
<span v-if="entry.meta.readonly"
@@ -385,7 +454,6 @@ export default {
}
],
items: [],
labels: {},
options: {},
filter: "",
filterableLabels: [ "QC", "QCAccepted" ],
@@ -394,7 +462,6 @@ export default {
eventDialog: false,
eventLabelsDialog: false,
defaultEventTimestamp: null,
presetRemarks: null,
remarksMenu: null,
remarksMenuItem: null,
editedEvent: {},
@@ -444,17 +511,6 @@ export default {
return Object.values(rows);
},
userLabels () {
const filtered = {};
for (const key in this.labels) {
if (this.labels[key].model.user) {
filtered[key] = this.labels[key];
}
}
return filtered;
},
popularLabels () {
const tuples = this.items.flatMap( i => i.labels )
.filter( l => (this.labels[l]??{})?.model?.user )
@@ -466,6 +522,10 @@ export default {
.sort( (a, b) => b[1]-a[1] );
},
presetRemarks () {
return this.projectConfiguration?.events?.presetRemarks ?? [];
},
defaultSequence () {
if (this.$route.params.sequence) {
return Number(this.$route.params.sequence.split(";").pop());
@@ -474,19 +534,24 @@ export default {
}
},
...mapGetters(['user', 'writeaccess', 'loading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent']),
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
options: {
handler () {
//this.getEvents();
async handler () {
await this.fetchEvents();
},
deep: true
},
async events () {
console.log("Events changed");
await this.fetchEvents();
},
eventDialog (val) {
if (val) {
// If not online
@@ -494,30 +559,14 @@ export default {
}
},
async serverEvent (event) {
if (event.channel == "event" && event.payload.schema == this.projectSchema) {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getEvents();
} else {
this.queuedReload = true;
}
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchEvents();
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getEvents();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getEvents();
}
labelSearch () {
this.fetchEvents();
},
itemsPerPage (newVal, oldVal) {
@@ -574,50 +623,15 @@ export default {
}
},
async getEventCount () {
//this.eventCount = await this.api([`/project/${this.$route.params.project}/event/?count`]);
this.eventCount = null;
},
async getEvents (opts = {}) {
const query = new URLSearchParams(this.options);
if (this.options.itemsPerPage < 0) {
query.delete("itemsPerPage");
}
if (this.$route.params.sequence) {
query.set("sequence", this.$route.params.sequence);
}
if (this.$route.params.date0) {
query.set("date0", this.$route.params.date0);
}
if (this.$route.params.date1) {
query.set("date1", this.$route.params.date1);
}
const url = `/project/${this.$route.params.project}/event?${query.toString()}`;
this.queuedReload = false;
this.items = await this.api([url, opts]) || [];
},
async getLabelDefinitions () {
const url = `/project/${this.$route.params.project}/label`;
//const labelSet = {};
this.labels = await this.api([url]) ?? {};
//labels.forEach( l => labelSet[l.name] = l.data );
//this.labels = labelSet;
},
async getPresetRemarks () {
const url = `/project/${this.$route.params.project}/configuration`;
this.presetRemarks = (await this.api([url]))?.events?.presetRemarks ?? {};
async fetchEvents (opts = {}) {
const options = {
text: this.filter,
label: this.labelSearch,
...this.options
};
const res = await this.getEvents([this.$route.params.project, options]);
this.items = res.events;
this.eventCount = res.count;
},
newItem (from = {}) {
@@ -648,7 +662,6 @@ export default {
this.editedEvent.longitude = template.meta.geometry.coordinates[0];
this.editedEvent.latitude = template.meta.geometry.coordinates[1];
}
},
/** Add a brand new event.
@@ -691,7 +704,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -709,7 +722,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -756,7 +769,7 @@ export default {
if (!err && res.ok) {
this.showSnack([`${ids.length} events deleted`, "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -772,7 +785,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event deleted", "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -806,19 +819,6 @@ export default {
},
searchTable (value, search, item) {
if (!value && !search) return true;
const s = search.toLowerCase();
if (typeof value === 'string') {
return value.toLowerCase().includes(s);
} else if (typeof value === 'number') {
return value == search;
} else {
return item.items.some( i => i.remarks.toLowerCase().includes(s) ) ||
item.items.some( i => i.labels.some( l => l.toLowerCase().includes(s) ));
}
},
viewOnMap(item) {
if (item?.meta && item.meta?.geometry?.type == "Point") {
const [ lon, lat ] = item.meta.geometry.coordinates;
@@ -857,14 +857,11 @@ export default {
*/
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "refreshEvents", "getEvents"])
},
async mounted () {
await this.getLabelDefinitions();
this.getEventCount();
this.getEvents();
this.getPresetRemarks();
this.fetchEvents();
window.addEventListener('keyup', this.handleKeyboardEvent);
},

View File

@@ -44,6 +44,7 @@
label="Filter"
single-line
clearable
hint="Filter by sequence, line, first or last shotpoints, remarks or start/end time"
></v-text-field>
</v-toolbar>
</v-card-title>
@@ -109,11 +110,14 @@
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="sequenceCount"
item-key="sequence"
:search="filter"
:loading="loading"
:fixed-header="true"
:loading="plannedSequencesLoading"
fixed-header
no-data-text="No planned lines. Add lines via the context menu from either the Lines or Sequences view."
:item-class="(item) => (activeItem == item && !edit) ? 'blue accent-1 elevation-3' : ''"
:footer-props="{showFirstLastPage: true}"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
@@ -275,7 +279,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="plannedSequencesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -417,7 +421,8 @@ export default {
remarks: null,
editRemarks: false,
filter: null,
num_lines: null,
options: {},
sequenceCount: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
@@ -552,11 +557,22 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
},
watch: {
options: {
handler () {
this.fetchPlannedSequences();
},
deep: true
},
async plannedSequences () {
await this.fetchPlannedSequences();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.sequence == oldVal.sequence);
@@ -587,41 +603,9 @@ export default {
}
},
async serverEvent (event) {
if (event.channel == "planned_lines" && event.payload.pid == this.$route.params.project) {
// Ignore non-ops
/*
if (event.payload.old === null && event.payload.new === null) {
return;
}
*/
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getPlannedLines();
} else {
this.queuedReload = true;
}
} else if (event.channel == "info" && event.payload.pid == this.$route.params.project) {
if (event.payload?.new?.key == "plan" && ("remarks" in (event.payload?.new?.value || {}))) {
this.remarks = event.payload?.new.value.remarks;
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getPlannedLines();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getPlannedLines();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchPlannedSequences();
}
},
@@ -890,7 +874,6 @@ export default {
const url = `/project/${this.$route.params.project}/plan/${this.contextMenuItem.sequence}`;
const init = {method: "DELETE"};
await this.api([url, init]);
await this.getPlannedLines();
},
editItem (item, key, value) {
@@ -942,21 +925,6 @@ export default {
}
},
async getPlannedLines () {
const url = `/project/${this.$route.params.project}/plan`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
for (const item of this.items) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
this.wxQuery(item).then( (wx) => {
item.meta = {...item.meta, wx};
});
}
},
async getPlannerConfig () {
const url = `/project/${this.$route.params.project}/configuration/planner`;
this.plannerConfig = await this.api([url]) || {
@@ -967,14 +935,15 @@ export default {
}
},
async getPlannerRemarks () {
const url = `/project/${this.$route.params.project}/info/plan/remarks`;
this.remarks = await this.api([url]) || "";
},
async getSequences () {
const url = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([url]) || [];
async fetchPlannedSequences (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getPlannedSequences([this.$route.params.project, options]);
this.items = res.sequences;
this.sequenceCount = res.count;
this.remarks = this.planRemarks;
},
setActiveItem (item) {
@@ -983,13 +952,12 @@ export default {
: item;
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "getPlannedSequences"])
},
async mounted () {
await this.getPlannerConfig();
this.getPlannedLines();
this.getPlannerRemarks();
await this.fetchPlannedSequences();
}
}

View File

@@ -37,7 +37,7 @@ export default {
return this.loading || this.projectId;
},
...mapGetters(["loading", "projectId", "serverEvent"])
...mapGetters(["loading", "projectId", "projectSchema", "serverEvent"])
},
watch: {
@@ -45,16 +45,39 @@ export default {
if (event.channel == "project" && event.payload?.operation == "DELETE" && event.payload?.schema == "public") {
// Project potentially deleted
await this.getProject(this.$route.params.project);
} else if (event.payload?.schema == this.projectSchema) {
if (event.channel == "event") {
this.refreshEvents();
} else if (event.channel == "planned_lines") {
this.refreshPlan();
} else if (["raw_lines", "final_lines", "final_shots"].includes(event.channel)) {
this.refreshSequences();
} else if (["preplot_lines", "preplot_points"].includes(event.channel)) {
this.refreshLines();
} else if (event.channel == "info") {
if ((event.payload?.new ?? event.payload?.old)?.key == "plan") {
this.refreshPlan();
}
} else if (event.channel == "project") {
this.getProject(this.$route.params.project);
}
}
}
},
methods: {
...mapActions(["getProject"])
...mapActions(["getProject", "refreshLines", "refreshSequences", "refreshEvents", "refreshLabels", "refreshPlan"])
},
async mounted () {
await this.getProject(this.$route.params.project);
if (this.projectFound) {
this.refreshLines();
this.refreshSequences();
this.refreshEvents();
this.refreshLabels();
this.refreshPlan();
}
}
}

View File

@@ -1,14 +1,25 @@
<template>
<v-container fluid>
<v-data-table
:headers="headers"
:items="items"
:items="displayItems"
:options.sync="options"
:loading="loading"
>
<template v-slot:item.pid="{item, value}">
<a :href="`/projects/${item.pid}`">{{value}}</a>
<template v-if="item.archived">
<a class="secondary--text" title="This project has been archived" :href="`/projects/${item.pid}`">{{value}}</a>
<v-icon
class="ml-1 secondary--text"
small
title="This project has been archived"
>mdi-archive-outline</v-icon>
</template>
<template v-else>
<a :href="`/projects/${item.pid}`">{{value}}</a>
</template>
</template>
<template v-slot:item.shots="{item}">
@@ -27,6 +38,19 @@
/>
</template>
<template v-slot:footer.prepend>
<v-checkbox
class="mr-3"
v-model="showArchived"
dense
hide-details
title="Projects that have been marked as archived by an administrator no longer receive updates from external sources, such as the project's file repository or the navigation system, but they may still be interacted with via Dougal, including adding or editing log comments."
>
<template v-slot:label>
<span class="subtitle-2">Show archived projects</span>
</template>
</v-checkbox>
</template>
</v-data-table>
@@ -79,11 +103,21 @@ export default {
],
items: [],
options: {},
showArchived: true,
}
},
computed: {
...mapGetters(['loading', 'serverEvent'])
displayItems () {
return this.showArchived
? this.items
: this.items.filter(i => !i.archived);
},
...mapGetters(['loading', 'serverEvent', 'adminaccess', 'projects'])
},
watch: {

View File

@@ -148,15 +148,16 @@
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="sequenceCount"
item-key="sequence"
:server-items-length="num_rows"
:search="filter"
:custom-filter="customFilter"
:loading="loading"
:fixed-header="true"
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
show-expand
: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"
>
@@ -176,7 +177,7 @@
icon
small
title="Cancel edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit.value = item.remarks; edit = null"
>
<v-icon small>mdi-close</v-icon>
@@ -185,7 +186,7 @@
icon
small
title="Save edits"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
@@ -196,7 +197,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -210,7 +211,7 @@
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
@@ -228,7 +229,7 @@
icon
small
title="Cancel edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit.value = item.remarks_final; edit = null"
>
<v-icon small>mdi-close</v-icon>
@@ -237,7 +238,7 @@
icon
small
title="Save edits"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
@@ -248,7 +249,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks_final')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -262,7 +263,7 @@
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
@@ -331,9 +332,31 @@
</template>
<template v-slot:item.sequence="{value}">
<a
:href="`/projects/${$route.params.project}/log/sequence/${value}`"
title="View the event log for this sequence">{{value}}</a>
<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}">
@@ -566,7 +589,7 @@ export default {
items: [],
filter: "",
options: {},
num_rows: null,
sequenceCount: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
@@ -593,17 +616,22 @@ export default {
return this.queuedItems.find(i => i.payload.sequence == this.contextMenuItem.sequence);
},
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'sequencesLoading', 'sequences'])
},
watch: {
options: {
handler () {
this.getSequences();
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);
@@ -617,39 +645,9 @@ export default {
}
},
async serverEvent (event) {
const subscriptions = ["raw_lines", "final_lines", "final_shots"];
if (subscriptions.includes(event.channel) && event.payload.pid == this.$route.params.project) {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getSequences();
} else {
this.queuedReload = true;
}
} else if (event.channel == "queue_items") {
const project =
event.payload?.project ??
event.payload?.new?.payload?.project ??
event.payload?.old?.payload?.project;
if (project == this.$route.params.project) {
this.getQueuedItems();
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getSequences();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getSequences();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchSequences();
}
},
@@ -818,19 +816,14 @@ export default {
this.num_rows = projectInfo.sequences;
},
async getSequences () {
const query = new URLSearchParams(this.options);
query.set("filter", this.filter);
query.set("files", true);
if (this.options.itemsPerPage < 0) {
query.delete("itemsPerPage");
}
const url = `/project/${this.$route.params.project}/sequence?${query.toString()}`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
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 () {
@@ -878,11 +871,11 @@ export default {
return false;
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "getSequences"])
},
mounted () {
this.getSequences();
this.fetchSequences();
this.getNumLines();
this.getQueuedItems();
}

View File

@@ -1,13 +1,631 @@
<template>
<div>
SequenceSummary
</div>
<v-container fluid>
<v-card>
<v-card-title>
<v-progress-linear indeterminate v-if="loading"></v-progress-linear>
<v-toolbar flat>
<v-toolbar-title>
Sequence {{sequenceNumber}}
<small :class="statusColour" v-if="sequence">({{sequence.status}})</small>
</v-toolbar-title>
<a v-if="$route.params.sequence"
:href="`/projects/${$route.params.project}/log/sequence/${$route.params.sequence}`"
title="View the event log for this sequence"
>
<v-icon
right
color="blue"
>mdi-format-list-bulleted-type</v-icon>
</a>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter shots"
single-line
clearable
hint="Filter by point, line, time, etc."
></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col cols="6" class="pa-0">
<v-card outlined tile height="100%" class="flex-grow-1">
<v-card-subtitle>
Acquisition remarks
</v-card-subtitle>
<v-card-text v-html="$options.filters.markdown(remarks)">
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" class="pa-0">
<v-card outlined tile height="100%" class="flex-grow-1">
<v-card-subtitle>
Processing remarks
</v-card-subtitle>
<v-card-text v-html="$options.filters.markdown(remarks_final)">
</v-card-text>
</v-card>
</v-col>
</v-row>
<template v-if="sequence">
<v-row>
<v-col v-for="(col, idx) in infoColumns" :key="idx" cols="3">
<b :title="col.title">{{col.text}}:</b> {{ col.value }}
</v-col>
</v-row>
</template>
<v-row>
<v-col cols=12 md=6 lg=4>
<div style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
gun-data-format="smsrc"
facet="2dhist"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
<v-col cols=12 md=6 lg=4>
<div style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
gun-data-format="smsrc"
facet="crossline"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
<v-col cols=12 md=6 lg=4>
<div ref="" style="height:300px;">
<dougal-graph-project-sequence-shotpoint-timing
:items="shots"
facet="area"
>
</dougal-graph-project-sequence-shotpoint-timing>
</div>
</v-col>
<v-col cols=12 md=6 lg=4 >
<div ref="" style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
facet="c-o"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
</v-row>
</v-container>
<v-card outlined tile class="flex-grow-1">
<v-card-subtitle>
Shot log
</v-card-subtitle>
<v-card-text>
<v-data-table
:headers="shotlogHeaders"
:items="shots"
item-key="point"
:search="filter"
:custom-sort="customSorter"
fixed-header
show-expand
@click:row="onRowClicked"
>
<template v-slot:item.position="{item}">
<a v-if="position(item).latitude"
:href="`/projects/${$route.params.project}/map#15/${position(item).longitude}/${position(item).latitude}`"
title="View on map"
:target="`/projects/${$route.params.project}/map`"
@click.stop
>
<small>{{ format_position(position(item)) }}</small>
</a>
</template>
<template v-slot:item.ε_inline="{item}">
{{ ε(item).inline }}
</template>
<template v-slot:item.ε_crossline="{item}">
{{ ε(item).crossline }}
</template>
<template v-slot:item.co_inline="{item}">
{{ co(item).inline }}
</template>
<template v-slot:item.co_crossline="{item}">
{{ co(item).crossline }}
</template>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length" class="pa-0">
<v-card flat>
<v-card-subtitle>Gun data</v-card-subtitle>
<v-card-text v-if="item.meta.raw.smsrc">
<v-container>
<v-row>
<v-col>
Source fired: {{ gun_data(item).src_number }}
</v-col>
<v-col>
Subarrays: {{ gun_data(item).num_subarray }}
</v-col>
<v-col>
Total guns: {{ gun_data(item).num_guns }}
</v-col>
<v-col>
Active guns: {{ gun_data(item).num_active }}
</v-col>
<v-col>
Autofires: {{ gun_data(item).num_auto }}
</v-col>
<v-col>
No fires: {{ gun_data(item).num_nofire }}
</v-col>
</v-row>
<v-row>
<v-col>
Average delta: {{ gun_data(item).avg_delta }} ±{{ gun_data(item).std_delta }} ms
</v-col>
<v-col>
Delta errors: {{ gun_data(item).num_delta }}
</v-col>
<v-col>
Source volume: {{ gun_data(item).volume }} in³
</v-col>
<v-col>
Manifold pressure: {{ gun_data(item).manifold }} psi
</v-col>
<v-col>
Mask: {{ gun_data(item).mask }}
</v-col>
<v-col>
Trigger mode: {{ gun_data(item).trg_mode }}
</v-col>
</v-row>
<v-row>
<v-col cols=12>
<v-expansion-panels>
<v-expansion-panel>
<v-expansion-panel-header>
Individual gun data
</v-expansion-panel-header>
<v-expansion-panel-content>
<v-simple-table>
<template v-slot:default>
<thead>
<th>String</th>
<th>Gun</th>
<th>Source</th>
<th>Mode</th>
<th>Detect</th>
<th>Autofire</th>
<th>Aimpoint</th>
<th>Fire time</th>
<th>Delay</th>
<th>Depth</th>
<th>Pressure</th>
<th>Volume</th>
<th>Filltime</th>
</thead>
<tbody>
<tr v-for="(gun_values, idx_gun) in gun_data(item).guns"
:key="idx_gun"
>
<td v-for="(val, idx) in gun_values" v-if="idx != 6"
:key="idx"
:class="typeof val === 'number' ? 'text-right' : 'text-left'"
>{{ gun_value_formatter(val, idx) }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-text v-else>
No gun data available {{ item }}
</v-card-text>
</v-card>
</td>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalGraphProjectSequenceInlineCrossline from '@/components/graphs/project/sequence/inline-crossline';
import DougalGraphProjectSequenceShotpointTiming from '@/components/graphs/project/sequence/shotpoint-timing';
export default {
name: "SequenceSummary"
name: "SequenceSummary",
components: {
DougalGraphProjectSequenceInlineCrossline,
DougalGraphProjectSequenceShotpointTiming
},
data () {
return {
filter: null,
loading: false,
shotlogHeaders: [
{
value: "point",
text: "Point",
sort: this.sorterFor("point", this.number_sort)
},
{
value: "sailline",
text: "Sail line",
sort: this.sorterFor("sailline", this.number_sort)
},
{
value: "line",
text: "Source line",
sort: this.sorterFor("line", this.number_sort)
},
{
value: "tstamp",
text: "Timestamp",
sort: this.sorterFor("tstamp", this.string_sort)
},
{
value: "position",
text: "φ, λ",
sort: this.sorterFor(["geometryfinal", "geometryraw", "geometrypreplot"],
this.sorterFor("coordinates",
this.sequentialSort([0, 1], this.number_sort)))
},
{
value: "ε_inline",
text: "ε inline",
align: "end",
sort: this.sorterFor(["errorfinal", "errorraw"],
this.sorterFor("coordinates",
this.sorterFor(1, this.number_sort)))
},
{
value: "ε_crossline",
text: "ε crossline",
align: "end",
sort: this.sorterFor(["errorfinal", "errorraw"],
this.sorterFor("coordinates",
this.sorterFor(0, this.number_sort)))
},
{
value: "co_inline",
text: "c-o inline",
align: "end",
sort: (a, b) => this.number_sort(this.co(a).inline, this.co(b).inline)
},
{
value: "co_crossline",
text: "c-o crossline",
align: "end",
sort: (a, b) => this.number_sort(this.co(a).crossline, this.co(b).crossline)
},
{
value: 'data-table-expand'
},
],
shots: []
};
},
computed: {
sequenceNumber () {
return this.$route.params.sequence;
},
sequence () {
return this.sequences.find(i => i.sequence == this.sequenceNumber);
},
remarks () {
return this.sequence?.remarks || "Nil.";
},
remarks_final () {
return this.sequence?.remarks_final || "Nil.";
},
statusColour () {
switch (this.sequence?.status) {
case "final":
return "green--text";
case "raw":
return "orange--text";
case "ntbp":
return "red--text";
default:
return "";
}
},
infoColumns () {
return [
{
text: "FSP",
title: "First shotpoint",
value: this.sequence.fsp ?? "n/a"
},
{
text: "FPSP",
title: "First prime shotpoint",
value: this.sequence.fsp_final ?? "n/a"
},
{
text: "LPSP",
title: "Last prime shotpoint",
value: this.sequence.lsp_final ?? "n/a"
},
{
text: "LSP",
title: "Last shotpoint",
value: this.sequence.lsp ?? "n/a"
},
{
text: "Start time",
value: this.sequence.ts0,
},
{
text: "FPSP time",
title: "First prime shotpoint time",
value: this.sequence.ts0_final ?? "n/a",
},
{
text: "LPSP time",
title: "Last prime shotpoint time",
value: this.sequence.ts1_final ?? "n/a",
},
{
text: "End time",
value: this.sequence.ts1,
},
{
text: "Length",
value: this.sequence.length.toFixed(0)+" m"
},
{
text: "Azimuth",
value: this.sequence.azimuth.toFixed(4).padStart(8, "0")+"°"
},
{
text: "Shots acquired",
value: this.sequence.num_points
},
{
text: "Shots missed",
value: this.sequence.missing_shots
},
{
text: "Total duration",
value: this.format_duration(this.sequence.duration)
},
{
text: "Prime duration",
value: this.format_duration(this.sequence.duration_final) ?? "n/a"
}
];
},
...mapGetters(["sequencesLoading", "sequences"])
},
watch: {
sequencesLoading () {
this.loading == this.loading || this.sequencesLoading;
}
},
methods: {
/** Apply fn to a[keys], b[keys]
*
* If keys is an array, use the first
* attribute that exists in each of a
* and b (may not be the same attribute)
*/
sorterFor (keys, fn) {
if (Array.isArray(keys)) {
return (a, b) => fn(a[keys.find(k => k in a)], b[keys.find(k => k in b)]);
} else {
return (a, b) => fn(a[keys], b[keys]);
}
},
/** Apply fn to each member of keys
* until the comparison returns non-zero.
*/
sequentialSort (keys, fn) {
return (a, b) => {
for (const key of keys) {
const res = fn(a[key], b[key]);
if (res != 0) {
return res;
}
}
return 0;
}
},
number_sort (a, b) {
return a - b;
},
string_sort (a, b) {
return a > b
? 1
: a < b
? -1
: 0;
},
customSorter (items, sortBy, sortDesc) {
/** Get the sorter for a given column from shotlogHeaders
*/
const getFieldSorter = (key) => {
return this.shotlogHeaders.find(i => i.value == key)?.sort;
}
/** Conditionally invert a comparator
*/
function inverter (fn, desc) {
return desc
? (a, b) => fn(a, b) * -1
: fn;
}
for (const idx in sortBy) {
const sortKey = sortBy[idx];
const desc = sortDesc[idx];
const fn = inverter(getFieldSorter(sortKey), desc);
items.sort(fn);
}
return items;
},
onRowClicked (data, row) {
row.expand(!row.isExpanded);
},
async getSequence (seq = this.sequence) {
// NOTE:
// We are intentionally not using Vuex here, given that at this time
// no other component (except Graphs?) this endpoint's data.
// NB: Graphs does use this endpoint but there doesn't seem to be
// any point in keeping the data in memory anyway in case the user
// decides to navigate from the shotlog to the graphs or vice-versa.
try {
this.loading = true;
const url = `/project/${this.$route.params.project}/sequence/${this.$route.params.sequence}`;
this.shots = await this.api([url])
} catch {
} finally {
this.loading = false;
}
},
format_duration (duration) {
if (duration) {
let t = ["hours", "minutes", "seconds"].map(k =>
(duration[k] ?? 0).toFixed(0).padStart(2, "0")).join(":");
if (duration.days) {
t = duration.days+" d "+t;
}
return t;
}
},
format_position (position) {
return `${position.latitude.toFixed(6).padStart(9, "0")}, ${position.longitude.toFixed(6).padStart(10, "0")}`;
},
position (item) {
const coords = (item.geometryfinal ?? item.geometryraw)?.coordinates;
if (coords) {
return {
longitude: coords[0],
latitude: coords[1]
};
}
return {};
},
ε (item) {
const coords = (item.errorfinal ?? item.errorraw)?.coordinates;
if (coords) {
return {
crossline: coords[0]?.toFixed(2),
inline: coords[1]?.toFixed(2)
}
}
return {};
},
co (item) {
const raw = item.errorraw?.coordinates;
const final = item.errorfinal?.coordinates;
if (raw && final) {
return {
crossline: (final[0] - raw[0]).toFixed(2),
inline: (final[1] - raw[1]).toFixed(2),
}
} else {
return {
crossline: "n/a",
inline: "n/a"
}
}
},
gun_data (item) {
return item?.meta?.raw?.smsrc ?? {};
},
gun_value_formatter (value, index) {
const ms = (v) => v.toFixed(2)+" ms";
const transformers = [];
transformers[7] = ms;
transformers[8] = ms;
transformers[9] = ms;
transformers[10] = (v) => v.toFixed(1)+" m";
transformers[11] = (v) => v+" psi";
transformers[12] = (v) => v+" in³";
transformers[13] = ms;
const transformer = transformers[index];
return transformer ? transformer(value) : value;
},
...mapActions(["api"])
},
mounted () {
this.getSequence();
}
}
</script>

View File

@@ -7,10 +7,10 @@ module.exports = {
host: "0.0.0.0",
proxy: {
"^/api(/|$)": {
target: "http://localhost:3000",
target: "http://127.0.0.1:3000",
},
"^/ws(/|$)": {
target: "ws://localhost:3000",
target: "ws://127.0.0.1:3000",
ws: true
}
}

View File

@@ -181,6 +181,9 @@ app.map({
post: [ mw.auth.access.write, mw.event.post ],
put: [ mw.auth.access.write, mw.event.put ],
delete: [ mw.auth.access.write, mw.event.delete ],
'changes/:since': {
get: [ mw.event.changes ]
},
// TODO Rename -/:sequence → sequence/:sequence
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
get: [ mw.event.sequence.get ],
@@ -200,25 +203,25 @@ app.map({
'/project/:project/qc': {
'/results': {
// Get all QC results for :project
get: [ mw.qc.results.get ],
get: [ mw.etag.noSave, mw.qc.results.get ],
// Delete all QC results for :project
delete: [ mw.auth.access.write, mw.qc.results.delete ],
delete: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.delete ],
'/accept': {
post: [ mw.auth.access.write, mw.qc.results.accept ]
post: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.accept ]
},
'/unaccept': {
post: [ mw.auth.access.write, mw.qc.results.unaccept ]
post: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.unaccept ]
},
'/sequence/:sequence': {
// Get QC results for :project, :sequence
get: [ mw.qc.results.get ],
get: [ mw.etag.noSave, mw.qc.results.get ],
// Delete QC results for :project, :sequence
delete: [ mw.auth.access.write, mw.qc.results.delete ]
delete: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.delete ]
}
}
},

View File

@@ -33,7 +33,7 @@ function saveResponse (res) {
const cache = getCache(res);
const req = res.req;
console.log(`Saving ETag: ${req.method} ${req.url}${etag}`);
const headers = res.getHeaders();
const headers = structuredClone(res.getHeaders());
delete headers["set-cookie"];
cache[req.url] = {etag, headers};
}

View File

@@ -43,15 +43,26 @@ const rels = [
matches: [ ],
callback (url, data) {
if (data.payload?.table == "info") {
const pid = data.payload?.pid;
const key = (data.payload?.new ?? data.payload?.old)?.key;
const rx = /^\/project\/([^\/]+)\/info\/([^\/?]+)[\/?]?/;
const match = url.match(rx);
if (match) {
if (match[1] == data.payload.pid) {
if (match[1] == pid) {
if (match[2] == data.payload?.old?.key || match[2] == data.payload?.new?.key) {
return true;
}
}
}
if (key == "plan") {
const rx = /^\/project\/([^\/]+)\/plan[\/?]?/;
const match = url.match(rx);
if (match) {
return match[1] == pid;
}
}
}
return false;
}

View File

@@ -0,0 +1,14 @@
const { event } = require('../../../lib/db');
const json = async function (req, res, next) {
try {
const response = await event.changes(req.params.project, req.params.since, req.query);
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
};
module.exports = json;

View File

@@ -6,5 +6,6 @@ module.exports = {
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
delete: require('./delete')
delete: require('./delete'),
changes: require('./changes')
}

View File

@@ -0,0 +1,83 @@
const { stringify } = require('csv');
const { event } = require('../../../../lib/db');
const { ALERT, ERROR, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
const csv = async function (req, res, next) {
try {
const events = await event.list(req.params.project, req.query);
if ("download" in req.query || "d" in req.query) {
const extension = "csv";
const tstamp = (new Date()).toISOString();
const filename = `${req.params.project}-event-log-${tstamp}.${extension}`;
res.set("Content-Disposition", `attachment; filename="${filename}"`);
}
const columns = {
id: "id",
unix_epoch: (row) => Math.floor(row.tstamp/1000),
timestamp: (row) => (new Date(row.tstamp)).toISOString(),
sequence: "sequence",
point: "point",
text: "remarks",
labels: (row) => row.labels.join(";"),
latitude: (row) => {
if (row.meta.geometry?.type == "Point" && row.meta.geometry?.coordinates) {
return row.meta.geometry.coordinates[1];
}
},
longitude: (row) => {
if (row.meta.geometry?.type == "Point" && row.meta.geometry?.coordinates) {
return row.meta.geometry.coordinates[0];
}
}
};
let fields = [ "timestamp", "sequence", "point", "text", "labels", "latitude", "longitude", "id" ];
if (req.query.fields) {
fields = req.query.fields.split(/[,;:.\s+*|]+/);
}
let delimiter = req.query.delimiter ?? ",";
const stringifier = stringify({delimiter});
stringifier.on('error', (err) => {
ERROR(err);
});
stringifier.on('readable', () => {
while((row = stringifier.read()) !== null) {
res.write(row);
}
});
stringifier.on('end', () => {
DEBUG("Transform complete");
res.end();
next();
});
res.status(200);
if (!req.query.header || req.query.header.toLowerCase() == "true" || req.query.header == "1") {
// Send header
stringifier.write(fields);
}
events.forEach( event => {
stringifier.write(fields.map( field => {
if (typeof columns[field] === "function") {
return columns[field](event);
} else {
return event[columns[field]];
}
}));
});
stringifier.end();
} catch (err) {
next(err);
}
};
module.exports = csv;

View File

@@ -1,19 +1,26 @@
const json = require('./json');
const yaml = require('./yaml');
const geojson = require('./geojson');
const seis = require('./seis');
const csv = require('./csv');
const setContentDisposition = require('../../../../lib/utils/setContentDisposition');
module.exports = async function (req, res, next) {
try {
const handlers = {
"application/json": json,
"application/yaml": yaml,
"application/geo+json": geojson,
"application/vnd.seis+json": seis
"application/vnd.seis+json": seis,
"text/csv": csv
};
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
if (mimetype) {
res.set("Content-Type", mimetype);
setContentDisposition(req, res);
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();

View File

@@ -0,0 +1,14 @@
const YAML = require('yaml');
const { event } = require('../../../../lib/db');
const yaml = async function (req, res, next) {
try {
const response = await event.list(req.params.project, req.query);
res.status(200).send(YAML.stringify(response));
next();
} catch (err) {
next(err);
}
};
module.exports = yaml;

View File

@@ -1,9 +1,14 @@
const { plan } = require('../../../../lib/db');
const { plan, info } = require('../../../../lib/db');
const json = async function (req, res, next) {
try {
const response = await plan.list(req.params.project, req.query);
const sequences = await plan.list(req.params.project, req.query) ?? [];
const remarks = await info.get(req.params.project, "plan/remarks", req.query, req.user.role) ?? null;
const response = {
remarks,
sequences
};
res.status(200).send(response);
next();
} catch (err) {

View File

@@ -1,13 +1,61 @@
const YAML = require('yaml');
const { stringify } = require('csv-stringify/sync');
const { project } = require('../../../../lib/db');
const { flatEntries } = require('../../../../lib/utils/flatEntries');
function json (req, res, next) {
if (res.locals.payload) {
res.status(200).send(res.locals.payload);
} else {
res.status(404).send({message: "Not found"});
}
next();
}
function yaml (req, res, next) {
if (res.locals.payload) {
res.status(200).send(YAML.stringify(res.locals.payload));
} else {
res.status(404).send({message: "Not found"});
}
next();
}
function csv (req, res, next) {
if (res.locals.payload) {
const fields = [ "key", "value" ];
const delimiter = req.query.delimiter ?? ",";
const text = stringify([fields, ...flatEntries(res.locals.payload)], {delimiter});
res.status(200).send(text);
} else {
res.status(404).send({message: "Not found"});
}
next();
}
module.exports = async function (req, res, next) {
try {
res.status(200).send(await project.configuration.get(req.params.project));
next();
const handlers = {
"application/json": json,
"application/yaml": yaml,
"text/csv": csv
};
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
if (mimetype) {
res.locals.payload = await project.configuration.get(req.params.project);
res.set("Content-Type", mimetype);
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();
next();
}
} catch (err) {
next(err);
}
};
}

View File

@@ -9,105 +9,16 @@ const { ALERT, ERROR, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
* the last shot and first shot of the previous and current dates, respectively.
*/
class DetectFDSP {
/* Data may come much faster than we can process it, so we put it
* in a queue and process it at our own pace.
*
* The run() method fills the queue with the necessary data and then
* calls processQueue().
*
* The processQueue() method looks at the first two elements in
* the queue and processes them if they are not already being taken
* care of by a previous processQueue() call this will happen when
* data is coming in faster than it can be processed.
*
* If the processQueue() call is the first to see the two bottommost
* two elements, it will process them and, when finished, it will set
* the `isPending` flag of the bottommost element to `false`, thus
* letting the next call know that it has work to do.
*
* If the queue was empty, run() will set the `isPending` flag of its
* first element to a falsy value, thus bootstrapping the process.
*/
static MAX_QUEUE_SIZE = 125000;
queue = [];
author = `*${this.constructor.name}*`;
prev = null;
async processQueue () {
DEBUG("Queue length", this.queue.length)
while (this.queue.length > 1) {
if (this.queue[0].isPending) {
setImmediate(() => this.processQueue());
return;
}
const prev = this.queue.shift();
const cur = this.queue[0];
const sequence = Number(cur._sequence);
try {
if (prev.lineName == cur.lineName && prev._sequence == cur._sequence &&
prev.lineStatus == "online" && cur.lineStatus == "online" && sequence) {
// DEBUG("Previous", prev);
// DEBUG("Current", cur);
if (prev.time.substr(0, 10) != cur.time.substr(0, 10)) {
// Possible a date change, but could also be a missing timestamp
// or something else.
const ts0 = new Date(prev.time)
const ts1 = new Date(cur.time);
if (!isNaN(ts0) && !isNaN(ts1) && ts0.getUTCDay() != ts1.getUTCDay()) {
INFO("Sequence shot across midnight UTC detected", cur._sequence, cur.lineName);
const ldsp = {
sequence: prev._sequence,
point: prev._point,
remarks: "Last shotpoint of the day",
labels: ["LDSP", "Prod"],
meta: {auto: true, insertedBy: this.constructor.name}
};
const fdsp = {
sequence: cur._sequence,
point: cur._point,
remarks: "First shotpoint of the day",
labels: ["FDSP", "Prod"],
meta: {auto: true, insertedBy: this.constructor.name}
};
INFO("LDSP", ldsp);
INFO("FDSP", fdsp);
const projectId = await schema2pid(prev._schema);
if (projectId) {
await event.post(projectId, ldsp);
await event.post(projectId, fdsp);
} else {
ERROR("projectId not found for", prev._schema);
}
} else {
WARNING("False positive on these timestamps", prev.time, cur.time);
WARNING("No events were created");
}
}
}
// Processing of this shot has already been completed.
// The queue can now move forward.
} catch (err) {
ERROR(err);
} finally {
cur.isPending = false;
}
}
constructor () {
DEBUG(`${this.author} instantiated`);
}
async run (data) {
async run (data, ctx) {
if (!data || data.channel !== "realtime") {
return;
}
@@ -116,27 +27,70 @@ class DetectFDSP {
return;
}
const meta = data.payload.new.meta;
if (this.queue.length < DetectFDSP.MAX_QUEUE_SIZE) {
const event = {
isPending: this.queue.length,
_schema: meta._schema,
time: meta.time,
lineStatus: meta.lineStatus,
_sequence: meta._sequence,
_point: meta._point,
lineName: meta.lineName
};
this.queue.push(event);
// DEBUG("EVENT", event);
} else {
ALERT("Queue full at", this.queue.length);
if (!this.prev) {
DEBUG("Initialising `prev`");
this.prev = data;
return;
}
this.processQueue();
try {
DEBUG("Running");
const cur = data;
const sequence = Number(cur._sequence);
if (this.prev.lineName == cur.lineName && this.prev._sequence == cur._sequence &&
this.prev.lineStatus == "online" && cur.lineStatus == "online" && sequence) {
if (this.prev.time.substr(0, 10) != cur.time.substr(0, 10)) {
// Possibly a date change, but could also be a missing timestamp
// or something else.
const ts0 = new Date(this.prev.time)
const ts1 = new Date(cur.time);
if (!isNaN(ts0) && !isNaN(ts1) && ts0.getUTCDay() != ts1.getUTCDay()) {
INFO("Sequence shot across midnight UTC detected", cur._sequence, cur.lineName);
const ldsp = {
sequence: this.prev._sequence,
point: this.prev._point,
remarks: "Last shotpoint of the day",
labels: ["LDSP", "Prod"],
meta: {auto: true, author: `*${this.constructor.name}*`}
};
const fdsp = {
sequence: cur._sequence,
point: cur._point,
remarks: "First shotpoint of the day",
labels: ["FDSP", "Prod"],
meta: {auto: true, author: `*${this.constructor.name}*`}
};
INFO("LDSP", ldsp);
INFO("FDSP", fdsp);
const projectId = await schema2pid(this.prev._schema);
if (projectId) {
await event.post(projectId, ldsp);
await event.post(projectId, fdsp);
} else {
ERROR("projectId not found for", this.prev._schema);
}
} else {
WARNING("False positive on these timestamps", this.prev.time, cur.time);
WARNING("No events were created");
}
}
}
} catch (err) {
DEBUG(`${this.author} error`, err);
throw err;
} finally {
this.prev = data;
}
}
}

View File

@@ -0,0 +1,60 @@
const project = require('../../lib/db/project');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
class DetectProjectConfigurationChange {
author = `*${this.constructor.name}*`;
constructor (ctx) {
DEBUG(`${this.author} instantiated`);
// Grab project configurations.
// NOTE that this will run asynchronously
this.run({channel: "project"}, ctx);
}
async run (data, ctx) {
if (!data || data.channel !== "project") {
return;
}
// Project notifications, as of this writing, most likely
// do not carry payloads as those exceed the notification
// size limit.
// For our purposes, we do not care as we just re-read all
// the configurations for all non-archived projects.
try {
DEBUG("Project configuration change detected")
const projects = await project.get();
const _ctx_data = {};
for (let pid of projects.map(i => i.pid)) {
DEBUG("Retrieving configuration for", pid);
const cfg = await project.configuration.get(pid);
if (cfg?.archived === true) {
DEBUG(pid, "is archived. Ignoring");
continue;
}
DEBUG("Saving configuration for", pid);
_ctx_data[pid] = cfg;
}
if (! ("projects" in ctx)) {
ctx.projects = {};
}
ctx.projects.configuration = _ctx_data;
DEBUG("Committed project configuration to ctx.projects.configuration");
} catch (err) {
DEBUG(`${this.author} error`, err);
throw err;
}
}
}
module.exports = DetectProjectConfigurationChange;

View File

@@ -0,0 +1,80 @@
const { schema2pid } = require('../../lib/db/connection');
const { event } = require('../../lib/db');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
class DetectSoftStart {
author = `*${this.constructor.name}*`;
prev = null;
constructor () {
DEBUG(`${this.author} instantiated`);
}
async run (data, ctx) {
if (!data || data.channel !== "realtime") {
return;
}
if (!(data.payload && data.payload.new && data.payload.new.meta)) {
return;
}
if (!this.prev) {
DEBUG("Initialising `prev`");
this.prev = data;
return;
}
try {
DEBUG("Running");
const cur = data?.payload?.new?.meta;
const prev = this.prev?.payload?.new?.meta;
// DEBUG("%j", prev);
// DEBUG("%j", cur);
DEBUG("cur.num_guns: %d\ncur.num_active: %d\nprv.num_active: %d\ntest passed: %j", cur.num_guns, cur.num_active, prev.num_active, cur.num_active >= 1 && !prev.num_active && cur.num_active < cur.num_guns);
if (cur.num_active >= 1 && !prev.num_active && cur.num_active < cur.num_guns) {
INFO("Soft start detected @", cur.tstamp);
// FIXME Shouldn't need to use schema2pid as pid already present in payload.
const projectId = await schema2pid(cur._schema ?? prev._schema);
// TODO: Try and grab the corresponding comment from the configuration?
const payload = {
tstamp: cur.tstamp,
remarks: "Soft start",
labels: [ "Daily", "Guns", "Prod" ],
meta: {auto: true, author: `*${this.constructor.name}*`}
};
DEBUG("Posting event", projectId, payload);
await event.post(projectId, payload);
} else if (cur.num_active == cur.num_guns && prev.num_active < cur.num_active) {
INFO("Full volume detected @", cur.tstamp);
const projectId = await schema2pid(cur._schema ?? prev._schema);
// TODO: Try and grab the corresponding comment from the configuration?
const payload = {
tstamp: cur.tstamp,
remarks: "Full volume",
labels: [ "Daily", "Guns", "Prod" ],
meta: {auto: true, author: `*${this.constructor.name}*`}
};
DEBUG("Posting event", projectId, payload);
await event.post(projectId, payload);
}
} catch (err) {
DEBUG(`${this.author} error`, err);
throw err;
} finally {
this.prev = data;
}
}
}
module.exports = DetectSoftStart;

View File

@@ -1,114 +1,17 @@
const { schema2pid } = require('../../lib/db/connection');
const { event } = require('../../lib/db');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
class DetectSOLEOL {
/* Data may come much faster than we can process it, so we put it
* in a queue and process it at our own pace.
*
* The run() method fills the queue with the necessary data and then
* calls processQueue().
*
* The processQueue() method looks takes the first two elements in
* the queue and processes them if they are not already being taken
* care of by a previous processQueue() call this will happen when
* data is coming in faster than it can be processed.
*
* If the processQueue() call is the first to see the two bottommost
* two elements, it will process them and, when finished, it will set
* the `isPending` flag of the bottommost element to `false`, thus
* letting the next call know that it has work to do.
*
* If the queue was empty, run() will set the `isPending` flag of its
* first element to a falsy value, thus bootstrapping the process.
*/
static MAX_QUEUE_SIZE = 125000;
queue = [];
author = `*${this.constructor.name}*`;
prev = null;
async processQueue () {
while (this.queue.length > 1) {
if (this.queue[0].isPending) {
setImmediate(() => this.processQueue());
return;
}
const prev = this.queue.shift();
const cur = this.queue[0];
const sequence = Number(cur._sequence);
try {
if (prev.lineName == cur.lineName && prev._sequence == cur._sequence &&
prev.lineStatus != "online" && cur.lineStatus == "online" && sequence) {
// console.log("TRANSITION TO ONLINE", prev, cur);
// Check if there are already FSP, FGSP events for this sequence
const projectId = await schema2pid(cur._schema);
const sequenceEvents = await event.list(projectId, {sequence});
const labels = ["FSP", "FGSP"].filter(l => !sequenceEvents.find(i => i.labels.includes(l)));
if (labels.includes("FSP")) {
// At this point labels contains either FSP only or FSP + FGSP,
// depending on whether a FGSP event has already been entered.
const remarks = `SEQ ${cur._sequence}, SOL ${cur.lineName}, BSP: ${(cur.speed*3.6/1.852).toFixed(1)} kt, Water depth: ${Number(cur.waterDepth).toFixed(0)} m.`;
const payload = {
type: "sequence",
sequence,
point: cur._point,
remarks,
labels
}
// console.log(projectId, payload);
await event.post(projectId, payload);
} else {
// A first shot point has been already entered in the log,
// so we have nothing to do here.
}
} else if (prev.lineStatus == "online" && cur.lineStatus != "online") {
// console.log("TRANSITION TO OFFLINE", prev, cur);
// Check if there are already LSP, LGSP events for this sequence
const projectId = await schema2pid(prev._schema);
const sequenceEvents = await event.list(projectId, {sequence});
const labels = ["LSP", "LGSP"].filter(l => !sequenceEvents.find(i => i.labels.includes(l)));
if (labels.includes("LSP")) {
// At this point labels contains either LSP only or LSP + LGSP,
// depending on whether a LGSP event has already been entered.
const remarks = `SEQ ${prev._sequence}, EOL ${prev.lineName}, BSP: ${(prev.speed*3.6/1.852).toFixed(1)} kt, Water depth: ${Number(prev.waterDepth).toFixed(0)} m.`;
const payload = {
type: "sequence",
sequence,
point: prev._point,
remarks,
labels
}
// console.log(projectId, payload);
await event.post(projectId, payload);
} else {
// A first shot point has been already entered in the log,
// so we have nothing to do here.
}
}
// Processing of this shot has already been completed.
// The queue can now move forward.
} catch (err) {
console.error("DetectSOLEOL Error")
console.log(err);
} finally {
cur.isPending = false;
}
}
constructor () {
DEBUG(`${this.author} instantiated`);
}
async run (data) {
async run (data, ctx) {
if (!data || data.channel !== "realtime") {
return;
}
@@ -117,30 +20,69 @@ class DetectSOLEOL {
return;
}
const meta = data.payload.new.meta;
if (this.queue.length < DetectSOLEOL.MAX_QUEUE_SIZE) {
this.queue.push({
isPending: this.queue.length,
_schema: meta._schema,
time: meta.time,
shot: meta.shot,
lineStatus: meta.lineStatus,
_sequence: meta._sequence,
_point: meta._point,
lineName: meta.lineName,
speed: meta.speed,
waterDepth: meta.waterDepth
});
} else {
// FIXME Change to alert
console.error("DetectSOLEOL queue full at", this.queue.length);
if (!this.prev) {
DEBUG("Initialising `prev`");
this.prev = data;
return;
}
this.processQueue();
try {
DEBUG("Running");
// DEBUG("%j", data);
const cur = data?.payload?.new?.meta;
const prev = this.prev?.payload?.new?.meta;
const sequence = Number(cur._sequence);
// DEBUG("%j", prev);
// DEBUG("%j", cur);
DEBUG("prv.lineName: %s\ncur.lineName: %s\nprv._sequence: %s\ncur._sequence: %s\nprv.lineStatus: %s\ncur.lineStatus: %s", prev.lineName, cur.lineName, prev._sequence, cur._sequence, prev.lineStatus, cur.lineStatus);
if (prev.lineName == cur.lineName && prev._sequence == cur._sequence &&
prev.lineStatus != "online" && cur.lineStatus == "online" && sequence) {
INFO("Transition to ONLINE detected");
// We must use schema2pid because the pid may not have been
// populated for this event.
const projectId = await schema2pid(cur._schema ?? prev._schema);
const labels = ["FSP", "FGSP"];
const remarks = `SEQ ${cur._sequence}, SOL ${cur.lineName}, BSP: ${(cur.speed*3.6/1.852).toFixed(1)} kt, Water depth: ${Number(cur.waterDepth).toFixed(0)} m.`;
const payload = {
type: "sequence",
sequence,
point: cur._point,
remarks,
labels,
meta: {auto: true, author: `*${this.constructor.name}*`}
}
INFO("Posting event", projectId, payload);
await event.post(projectId, payload);
} else if (prev.lineName == cur.lineName && prev._sequence == cur._sequence &&
prev.lineStatus == "online" && cur.lineStatus != "online" && sequence) {
INFO("Transition to OFFLINE detected");
const projectId = await schema2pid(prev._schema ?? cur._schema);
const labels = ["LSP", "LGSP"];
const remarks = `SEQ ${cur._sequence}, EOL ${cur.lineName}, BSP: ${(cur.speed*3.6/1.852).toFixed(1)} kt, Water depth: ${Number(cur.waterDepth).toFixed(0)} m.`;
const payload = {
type: "sequence",
sequence,
point: cur._point,
remarks,
labels,
meta: {auto: true, author: `*${this.constructor.name}*`}
}
INFO("Posting event", projectId, payload);
await event.post(projectId, payload);
}
} catch (err) {
DEBUG(`${this.author} error`, err);
throw err;
} finally {
this.prev = data;
}
}
}
module.exports = DetectSOLEOL;

View File

@@ -1,13 +1,44 @@
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
const Handlers = [
require('./detect-project-configuration-change'),
require('./detect-soleol'),
require('./detect-soft-start'),
require('./report-line-change-time'),
require('./detect-fdsp')
];
function init () {
return Handlers.map(Handler => new Handler());
function init (ctx) {
const instances = Handlers.map(Handler => new Handler(ctx));
function prepare (data, ctx) {
const promises = [];
for (let instance of instances) {
const promise = new Promise(async (resolve, reject) => {
try {
DEBUG("Run", instance.author);
const result = await instance.run(data, ctx);
DEBUG("%s result: %O", instance.author, result);
resolve(result);
} catch (err) {
ERROR("%s error:\n%O", instance.author, err);
reject(err);
}
});
promises.push(promise);
}
return promises;
}
function despatch (data, ctx) {
return Promise.allSettled(prepare(data, ctx));
}
return { instances, prepare, despatch };
}
module.exports = {
Handlers,
init
}
};

View File

@@ -0,0 +1,231 @@
const { event, project } = require('../../lib/db');
const { withinValidity } = require('../../lib/utils/ranges');
const unique = require('../../lib/utils/unique');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
class ReportLineChangeTime {
author = `*${this.constructor.name}*`;
constructor () {
DEBUG(`${this.author} instantiated`);
}
async run (data, ctx) {
if (!data || data.channel !== "event") {
return;
}
const n = data.payload.new;
const o = data.payload.old;
if (!(n?.labels) && !(o?.labels)) {
return;
}
if (!n?.labels?.includes("FGSP") && !o?.labels?.includes("FGSP") &&
!n?.labels?.includes("LGSP") && !o?.labels?.includes("LGSP")) {
return;
}
try {
DEBUG("Running");
const cur = data;
const projectId = cur?.payload?.pid;
const forward = (cur?.payload?.old?.labels?.includes("LGSP") || cur?.payload?.new?.labels?.includes("LGSP"));
DEBUG("%j", cur);
if (!projectId) {
throw {message: "No projectID found in event", cur};
return;
}
async function getLineChangeTime (data, forward = false) {
if (forward) {
const ospEvents = await event.list(projectId, {label: "FGSP"});
// DEBUG("ospEvents", ospEvents);
const osp = ospEvents.filter(i => i.tstamp > data.tstamp).pop();
DEBUG("fsp", osp);
// DEBUG("data", data);
if (osp) {
DEBUG("lineChangeTime", osp.tstamp - data.tstamp);
return { lineChangeTime: osp.tstamp - data.tstamp, osp };
}
} else {
const ospEvents = await event.list(projectId, {label: "LGSP"});
// DEBUG("ospEvents", ospEvents);
const osp = ospEvents.filter(i => i.tstamp < data.tstamp).shift();
DEBUG("lsp", osp);
// DEBUG("data", data);
if (osp) {
DEBUG("lineChangeTime", data.tstamp - osp.tstamp);
return { lineChangeTime: data.tstamp - osp.tstamp, osp };
}
}
}
function parseInterval (dt) {
const daySeconds = (dt/1000) % 86400;
const d = Math.floor((dt/1000) / 86400);
const dateObject = new Date(null);
dateObject.setSeconds(daySeconds);
const [ h, m, s ] = dateObject.toISOString().slice(11, 19).split(":").map(Number);
return {d, h, m, s};
}
function formatInterval (i) {
let str = "";
for (let [k, v] of Object.entries(i)) {
if (v) {
str += " " + v + " " + k;
}
}
return str.trim();
}
const deleteStaleEvents = async (seq) => {
if (seq) {
DEBUG("Will delete lct events related to sequence(s)", seq);
const jpq = `$."${this.author}"`;
const opts = {jpq};
if (Array.isArray(seq)) {
opts.sequences = unique(seq).filter(i => !!i);
} else {
opts.sequence = seq;
}
const staleEvents = await event.list(projectId, opts);
DEBUG(staleEvents.length ?? 0, "events to delete");
for (let staleEvent of staleEvents) {
DEBUG(`Deleting event id ${staleEvent.id} (seq = ${staleEvent.sequence}, point = ${staleEvent.point})`);
await event.del(projectId, staleEvent.id);
}
}
}
const createLineChangeTimeEvents = async (lineChangeTime, data, osp) => {
const events = [];
const cfg = ctx?.projects?.configuration?.[projectId] ?? {};
const nlcd = cfg?.production?.nominalLineChangeDuration * 60*1000; // m → ms
DEBUG("nlcd", nlcd);
if (nlcd && lineChangeTime > nlcd) {
const excess = lineChangeTime-nlcd;
const excessString = formatInterval(parseInterval(excess));
DEBUG("excess", excess, excessString);
// ref: The later of the two events
const ref = forward ? osp : data;
const payload = {
// tstamp: new Date(ref.tstamp-1),
sequence: ref.sequence,
point: ref.point,
remarks: `_Nominal line change duration exceeded by ${excessString}_`,
labels: [ "Nav", "Prod" ],
meta: {
auto: true,
author: this.author,
[this.author]: {
parents: [
data.id,
osp.id
],
type: "excess",
value: excess
}
}
}
events.push(payload);
DEBUG("Created line change duration exceeded event", projectId, payload);
}
const lctString = formatInterval(parseInterval(lineChangeTime));
// ref: The later of the two events
const ref = forward ? osp : data;
const payload = {
// tstamp: new Date(ref.tstamp-1),
sequence: ref.sequence,
point: ref.point,
remarks: `Line change time: ${lctString}`,
labels: [ "Nav", "Prod" ],
meta: {
auto: true,
author: this.author,
[this.author]: {
parents: [
data.id,
osp.id
],
type: "lineChangeTime",
value: lineChangeTime
}
}
};
events.push(payload);
DEBUG("Created line change duration event", projectId, payload);
return events;
}
const maybePostEvent = async (projectId, payload) => {
DEBUG("Posting event", projectId, payload);
await event.post(projectId, payload);
}
await deleteStaleEvents([cur.old?.sequence, cur.new?.sequence]);
if (cur?.payload?.operation == "INSERT") {
// NOTE: UPDATE on the event_log view translates to one UPDATE plus one INSERT
// on event_log_full, so we don't need to worry about UPDATE here.
const data = n;
DEBUG("INSERT seen: will add lct events related to ", data.id);
if (withinValidity(data.validity)) {
DEBUG("Event within validity period", data.validity, new Date());
data.tstamp = new Date(data.tstamp);
const { lineChangeTime, osp } = await getLineChangeTime(data, forward);
if (lineChangeTime) {
const events = await createLineChangeTimeEvents(lineChangeTime, data, osp);
if (events?.length) {
DEBUG("Deleting other events for sequence", events[0].sequence);
await deleteStaleEvents(events[0].sequence);
}
for (let payload of events) {
await maybePostEvent(projectId, payload);
}
}
} else {
DEBUG("Event outside of validity range", data.validity, "lct events not inserted");
}
}
} catch (err) {
ERROR(`${this.author} error`, err);
throw err;
}
}
}
module.exports = ReportLineChangeTime;

View File

@@ -1,23 +1,25 @@
const { listen } = require('../ws/db');
const { listen } = require('../lib/db/notify');
const channels = require('../lib/db/channels');
const handlers = require('./handlers').init();
const handlers = require('./handlers');
const { ActionsQueue } = require('../lib/queue');
const { ERROR, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
function start () {
listen(channels, async function (data) {
const queue = new ActionsQueue();
const ctx = {}; // Context object
const { prepare, despatch } = handlers.init(ctx);
listen(channels, function (data) {
DEBUG("Incoming data", data);
for (const handler of handlers) {
// NOTE: We are intentionally passing the same instance
// of the data to every handler. This means that earlier
// handlers could, in principle, modify the data to be
// consumed by latter ones, provided that they are
// synchronous (as otherwise, the completion order is
// undefined).
await handler.run(data);
}
// We don't bother awaiting
queue.enqueue(() => despatch(data, ctx));
DEBUG("Queue size", queue.length());
});
INFO("Events manager started.", handlers.length, "active handlers");
INFO("Events manager started");
}
module.exports = { start }

View File

@@ -8,23 +8,55 @@ async function main () {
INFO("Running version", await version.describe());
version.compatible()
.then( (versions) => {
const api = require('./api');
const ws = require('./ws');
try {
const api = require('./api');
const ws = require('./ws');
const periodicTasks = require('./periodic-tasks').init();
const { fork } = require('child_process');
const { fork } = require('child_process');
const port = process.env.HTTP_PORT || 3000;
const host = process.env.HTTP_HOST || "127.0.0.1";
const path = process.env.HTTP_PATH ?? "/api";
const server = api.start(port, host, path);
ws.start(server);
const port = process.env.HTTP_PORT || 3000;
const host = process.env.HTTP_HOST || "127.0.0.1";
const path = process.env.HTTP_PATH ?? "/api";
const server = api.start(port, host, path);
ws.start(server);
const eventManagerPath = [__dirname, "events"].join("/");
const eventManager = fork(eventManagerPath, /*{ stdio: 'ignore' }*/);
INFO("Versions:", versions);
INFO("Versions:", versions);
periodicTasks.start();
process.on('exit', () => eventManager.kill());
const eventManagerPath = [__dirname, "events"].join("/");
const eventManager = fork(eventManagerPath, /*{ stdio: 'ignore' }*/);
process.on("SIGINT", async () => {
DEBUG("Interrupted (SIGINT)");
eventManager.kill()
await periodicTasks.cleanup();
process.exit(0);
})
process.on("SIGHUP", async () => {
DEBUG("Stopping (SIGHUP)");
eventManager.kill()
await periodicTasks.cleanup();
process.exit(0);
})
process.on('beforeExit', async () => {
DEBUG("Preparing to exit");
eventManager.kill()
await periodicTasks.cleanup();
});
process.on('exit', async () => {
DEBUG("Exiting");
// eventManager.kill()
// periodicTasks.cleanup();
});
} catch (err) {
ERROR(err);
process.exit(2);
}
})
.catch( ({current, wanted, component}) => {
console.error(`Fatal error: incompatible ${component} version ${current} (wanted: ${wanted})`);

View File

@@ -0,0 +1,61 @@
const { setSurvey } = require('../connection');
const { replaceMarkers } = require('../../utils');
function parseValidity (row) {
if (row.validity) {
const rx = /^(.)("([\d :.+-]+)")?,("([\d :.+-]+)")?([\]\)])$/;
const m = row.validity.match(rx);
row.validity = [ m[1], m[3], m[5], m[6] ];
}
return row;
}
function transform (row) {
if (row.validity[2]) {
return {
uid: row.uid,
id: row.id,
is_deleted: true
}
} else {
row.is_deleted = false;
row.has_edits = row.id != row.uid;
row.modified_on = row.validity[1];
delete row.uid;
delete row.validity;
return row;
}
}
function unique (rows) {
const o = {};
rows.forEach(row => o[row.id] = row);
return Object.values(o);
}
/**
* Get the event change history from a given epoch (ts0),
* for all events.
*/
async function changes (projectId, ts0, opts = {}) {
if (!projectId || !ts0) {
throw {status: 400, message: "Invalid request" };
return;
}
const client = await setSurvey(projectId);
const text = `
SELECT *
FROM event_log_changes($1);
`;
const res = await client.query(text, [ts0]);
client.release();
return opts.unique
? unique(res.rows.map(i => transform(replaceMarkers(parseValidity(i)))))
: res.rows.map(i => transform(replaceMarkers(parseValidity(i))));
}
module.exports = changes;

View File

@@ -5,5 +5,6 @@ module.exports = {
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
del: require('./delete')
del: require('./delete'),
changes: require('./changes')
}

View File

@@ -10,25 +10,33 @@ async function list (projectId, opts = {}) {
const offset = Math.abs((opts.page-1)*opts.itemsPerPage) || 0;
const limit = Math.abs(Number(opts.itemsPerPage)) || null;
const filter = opts.sequence
? String(opts.sequence).includes(";")
? [ "sequence = ANY ( $1 )", [ opts.sequence.split(";") ] ]
: [ "sequence = $1", [ opts.sequence ] ]
: opts.date0
? opts.date1
? [ "date(tstamp) BETWEEN SYMMETRIC $1 AND $2", [ opts.date0, opts.date1 ] ]
: [ "date(tstamp) = $1", [ opts.date0 ] ]
: [ "true = true", [] ];
const sequence = opts.sequence && Number(opts.sequence) || null;
const sequences = opts.sequences && (Array.isArray(opts.sequences)
? opts.sequences.map(Number)
: opts.sequences.split(/[^0-9]+/).map(Number)) || null;
const date0 = opts.date0 ?? null;
const date1 = opts.date1 ?? null;
const jpq = opts.jpq || null; // jpq: JSONPath Query
const label = opts.label ?? null;
const text = `
SELECT *
FROM event_log e
WHERE
${filter[0]}
ORDER BY ${sortKey} ${sortDir};
($1::numeric IS NULL OR sequence = $1) AND
($2::numeric[] IS NULL OR sequence = ANY( $2 )) AND
($3::timestamptz IS NULL OR
(($4::timestamptz IS NULL AND date(tstamp) = $3) OR
date(tstamp) BETWEEN SYMMETRIC $3 AND $4)) AND
($5::jsonpath IS NULL OR jsonb_path_exists(meta::jsonb, $5::jsonpath)) AND
($6::text IS NULL OR $6 = ANY(labels))
ORDER BY ${sortKey} ${sortDir}
LIMIT ${limit};
`;
const res = await client.query(text, filter[1]);
const values = [ sequence, sequences, date0, date1, jpq, label ];
const res = await client.query(text, values);
client.release();
return res.rows.map(i => replaceMarkers(i));
}

View File

@@ -9,10 +9,10 @@ async function post (projectId, payload, opts = {}) {
const text = `
INSERT
INTO event_log (tstamp, sequence, point, remarks, labels)
VALUES ($1, $2, $3, replace_placeholders($4, $1, $2, $3), $5);
INTO event_log (tstamp, sequence, point, remarks, labels, meta)
VALUES ($1, $2, $3, replace_placeholders($4, $1, $2, $3), $5, $6);
`;
const values = [ p.tstamp, p.sequence, p.point, p.remarks, p.labels ];
const values = [ p.tstamp, p.sequence, p.point, p.remarks, p.labels, p.meta ];
DEBUG("Inserting new values: %O", values);
await client.query(text, values);

View File

@@ -1,21 +1,43 @@
// FIXME This code is in painful need of refactoring
const { DEBUG } = require("DOUGAL_ROOT/debug")(__filename);
const { setSurvey, transaction, pool } = require('../connection');
const { listen } = require('../notify');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
let last_tstamp = 0;
let project_configs, listener;
async function getAllProjectConfigs () {
const client = await pool.connect();
const res0 = await client.query("SELECT schema FROM projects;");
const text = res0.rows.map(r => {
return `SELECT '${r.schema}' AS schema, data FROM ${r.schema}.file_data WHERE (data->>'archived')::boolean IS NOT true AND data->>'id' IS NOT NULL`;
}).join("\nUNION ALL ");
async function getFromDatabase () {
DEBUG("Getting project configurations");
const client = await pool.connect();
const res1 = await client.query(text);
client.release();
return res1.rows.map(r => Object.assign(r.data, {schema: r.schema}));
try {
const text = `
SELECT schema, meta AS data
FROM projects
WHERE (meta->>'archived')::boolean IS NOT true;
`;
const res = await client.query(text);
project_configs = res.rows;
DEBUG("Have configurations for projects", project_configs.map(i => i.data.id));
} catch (err) {
ERROR(err);
} finally {
client.release();
}
return project_configs;
}
if (project_configs) {
return project_configs;
} else {
listener = await listen(["project"], getFromDatabase);
DEBUG("Added project configuration change listener");
return await getFromDatabase();
}
}
async function getNearestPreplot (candidates) {
@@ -74,9 +96,9 @@ async function getNearestOfflinePreplot (candidates) {
if ("latitude" in candidates[0] && "longitude" in candidates[0]) {
text = `
SELECT
'${c._schema}' AS _schema,
'${c.schema}' AS schema,
ST_Distance(ST_Transform(ST_SetSRID(ST_MakePoint($1, $2), 4326), ST_SRID(geometry)), geometry) AS distance
FROM ${c._schema}.preplot_points
FROM ${c.schema}.preplot_points
ORDER BY distance ASC
LIMIT 1;
`;
@@ -84,9 +106,9 @@ async function getNearestOfflinePreplot (candidates) {
} else if ("easting" in candidates[0] && "northing" in candidates[0]) {
text = `
SELECT
'${c._schema}' AS _schema,
'${c.schema}' AS schema,
ST_Distance(ST_SetSRID(ST_MakePoint($1, $2), ST_SRID(geometry)), geometry) AS distance
FROM ${c._schema}.preplot_points
FROM ${c.schema}.preplot_points
ORDER BY distance ASC
LIMIT 1;
`;
@@ -102,13 +124,13 @@ async function getNearestOfflinePreplot (candidates) {
const results = [];
for (const qry of queries) {
const res = await client.query(qry.text, qry.values);
if (res.rows[0] && res.rows[0]._schema) {
if (res.rows[0] && res.rows[0].schema) {
results.push(res.rows[0]);
}
}
client.release();
const _schema = results.sort( (a, b) => a.distance - b.distance).shift()?._schema;
return candidates.find(c => c._schema == _schema);
const schema = results.sort( (a, b) => a.distance - b.distance).shift()?.schema;
return candidates.find(c => c.schema == schema);
}
async function saveOnline (dataset, opts = {}) {
@@ -141,14 +163,14 @@ async function saveOnline (dataset, opts = {}) {
await client.query(`
INSERT INTO raw_shots
(sequence, line, point, objref, tstamp, geometry, hash)
VALUES ($1, $2, $3, $4, $5, ST_SetSRID(ST_MakePoint($6, $7), (SELECT (data->>'epsg')::integer AS epsg FROM file_data WHERE data ? 'id')), '*online*')
VALUES ($1, $2, $3, $4, $5, ST_SetSRID(ST_MakePoint($6, $7), (select (project_configuration()->>'epsg')::integer as epsg)), '*online*')
ON CONFLICT DO NOTHING;
`, [item.sequence, item.line, item.point, 0, item.tstamp, item.easting, item.northing]);
} else if (item.latitude && item.longitude) {
await client.query(`
INSERT INTO raw_shots
(sequence, line, point, objref, tstamp, geometry, hash)
VALUES ($1, $2, $3, $4, $5, ST_Transform(ST_SetSRID(ST_MakePoint($6, $7), 4326), (SELECT (data->>'epsg')::integer AS epsg FROM file_data WHERE data ? 'id')), '*online*')
VALUES ($1, $2, $3, $4, $5, ST_Transform(ST_SetSRID(ST_MakePoint($6, $7), 4326), (select (project_configuration()->>'epsg')::integer as epsg)), '*online*')
ON CONFLICT DO NOTHING;
`, [item.sequence, item.line, item.point, 0, item.tstamp, item.longitude, item.latitude]);
} else {
@@ -158,8 +180,8 @@ async function saveOnline (dataset, opts = {}) {
}
await transaction.commit(client);
} catch (error) {
console.error("ONLINE DATA INSERT ERROR");
console.error(error);
ERROR("ONLINE DATA INSERT ERROR");
ERROR(error);
await transaction.rollback(client);
} finally {
client.release();
@@ -186,7 +208,7 @@ async function saveOffline (navData, opts = {}) {
} else if (schema && hasEastNorth) {
const text = `
INSERT INTO real_time_inputs (tstamp, geometry, meta)
VALUES ($1, ST_Transform(ST_SetSRID(ST_MakePoint($2, $3), (SELECT (data->>'epsg')::integer AS epsg FROM ${schema}.file_data)), 4326), $4);
VALUES ($1, ST_Transform(ST_SetSRID(ST_MakePoint($2, $3), (select (project_configuration()->>'epsg')::integer as epsg), 4326), $4);
`;
const values = [navData.tstamp, navData.longitude, navData.latitude, navData.payload];
@@ -215,6 +237,37 @@ async function saveOffline (navData, opts = {}) {
client.release();
}
async function getCandidates (navData) {
const configs = await getAllProjectConfigs();
// We just get the bits of interest: pattern and schema
const candidates = configs.map(c => {
if (!c?.data?.online?.line || c?.archived === true) {
return null;
}
const p = c.data.online.line.pattern; // For short
const rx = new RegExp(p.regex, p.flags);
const matches = navData.lineName.match(rx);
if (!matches || ((matches.length+1) < p.captures.length)) {
return null;
}
matches.shift(); // Get rid of the full matched text
const obj = Object.assign({}, navData, {schema: c.schema});
p.captures.forEach( (k, i) => {
obj[k] = matches[i];
});
return obj;
}).filter(c => !!c);
// DEBUG("Candidates: %j", candidates.map(c => c.schema));
return candidates;
}
async function save (navData, opts = {}) {
const hasLatLon = ("latitude" in navData && "longitude" in navData);
@@ -222,50 +275,27 @@ async function save (navData, opts = {}) {
const hasLinePoint = ("lineName" in navData && "point" in navData);
if (!(hasLinePoint || hasLatLon || hasEastNorth)) {
// This is of no interest to us
console.warning("Ignoring data without useful values", navData);
NOTICE("Ignoring data without useful values", navData);
return;
}
// DEBUG("navData", navData);
if (navData.online === true) {
// So we have a lineName, see which projects match the line pattern.
// For this we need to get all the project configs
const configs = await getAllProjectConfigs();
// We just get the bits of interest: pattern and schema
const candidates = configs.map(c => {
if (!(c && c.online && c.online.line)) {
return null;
}
const p = c.online.line.pattern; // For short
const rx = new RegExp(p.regex, p.flags);
const matches = navData.lineName.match(rx);
if (!matches || ((matches.length+1) < p.captures.length)) {
return null;
}
matches.shift(); // Get rid of the full matched text
const obj = Object.assign({}, navData, {schema: c.schema});
p.captures.forEach( (k, i) => {
obj[k] = matches[i];
});
return obj;
}).filter(c => !!c);
DEBUG("Candidates: %j", candidates);
// console.log("CANDIDATES", candidates);
const candidates = await getCandidates(navData);
if (candidates.length == 0) {
// This is probably a test line, so we treat it as offline
console.log("No match");
WARNING("No match");
} else {
if (candidates.length == 1) {
// Only one candidate, associate with it
// console.log("Save into schema", candidates[0].match.schema);
await saveOnline(candidates);
navData.payload._schema = candidates[0].match.schema;
navData.payload._schema = candidates[0].schema;
} else {
// More than one candidate, go for the closest. If more than one active
// project with the same preplots, highest numbered schema.
@@ -275,7 +305,7 @@ async function save (navData, opts = {}) {
await saveOnline(candidates.filter(c => c.schema == destinationSchema), opts);
navData.payload._schema = destinationSchema;
} else {
console.log("Nowhere to save to");
WARNING("Nowhere to save to");
}
}
@@ -286,17 +316,18 @@ async function save (navData, opts = {}) {
}
} else {
// We are offline. We only assign _schema once every save_interval seconds at most
// unless there is gun data present.
if (opts.offline_survey_heuristics == "nearest_preplot") {
const now = Date.now();
const do_save = !opts.offline_survey_detect_interval ||
(now - last_tstamp) >= opts.offline_survey_detect_interval;
if (do_save) {
if (do_save || "guns" in navData?.payload) {
const configs = await getAllProjectConfigs();
const candidates = configs.map(c => Object.assign({}, navData, {_schema: c.schema}));
const candidates = await getCandidates(navData);
const bestCandidate = await getNearestOfflinePreplot(candidates);
if (bestCandidate) {
navData.payload._schema = bestCandidate._schema;
navData.payload._schema = bestCandidate.schema;
last_tstamp = now;
}
}
@@ -304,6 +335,7 @@ async function save (navData, opts = {}) {
}
await saveOffline(navData, opts);
DEBUG("Saved");
}
module.exports = save;

View File

@@ -1,5 +1,43 @@
const { makeSubscriber } = require('./connection');
const { makeSubscriber, pool } = require('./connection');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
async function purge () {
DEBUG("Purging old notifications");
const client = await pool.connect();
try {
await client.query("CALL purge_notifications();");
} catch (err) {
ERROR(err);
} finally {
client.release();
}
}
async function fullPayload (payload) {
if (!payload.payload_id) {
return payload;
} else {
let client, res;
try {
client = await pool.connect();
const text = `SELECT payload FROM notify_payloads WHERE id = $1;`;
const values = [ payload.payload_id ];
res = await client.query(text, values);
res = res?.rows[0]?.payload;
DEBUG(`Oversize notification payload retrieved with id ${payload.payload_id} and size ${res.length}`);
// DEBUG(res);
res = JSON.parse(res);
} catch (err) {
ERROR(err);
} finally {
if (client) {
client.release();
}
}
return res;
}
}
async function listen (addChannels, callback) {
@@ -18,11 +56,11 @@ async function listen (addChannels, callback) {
for (const channel of addChannels) {
await client.listenTo(channel);
client.notifications.on(channel, (payload) => {
client.notifications.on(channel, async (payload) => {
const data = {
channel,
_received: new Date(),
payload
payload: await fullPayload(payload)
};
callback(data);
});
@@ -32,5 +70,6 @@ async function listen (addChannels, callback) {
}
module.exports = {
listen
listen,
purge
};

View File

@@ -36,6 +36,9 @@ async function patch (projectId, payload, opts = {}) {
}
}
// We do not allow users to change the schema
delete payload.schema;
const dest = removeNulls(deepMerge(source, payload));
await modify(projectId, dest);
return dest;

View File

@@ -7,10 +7,11 @@ const { INFO, DEBUG, WARNING, ERROR } = require('DOUGAL_ROOT/debug')(__filename)
function checkSyntax (value, type = "project") {
var requiredFields = {};
switch (type) {
case "project":
var requiredFields = {
requiredFields = {
id: "string",
name: "string",
epsg: "number",
@@ -18,7 +19,7 @@ function checkSyntax (value, type = "project") {
};
break;
case "binning":
var requiredFields = {
requiredFields = {
theta: "number",
I_inc: "number",
J_inc: "number",
@@ -28,23 +29,19 @@ function checkSyntax (value, type = "project") {
}
break
case "origin":
var requiredFields = {
requiredFields = {
easting: "number",
northing: "number",
I: "number",
J: "number"
}
break;
break;
default:
return typeof type == "function"
? type(value)
: typeof value == type;
}
// return Object.entries(requiredFields).every( ([field, test]) => {
// return value.hasOwnProperty(field) && checkSyntax(value[field], test);
// });
for (const [field, test] of Object.entries(requiredFields)) {
if (!value.hasOwnProperty(field)) {
return `Missing required property: ${field}`;

View File

@@ -1,7 +1,16 @@
const { setSurvey, pool } = require('../connection');
async function get () {
const res = await pool.query("SELECT pid, name, schema FROM public.projects;");
const text = `
SELECT
pid,
name,
schema,
COALESCE(meta->'groups', '[]'::jsonb) AS groups,
COALESCE(meta->'archived', 'false'::jsonb) AS archived
FROM public.projects;
`;
const res = await pool.query(text);
return res.rows;
}

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const YAML = require('yaml');
const flattenQCDefinitions = require('../../../utils/flattenQCDefinitions');
const { translatePath } = require('../../../utils/logicalPath');
const project = require('../../project'); // lib/db/project
@@ -8,7 +9,7 @@ async function get (projectId, opts = {}) {
const qcConfig = (await project.configuration.get(projectId))?.qc;
if (qcConfig?.definitions) {
try {
const definitions = YAML.parse(fs.readFileSync(qcConfig.definitions).toString());
const definitions = YAML.parse(fs.readFileSync(translatePath(qcConfig.definitions)).toString());
return opts.flat ? flattenQCDefinitions(definitions) : definitions;
} catch (err) {

View File

@@ -1,7 +1,7 @@
const fs = require('fs/promises');
const Path = require('path');
const mime = require('./mime-types');
const { translatePath, logicalRoot } = require('./logical');
const { translatePath, logicalRoot } = require('../utils/logicalPath');
const systemCfg = require('../config');
const projectCfg = require('../db/configuration');

View File

@@ -8,6 +8,7 @@ const { pool, setSurvey, transaction, fetchRow } = require('../db/connection')
const { project, sequence, configuration, info } = require('../db')
const flattenQCDefinitions = require('./flatten');
const { projectHash, sequenceHash } = require('./last-modified');
const { translatePath } = require('../utils/logicalPath');
const { runShotsQC, saveShotsQC } = require('./shots');
const { runSequenceQCs, saveSequenceQCs } = require('./sequences');
@@ -46,8 +47,8 @@ async function getProjectQCConfig (projectId) {
console.log("qcConfig", qcConfig);
if (qcConfig?.definitions && qcConfig?.parameters) {
const definitions =
flattenQCDefinitions(YAML.parse(fs.readFileSync(qcConfig.definitions).toString()));
const parameters = YAML.parse(fs.readFileSync(qcConfig.parameters).toString());
flattenQCDefinitions(YAML.parse(fs.readFileSync(translatePath(qcConfig.definitions)).toString()));
const parameters = YAML.parse(fs.readFileSync(translatePath(qcConfig.parameters)).toString());
return { definitions, parameters };
}

View File

@@ -0,0 +1,52 @@
const Queue = require('./queue');
// Inspired by:
// https://stackoverflow.com/questions/53540348/js-async-await-tasks-queue#53540586
class ActionsQueue extends Queue {
constructor (items = []) {
super(items);
this.pending = false;
}
enqueue (action) {
return new Promise ((resolve, reject) => {
super.enqueue({ action, resolve, reject });
this.dequeue();
});
}
async dequeue () {
if (this.pending) {
return false;
}
const item = super.dequeue();
if (!item) {
return false;
}
try {
this.pending = true;
const result = await item.action(this);
this.pending = false;
item.resolve(result);
} catch (err) {
this.pending = false;
item.reject(err);
} finally {
this.dequeue();
}
}
}
module.exports = ActionsQueue;

View File

@@ -0,0 +1,6 @@
module.exports = {
Queue: require('./queue'),
ActionsQueue: require('./actions-queue')
};

View File

@@ -0,0 +1,22 @@
class Queue {
constructor (items = []) {
this.items = items;
}
enqueue (item) {
this.items.push(item);
}
dequeue () {
return this.items.shift();
}
length () {
return this.items.length;
}
}
module.exports = Queue;

View File

@@ -0,0 +1,28 @@
/** Converts an object into a list of [ key, value ]
* pairs, like Object.entries(), except that it recurses
* into the object.
*
* Nested keys are joined with `sep` (a `.` by default).
*/
function* flatEntries (obj, sep=".", parents=[]) {
if (typeof obj == "object" && obj !== null) {
for (let key in obj) {
const val = obj[key];
yield* flatEntries(val, sep, [...parents, key]);
}
} else {
yield [[...parents].join(sep), obj];
}
}
/** Run the flatEntries() generator
*/
function entries (obj, sep) {
return [...flatEntries(obj, sep)];
}
module.exports = {
flatEntries,
entries
};

View File

@@ -5,5 +5,9 @@ module.exports = {
replaceMarkers: require('./replaceMarkers'),
flattenQCDefinitions: require('./flattenQCDefinitions'),
deepMerge: require('./deepMerge'),
removeNulls: require('./removeNulls')
removeNulls: require('./removeNulls'),
logicalPath: require('./logicalPath'),
ranges: require('./ranges'),
unique: require('./unique'),
setContentDisposition: require('./setContentDisposition')
};

View File

@@ -10,6 +10,7 @@ function translatePath (file) {
return physicalPath;
} else {
// An attempt to break out of the logical path?
console.warn("Attempting to break out of the logical path?", physicalPath, prefix);
throw {
status: 404,
message: "Not found"

View File

@@ -0,0 +1,74 @@
function parseRange (str) {
const rx = /^[\[(].*,.*[)\]]$/
if (rx.test(str)) {
const lower_inclusive = str[0] == '[';
const upper_inclusive = str[str.length-1] == ']';
const [ lower, upper ] = str.slice(1,-1).split(",");
return {
upper,
lower,
upper_inclusive,
lower_inclusive
};
}
}
function parseValidity (str) {
const range = parseRange(str);
if (range) {
ts0 = range.lower ? new Date(range.lower) : null;
ts1 = range.upper ? new Date(range.upper) : null;
return {
...range,
lower: ts0,
upper: ts1
};
}
}
function withinValidity (range, ts) {
if (!ts) {
ts = new Date();
}
if (typeof range === "string") {
range = parseValidity(range);
}
if (range.lower) {
if (range.lower_inclusive) {
if (!(range.lower <= ts)) {
return false;
}
} else {
if (!(range.lower < ts)) {
return false;
}
}
}
if (range.upper) {
if (range.upper_inclusive) {
if (!(range.upper >= ts)) {
return false;
}
} else {
if (!(range.upper > ts)) {
return false;
}
}
}
return true;
}
module.exports = {
parseRange,
parseValidity,
withinValidity
}

View File

@@ -0,0 +1,8 @@
function setContentDisposition (req, res) {
if (req.query.filename) {
res.set("Content-Disposition", `attachment; filename="${req.query.filename}"`);
}
}
module.exports = setContentDisposition;

Some files were not shown because too many files have changed in this diff Show More