summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRutger Broekhoff2026-09-22 22:28:10 +0200
committerRutger Broekhoff2026-09-22 22:28:10 +0200
commitecf1be3f834c7187312f8eee71f77e448c995388 (patch)
treeb7018285b815c356c49535c97ace67ac43deeb2d
parentae652cab3cf0541173b31273bcec5746a9a81e68 (diff)
downloadroutemon-ecf1be3f834c7187312f8eee71f77e448c995388.tar.gz
routemon-ecf1be3f834c7187312f8eee71f77e448c995388.zip
Check period selection
-rw-r--r--server/src/api.cpp11
-rw-r--r--server/src/api.cppm3
-rw-r--r--server/src/geo/wgs84.cppm3
-rw-r--r--server/src/http_server.cppm18
-rw-r--r--server/src/srv.cpp113
-rw-r--r--todo.org3
-rw-r--r--web/index.html55
-rw-r--r--web/index.js97
-rw-r--r--web/style.css21
9 files changed, 283 insertions, 41 deletions
diff --git a/server/src/api.cpp b/server/src/api.cpp
index f1ca24e..9a51690 100644
--- a/server/src/api.cpp
+++ b/server/src/api.cpp
@@ -141,20 +141,15 @@ handler::handler(log::logger const& l, datex2::situation_publication pub)
141 auto const dur_build_s = chrono::duration<double>{after_build - before_build}; 141 auto const dur_build_s = chrono::duration<double>{after_build - before_build};
142 l_.info("Indexes built in {}", dur_build_s); 142 l_.info("Indexes built in {}", dur_build_s);
143 l_.info("BLSE index size: {}", blse_index_.size()); 143 l_.info("BLSE index size: {}", blse_index_.size());
144 l_.info("Point index size: {}", bpe_index_.size()); 144 l_.info("BPE index size: {}", bpe_index_.size());
145} 145}
146 146
147auto handler::process_gpx(trace::id trace_id, gpx::file&& gpx_file) 147auto handler::process_gpx(
148 trace::id trace_id, gpx::file&& gpx_file, time::period_seq check_periods)
148 -> std::optional<process_gpx_result> 149 -> std::optional<process_gpx_result>
149{ 150{
150 auto l = l_.with("trace_id", trace_id.as_string()); 151 auto l = l_.with("trace_id", trace_id.as_string());
151 152
152 auto const now = chrono::utc_clock::now();
153 auto const relevant = std::initializer_list<time::period>{
154 time::period{now - chrono::days(7), now + chrono::days(7)}
155 };
156 auto const check_periods = time::period_seq{relevant.begin(), relevant.end()};
157
158 // TODO: eliminate use of overlap segments 153 // TODO: eliminate use of overlap segments
159 auto segments = std::vector<geo::utm::zonable_wgs84_linestring>{}; 154 auto segments = std::vector<geo::utm::zonable_wgs84_linestring>{};
160 for (auto const& track : gpx_file.tracks) 155 for (auto const& track : gpx_file.tracks)
diff --git a/server/src/api.cppm b/server/src/api.cppm
index b2744cb..b14bbcf 100644
--- a/server/src/api.cppm
+++ b/server/src/api.cppm
@@ -93,7 +93,8 @@ class handler
93public: 93public:
94 explicit handler(log::logger const& l, datex2::situation_publication pub); 94 explicit handler(log::logger const& l, datex2::situation_publication pub);
95 95
96 auto process_gpx(trace::id trace_id, gpx::file&& gpx_file) 96 auto process_gpx(
97 trace::id trace_id, gpx::file&& gpx_file, time::period_seq check_periods)
97 -> std::optional<process_gpx_result>; 98 -> std::optional<process_gpx_result>;
98 auto sysinfo() -> sysinfo; 99 auto sysinfo() -> sysinfo;
99}; 100};
diff --git a/server/src/geo/wgs84.cppm b/server/src/geo/wgs84.cppm
index b53a963..2212a92 100644
--- a/server/src/geo/wgs84.cppm
+++ b/server/src/geo/wgs84.cppm
@@ -10,9 +10,6 @@ namespace routemon::geo::wgs84 {
10 10
11using cs = bgeo::cs::geographic<bgeo::degree>; 11using cs = bgeo::cs::geographic<bgeo::degree>;
12using point = bgeo::model::point<double, 2, cs>; 12using point = bgeo::model::point<double, 2, cs>;
13// TODO: guarantee that linestring provides non-static member
14// auto reserve(std::size_t) -> void
15// Perhaps better yet: guarantee that the backing container is a std::vector.
16using linestring = 13using linestring =
17 bgeo::model::linestring<point, std::vector /* the default */>; 14 bgeo::model::linestring<point, std::vector /* the default */>;
18using box = bgeo::model::box<point>; 15using box = bgeo::model::box<point>;
diff --git a/server/src/http_server.cppm b/server/src/http_server.cppm
index e70110e..c111118 100644
--- a/server/src/http_server.cppm
+++ b/server/src/http_server.cppm
@@ -300,6 +300,7 @@ template <class InnerCtx>
300struct routed_ctx : InnerCtx 300struct routed_ctx : InnerCtx
301{ 301{
302 verb_set route_methods; 302 verb_set route_methods;
303 boost::urls::url target;
303}; 304};
304 305
305template <class Ctx> 306template <class Ctx>
@@ -687,12 +688,12 @@ class router
687 co_return problem_rsp(ctx, problem, keep_alive{false}); 688 co_return problem_rsp(ctx, problem, keep_alive{false});
688 } 689 }
689 690
690 l_.with( 691 // l_.with(
691 "trace_id", 692 // "trace_id",
692 static_cast<base_ctx const&>(ctx).trace_id.as_string()) 693 // static_cast<base_ctx const&>(ctx).trace_id.as_string())
693 .debug( 694 // .debug(
694 "Request targets {} {}", req_base.method_string(), 695 // "Request targets {} {}", req_base.method_string(),
695 req_url.path()); 696 // req_url.path());
696 697
697 auto mres = match(req_url.segments()); 698 auto mres = match(req_url.segments());
698 if (!mres) 699 if (!mres)
@@ -720,8 +721,9 @@ class router
720 721
721 if (auto mhdl = mres->route_handlers->lookup(*mverb)) 722 if (auto mhdl = mres->route_handlers->lookup(*mverb))
722 { 723 {
723 auto new_ctx = 724 auto new_ctx = routed_ctx<PreRouteCtx>{
724 routed_ctx<PreRouteCtx>{std::move(ctx), mres->allowed_methods()}; 725 std::move(ctx), mres->allowed_methods(), std::move(req_url)
726 };
725 co_return co_await mhdl( 727 co_return co_await mhdl(
726 std::move(new_ctx), r, mres->wildcard_matches); 728 std::move(new_ctx), r, mres->wildcard_matches);
727 } 729 }
diff --git a/server/src/srv.cpp b/server/src/srv.cpp
index dc9c814..5de0fc5 100644
--- a/server/src/srv.cpp
+++ b/server/src/srv.cpp
@@ -30,7 +30,7 @@ namespace routemon::srv {
30class gpx_parse_error_category_impl : public std::error_category 30class gpx_parse_error_category_impl : public std::error_category
31{ 31{
32public: 32public:
33 char const* name() const noexcept override { return "gpx_parse"; } 33 auto name() const noexcept -> char const* override { return "gpx_parse"; }
34 34
35 auto message(int condition) const noexcept -> std::string override 35 auto message(int condition) const noexcept -> std::string override
36 { 36 {
@@ -330,12 +330,120 @@ struct raw_deflate_body
330 }; 330 };
331}; 331};
332 332
333auto parse_timestamp(std::string_view in) -> std::optional<time::timestamp>
334{
335 auto res = time::timestamp{};
336 auto is = std::istringstream{in};
337 is >> std::chrono::parse("%Y-%m-%dT%H:%MZ", res);
338 return is.fail() ? std::nullopt : std::make_optional(res);
339}
340
333auto handler::handle_process_gpx(l0_ctx ctx, http::readable_request r) 341auto handler::handle_process_gpx(l0_ctx ctx, http::readable_request r)
334 -> net::awaitable<http::presponse> 342 -> net::awaitable<http::presponse>
335{ 343{
336 auto l = 344 auto l =
337 l_.sub("handle_process_gpx").with("trace_id", ctx.trace_id.as_string()); 345 l_.sub("handle_process_gpx").with("trace_id", ctx.trace_id.as_string());
338 346
347 auto cp_begin = time::timestamp{};
348 auto cp_end = time::timestamp{};
349
350 auto const params = ctx.target.params();
351 if (auto it = params.find("cpbegin"); it != params.end())
352 {
353 auto mts = parse_timestamp((*it).value);
354 if (!mts)
355 {
356 auto tpl = problem::tpl{
357 .status = bhttp::status::bad_request,
358 .title = translate("Query parameter has bad value"),
359 .type_uri =
360 "https://routemon.fautchen.eu/problems/query-param-bad-value",
361 };
362 co_return http::problem_rsp(
363 ctx, tpl.instantiate(), http::keep_alive{false});
364 }
365 cp_begin = *mts;
366
367 ++it;
368 if (it != params.end() && (*it).key == "cpbegin")
369 {
370 auto tpl = problem::tpl{
371 .status = bhttp::status::bad_request,
372 .title = translate("Query parameter provided too many times"),
373 .type_uri = "https://routemon.fautchen.eu/problems/"
374 "query-param-too-many-occurrences",
375 };
376 co_return http::problem_rsp(
377 ctx, tpl.instantiate(), http::keep_alive{false});
378 }
379 }
380 else
381 {
382 auto tpl = problem::tpl{
383 .status = bhttp::status::bad_request,
384 .title = translate("Required query parameter not provided"),
385 .type_uri = "https://routemon.fautchen.eu/problems/"
386 "required-query-param-not-provided",
387 };
388 co_return http::problem_rsp(
389 ctx, tpl.instantiate(), http::keep_alive{false});
390 }
391 if (auto it = params.find("cpend"); it != params.end())
392 {
393 auto mts = parse_timestamp((*it).value);
394 if (!mts)
395 {
396 auto tpl = problem::tpl{
397 .status = bhttp::status::bad_request,
398 .title = translate("Query parameter has bad value"),
399 .type_uri =
400 "https://routemon.fautchen.eu/problems/query-param-bad-value",
401 };
402 co_return http::problem_rsp(
403 ctx, tpl.instantiate(), http::keep_alive{false});
404 }
405 cp_end = *mts;
406
407 ++it;
408 if (it != params.end() && (*it).key == "cpend")
409 {
410 auto tpl = problem::tpl{
411 .status = bhttp::status::bad_request,
412 .title = translate("Query parameter provided too many times"),
413 .type_uri = "https://routemon.fautchen.eu/problems/"
414 "query-param-too-many-occurrences",
415 };
416 co_return http::problem_rsp(
417 ctx, tpl.instantiate(), http::keep_alive{false});
418 }
419 }
420 else
421 {
422 auto tpl = problem::tpl{
423 .status = bhttp::status::bad_request,
424 .title = translate("Required query parameter not provided"),
425 .type_uri = "https://routemon.fautchen.eu/problems/"
426 "required-query-param-not-provided",
427 };
428 co_return http::problem_rsp(
429 ctx, tpl.instantiate(), http::keep_alive{false});
430 }
431
432 if (cp_begin >= cp_end)
433 {
434 auto tpl = problem::tpl{
435 .status = bhttp::status::bad_request,
436 .title = translate("Invalid check period provided"),
437 .type_uri = "https://routemon.fautchen.eu/problems/invalid-check-period",
438 };
439 co_return http::problem_rsp(
440 ctx,
441 tpl.instantiate().set_detail(translate(
442 "Begin of check period must be before end of check period")),
443 http::keep_alive{false});
444 }
445 auto cp = time::period{cp_begin, cp_end};
446
339 auto gpx_file = gpx::file{}; 447 auto gpx_file = gpx::file{};
340 try 448 try
341 { 449 {
@@ -378,7 +486,8 @@ auto handler::handle_process_gpx(l0_ctx ctx, http::readable_request r)
378 486
379 // TODO: catch handler exceptions and return 500 when raised? 487 // TODO: catch handler exceptions and return 500 when raised?
380 // (keep-alive depends on whether whole request was read) 488 // (keep-alive depends on whether whole request was read)
381 auto mres = inner_.process_gpx(ctx.trace_id, std::move(gpx_file)); 489 auto mres = inner_.process_gpx(
490 ctx.trace_id, std::move(gpx_file), time::period_seq{cp});
382 if (!mres) 491 if (!mres)
383 { 492 {
384 auto tpl = problem::tpl{ 493 auto tpl = problem::tpl{
diff --git a/todo.org b/todo.org
index 1ad2bd7..fc3b062 100644
--- a/todo.org
+++ b/todo.org
@@ -1,3 +1,2 @@
1- [ ] Expect: 100-continue support in HTTP server? 1- [ ] Expect: 100-continue support in HTTP server?
2 2- [ ] Limit on (raw-zlib-inflated) body size?
3- [ ] UTM zome segmentation maybe not working?
diff --git a/web/index.html b/web/index.html
index 877da14..42a61ff 100644
--- a/web/index.html
+++ b/web/index.html
@@ -14,11 +14,57 @@
14 14
15 <form name="gpx-upload-form" class="box"> 15 <form name="gpx-upload-form" class="box">
16 <div> 16 <div>
17 <label for="gpx-file">Upload een GPX-bestand:</label> 17 <label for="gpx-file">GPX-bestand:</label>
18 <input type="file" id="gpx-file-input" name="gpx-file" accept="application/gpx+xml,.gpx" autofocus /> 18 <input type="file" id="gpx-file-input" name="gpx-file" accept="application/gpx+xml,.gpx" required autofocus />
19 </div> 19 </div>
20 <fieldset>
21 <legend>Controleer werkzaamheden gepland in periode</legend>
22 <table class="borderless">
23 <tr>
24 <td>
25 <label for="check-period-begin-preset">Vanaf (incl.):</label>
26 </td>
27 <td>
28 <select name="check-period-begin-preset" id="check-period-begin-preset-select">
29 <option value="-1m">1 maand geleden</option>
30 <option value="-2w">2 weken geleden</option>
31 <option value="-1w" selected>1 week geleden</option>
32 <option value="0">nu</option>
33 <option value="+1w">over 1 week</option>
34 <option value="+2w">over 2 weken</option>
35 <option value="+1m">over 1 maand</option>
36 <option value="custom">een ander moment</option>
37 </select>
38 </td>
39 <td>
40 <input type="datetime-local" id="check-period-begin-input" name="check-period-begin" class="hidden" />
41 </td>
42 </tr>
43 <tr>
44 <td>
45 <label for="check-period-end-preset">Tot (excl.):</label>
46 </td>
47 <td>
48 <select name="check-period-end-preset" id="check-period-end-preset-select">
49 <option value="">aanpassen naar…</option>
50 <option value="-1m">1 maand geleden</option>
51 <option value="+2w">2 weken geleden</option>
52 <option value="-1w">1 week geleden</option>
53 <option value="0">nu</option>
54 <option value="+1w" selected>over 1 week</option>
55 <option value="+2w">over 2 weken</option>
56 <option value="+1m">over 1 maand</option>
57 <option value="custom">een ander moment</option>
58 </select>
59 </td>
60 <td>
61 <input type="datetime-local" id="check-period-end-input" name="check-period-end" class="hidden" />
62 </td>
63 </tr>
64 </table>
65 </fieldset>
20 <div> 66 <div>
21 <input type="submit" value="Opsturen" /> 67 <input type="submit" value="Aanvraag versturen" />
22 </div> 68 </div>
23 </form> 69 </form>
24 70
@@ -38,7 +84,7 @@
38 <dialog class="box"> 84 <dialog class="box">
39 <h3 id="dialog-title"></h3> 85 <h3 id="dialog-title"></h3>
40 <p id="dialog-message"></p> 86 <p id="dialog-message"></p>
41 <details> 87 <details id="dialog-more-info-container">
42 <summary>Meer informatie</summary> 88 <summary>Meer informatie</summary>
43 <small id="dialog-more-info"></small> 89 <small id="dialog-more-info"></small>
44 </details> 90 </details>
@@ -46,6 +92,7 @@
46 </dialog> 92 </dialog>
47 93
48 <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="anonymous" referrerpolicy="no-referrer"></script> 94 <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
95 <script src="https://cdn.jsdelivr.net/npm/[email protected]/global.min.js"></script>
49 <script src="index.js"></script> 96 <script src="index.js"></script>
50 </body> 97 </body>
51</html> 98</html>
diff --git a/web/index.js b/web/index.js
index e8accf0..63292aa 100644
--- a/web/index.js
+++ b/web/index.js
@@ -7,6 +7,50 @@ closeButton.addEventListener("click", () => {
7 dialog.close(); 7 dialog.close();
8}); 8});
9 9
10function handlePeriodLimitPresetSelInput(selEl, inputEl) {
11 selEl.addEventListener("input", (event) => {
12 if (selEl.value === "custom") {
13 inputEl.classList.remove("hidden");
14 } else {
15 inputEl.classList.add("hidden");
16 }
17 });
18}
19
20function getPeriodLimitPresetSelInput(selEl, inputEl) {
21 const relativeDateTime = (durationArgs) => Temporal.Now.plainDateTimeISO().add(durationArgs);
22
23 let durationArgs = {};
24 switch (selEl.value) {
25 case "-1m": return relativeDateTime({ months: -1 });
26 case "-2w": return relativeDateTime({ weeks: -2 });
27 case "-1w": return relativeDateTime({ weeks: -1 });
28 case "0": return relativeDateTime({ seconds: 0 });
29 case "+1w": return relativeDateTime({ weeks: 1 });
30 case "+2w": return relativeDateTime({ weeks: 2 });
31 case "+1m": return relativeDateTime({ months: 1 });
32 case "custom": return Temporal.PlainDateTime.from(inputEl.value);
33 }
34}
35
36handlePeriodLimitPresetSelInput(
37 document.getElementById("check-period-begin-preset-select"),
38 document.getElementById("check-period-begin-input"));
39handlePeriodLimitPresetSelInput(
40 document.getElementById("check-period-end-preset-select"),
41 document.getElementById("check-period-end-input"));
42
43window.addEventListener("load", (event) => {
44 const oneWeek = Temporal.Duration.from({ weeks: 1 });
45 const now = Temporal.Now.plainDateTimeISO();
46 document.getElementById("check-period-begin-input").value = now.subtract(oneWeek).toString({
47 smallestUnit: "minute",
48 });
49 document.getElementById("check-period-end-input").value = now.add(oneWeek).toString({
50 smallestUnit: "minute",
51 });
52});
53
10let map = L.map("map"); 54let map = L.map("map");
11 55
12L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 56L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
@@ -19,10 +63,27 @@ document.forms["gpx-upload-form"].addEventListener("submit", (event) => {
19 event.preventDefault(); 63 event.preventDefault();
20 const gpxFileInput = document.getElementById("gpx-file-input"); 64 const gpxFileInput = document.getElementById("gpx-file-input");
21 if (gpxFileInput.files.length !== 1) { 65 if (gpxFileInput.files.length !== 1) {
22 alert("Voor de upload moet er exact één GPX-bestand zijn geselecteerd"); 66 showDialog({
67 title: "Geen bestand geselecteerd",
68 detail: "Voor de upload moet er exact één GPX-bestand zijn geselecteerd",
69 moreInfoEl: null,
70 });
23 return; 71 return;
24 } 72 }
25 load(gpxFileInput.files[0]); 73
74 const tzidHere = Temporal.Now.timeZoneId();
75 const cpBegin = getPeriodLimitPresetSelInput(document.getElementById("check-period-begin-preset-select"),
76 document.getElementById("check-period-begin-input")).toZonedDateTime(tzidHere).toInstant();
77 const cpEnd = getPeriodLimitPresetSelInput(document.getElementById("check-period-end-preset-select"),
78 document.getElementById("check-period-end-input")).toZonedDateTime(tzidHere).toInstant();
79
80 load({
81 gpxFile: gpxFileInput.files[0],
82 checkPeriod: {
83 begin: cpBegin,
84 end: cpEnd,
85 },
86 });
26}); 87});
27 88
28function el(name, attrs, ...children) { 89function el(name, attrs, ...children) {
@@ -47,15 +108,22 @@ function tbl(rowcols) {
47 )))); 108 ))));
48} 109}
49 110
50async function showProblem(rsp) { 111function showDialog({ title, detail, moreInfoEl }) {
51 const problem = await rsp.json(); 112 document.getElementById("dialog-title").innerText = title;
113 document.getElementById("dialog-message").innerText = detail !== null ? detail : "";
114 if (moreInfoEl !== null) {
115 document.getElementById("dialog-more-info").replaceChildren(moreInfoEl);
116 document.getElementById("dialog-more-info-container").classList.remove("hidden");
117 } else {
118 document.getElementById("dialog-more-info").replaceChildren();
119 document.getElementById("dialog-more-info-container").classList.add("hidden");
120 }
52 121
53 document.getElementById("dialog-title").innerText = problem.title;
54 if (problem.detail)
55 document.getElementById("dialog-message").innerText = problem.detail;
56 else
57 document.getElementById("dialog-message").innerText = "";
58 dialog.showModal(); 122 dialog.showModal();
123}
124
125async function showProblem(rsp) {
126 const problem = await rsp.json();
59 127
60 let rows = [ 128 let rows = [
61 [txt("HTTP-statuscode"), 129 [txt("HTTP-statuscode"),
@@ -70,9 +138,12 @@ async function showProblem(rsp) {
70 rows.push([txt("Instantie"), 138 rows.push([txt("Instantie"),
71 el("code", {}, txt(problem.instance))]); 139 el("code", {}, txt(problem.instance))]);
72 } 140 }
73 document.getElementById("dialog-more-info").replaceChildren(tbl(rows));
74 141
75 return; 142 showDialog({
143 title: problem.title,
144 detail: problem.detail ? problem.detail : null,
145 moreInfoEl: tbl(rows),
146 });
76} 147}
77 148
78async function getSysinfo() { 149async function getSysinfo() {
@@ -101,7 +172,7 @@ async function getSysinfo() {
101 document.getElementById("sysinfo-details").replaceChildren(tbl(rows)); 172 document.getElementById("sysinfo-details").replaceChildren(tbl(rows));
102} 173}
103 174
104async function load(gpxFile) { 175async function load({ gpxFile, checkPeriod }) {
105 let compressedParts = []; 176 let compressedParts = [];
106 const compressedReader = gpxFile.stream().pipeThrough(new CompressionStream("deflate-raw")).getReader(); 177 const compressedReader = gpxFile.stream().pipeThrough(new CompressionStream("deflate-raw")).getReader();
107 while (true) { 178 while (true) {
@@ -113,7 +184,7 @@ async function load(gpxFile) {
113 } 184 }
114 } 185 }
115 186
116 const rsp = await fetch(apiBaseUrl + "/gpx", { 187 const rsp = await fetch(apiBaseUrl + `/gpx?cpbegin=${encodeURI(checkPeriod.begin.toString({ smallestUnit: "minute" }))}&cpend=${encodeURI(checkPeriod.end.toString({ smallestUnit: "minute" }))}`, {
117 method: "POST", 188 method: "POST",
118 headers: { 189 headers: {
119 "Accept-Language": "nl-NL", 190 "Accept-Language": "nl-NL",
diff --git a/web/style.css b/web/style.css
index a96354c..49cc502 100644
--- a/web/style.css
+++ b/web/style.css
@@ -34,15 +34,32 @@ th, td {
34 padding: 5px 5px; 34 padding: 5px 5px;
35} 35}
36 36
37table.borderless,
38table.borderless th,
39table.borderless td {
40 border: none;
41}
42
37form[name="gpx-upload-form"] { 43form[name="gpx-upload-form"] {
38 z-index: 1000; 44 z-index: 1000;
39 display: flex; 45 display: flex;
46 flex-direction: column;
40 gap: 16pt; 47 gap: 16pt;
41 position: absolute; 48 position: absolute;
42 top: 10px; 49 top: 10px;
43 right: 10px; 50 right: 10px;
44} 51}
45 52
53form fieldset {
54 display: flex;
55 flex-direction: column;
56 gap: 5px;
57}
58
59form[name="gpx-upload-form"] input[type="submit"] {
60 float: right;
61}
62
46#sysinfo { 63#sysinfo {
47 z-index: 1000; 64 z-index: 1000;
48 display: flex; 65 display: flex;
@@ -60,3 +77,7 @@ form[name="gpx-upload-form"] {
60.help-cursor { 77.help-cursor {
61 cursor: help; 78 cursor: help;
62} 79}
80
81.hidden {
82 display: none;
83}