Compare commits

...

31 Commits

Author SHA1 Message Date
b0txec e408fad26c Rewrite the root README as a short, non-technical project overview
CI / backend (push) Successful in 1m3s
CI / frontend (push) Successful in 42s
The old file was a confusing mix of eras: an accurate current
Windows/Rocky/VPS environment section sitting alongside stale leftovers
from before this project's current setup existed -- a wrong port (9090,
staging is actually 9190), old docker-compose v1 syntax, a podman-compose
three-terminal dev loop nobody uses, an nginx config for laikazinas.lsm.lv
(the real current LSM production site, unrelated to and confusable with
this project's actual temporary test domain), Fly.io commands from a
hosting platform no longer in use, and an example command that happens to
run one of the files this session's security review flagged as a
mislabeled test script capable of touching a live database.

Replaced with a short landing page: what the tool does, how it's built,
current status (Rocky-only development now that Windows is retired from
the workflow, laikapstak.li as the temporary test environment ahead of
eventually replacing the live laikazinas.lsm.lv service), and a security
summary -- all pointing to docs/ for actual technical depth rather than
duplicating half-true instructions. Updated docs/README.md's own
description of the root README to match.
2026-08-25 19:14:48 +03:00
b0txec cb7e9aa06b Record the Faktiskā blank-temperature fix and correct a runbook bug
CI / backend (push) Successful in 1m3s
CI / frontend (push) Successful in 40s
Documents release a6b2b84 (the queryLatestTemperatures fix and the user's
CSS polish pass) in the changelog and README status line. Also fixes a real
bug in VPS_RELEASE_RUNBOOK.md's own checksum step, found by actually running
it for this release: it generated the .sha256 file against a full local
path rather than a bare filename, which fails verification on the VPS since
that path doesn't exist there.
2026-08-25 17:19:17 +03:00
b0txec a6b2b8462c Fix Faktiskā showing blank temperatures right after each new hour starts
CI / backend (push) Successful in 1m37s
CI / frontend (push) Successful in 48s
queryLatestTemperatures picked the single most-recent row per city with no
regard for whether that row's tempAvg was actually populated yet. The
open-data source publishes fields on different schedules within the same
hour (snow lands well before the hourly temperature aggregate), so the
freshest row for a city routinely has a real dateTime but a still-null
tempAvg while the previous hour's row has good data. Confirmed live on both
Rocky and the VPS: every city's newest row had tempAvg null (but a real
snowAvg=0), one hour after the actual last real reading.

Fixed by requiring tempAvg IS NOT NULL in the WHERE clause, so DISTINCT ON's
"most recent" pick skips a fresh partial row and finds the newest row that
actually has a value. Checked before changing: this is the only caller of
queryLatestTemperatures (Faktiskā's fixed-station lookup); a city with zero
real temperature history ever now returns no row instead of a null-valued
one, which the frontend already treats identically to a present-but-null
observation (falls through to the same "Nav datu" missing state either
way) -- confirmed via MapGraphics.tsx before shipping, not assumed.
2026-08-25 17:13:20 +03:00
b0txec 829319edf8 Polish hover/focus states on inputs and checkboxes, drop a stray font override
- Add a .secondary button variant and hover/focus states (border-color,
  focus ring) to text/number/date/select inputs, matching the existing
  checkbox and button interaction language.
- Restyle Brīdinājumi's warning checkbox to match the same custom-checkbox
  look already used elsewhere (border-based, hover, focus-visible outline)
  instead of its own older shadow-based style.
- Remove a hardcoded Inter font-family on Faktiskā's reset-temperature
  button so it inherits the app's default Monda font like everything else.
2026-08-25 17:13:11 +03:00
b0txec 39478bde1d Add a literal, runnable VPS release runbook
CI / backend (push) Successful in 1m9s
CI / frontend (push) Successful in 1m0s
VPS_STAGING_PLAN.md describes the release process at a policy level; the
changelog table narrates what happened after each release. Neither is a
step-by-step "how" a person (or Guntis) could actually follow without
already knowing the process from having run it. This is that document --
copy-pasteable commands, in order, parameterized by commit SHA.

Writing it surfaced a real regression: earlier releases (through ef64895)
retained the release tarball under /srv/weathertool/releases/<sha>/ and
backed up .env.staging before every edit; tonight's releases (0be325f,
9bab93d, 001b014) did neither -- I'd been deleting the transfer copy from
/tmp after each deploy and sed-ing .env.staging directly with no backup.
Practical cost: rolling back to any of tonight's releases would have meant
rebuilding from git history and re-transferring, instead of just re-pointing
.env.staging at an already-present image. Closed the gap for the current
live release (001b014) retroactively -- exported it directly on the VPS
(checksum matches the exact bytes shipped tonight, confirming no drift) and
backed up the current .env.staging -- and the runbook restores both habits
going forward.
2026-08-25 11:51:23 +03:00
b0txec 5cf2ad58f5 Record the VPS deployment of tonight's accumulated changes (release 001b014)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 43s
2026-08-25 11:37:34 +03:00
b0txec 001b014932 Catch up the changelog and status docs with tonight's frontend work
CI / backend (push) Successful in 1m14s
CI / frontend (push) Successful in 43s
Six real commits (padding, the Result.css collision fix + dead code
cleanup, Ūdens's card layout, its button styling, the Valmiera substitution,
and click-to-edit) had detailed commit messages but no corresponding
roadmap changelog rows or README status updates -- caught while checking
doc status before a planned VPS push.
2026-08-25 11:30:41 +03:00
b0txec b561bd800c Document the click-to-edit temperature interaction on Faktiskā
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 41s
2026-08-25 11:21:31 +03:00
b0txec 4c40c244df Add click-to-edit temperature badges on the Faktiskā map
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 41s
The "Rādīt stacijas" panel is the only way to fix a value today, and it
opens all 13 fields at once even when only one or two need a tweak. Since
most values are already correct, that's the wrong default interaction for
the common case.

Click a temperature badge directly on the map to edit it inline instead.
Scoped strictly to mode==="faktiska" (Kartes/Ūdens/Brīdinājumi share the
same MapView component and are unaffected). Mirrors Brīdinājumi's existing
canvas-coordinate pattern exactly (getBoundingClientRect scaling via
canvasPoint) rather than inventing a new one, hit-testing against
Faktiskā's already-fixed per-resolution marker positions (faktiskaTemplates)
-- no dragging or calibration needed, just a box test against 13 known
points. The floating input is a plain DOM element positioned over the
canvas, not drawn onto it, so it's preview-only like Brīdinājumi's drag
handles and never reaches the exported PNG.

Reuses the existing updateValue function via a new onEditValue callback
prop, so overrides/reset/the "Manuāli" badge in the stations panel all stay
in sync automatically -- no parallel state to maintain. Added a hover
cursor and a one-line hint ("Klikšķini uz temperatūras kartē, lai to
mainītu") for discoverability, since the whole point was fixing an
undiscoverable interaction, not trading it for another one.

Verified end-to-end: hover shows a pointer cursor over a badge; clicking
shows a correctly positioned input prefilled with the real current value;
Enter commits and the canvas redraws immediately; the change is reflected
in the "Rādīt stacijas" panel as a manual override; Escape cancels without
committing; works correctly at both 1920x1080 and 3840x1440 (different
badge size and marker positions per template); no console errors; Kartes
(a different MapView mode) is unaffected.
2026-08-25 11:21:11 +03:00
b0txec 07b16accb2 Source Valmiera's Faktiskā temperature from Priekuļi as a temporary substitute
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 42s
LVĢMC's open-data station list has no station in or near Valmiera at all
(confirmed live 2026-08-23 in OpenDataStationService.scala), so that fixed
position has been silently blank. Priekuļi is the closest currently-
reporting station -- measured against the app's own calibrated map
coordinates (~56px away vs. the next-closest candidate, Rūjiena, at ~81px),
not a guess. Same pattern already used for Stende/Talsi and Zīlāni/Jēkabpils.

Implemented client-side in Faktiskā's fetch only: query Priekuļi's reading,
remap it back onto the "Valmiera" key before it reaches the rest of the
component. Valmiera's marker position and on-map label are untouched (both
are tied to Valmiera's real map location, not Priekuļi's) -- only the data
source for that one slot changes. This also fixes a second, previously
unexplained symptom for free: the weather-symbol picker only lists cities
with a value, so Valmiera never appeared there either; it now does.

Provisional pending direct confirmation from LVĢMC -- documented as such in
PRODUCT_WORKFLOWS.md, matching how Stende/Zīlāni are already flagged.

Verified: typecheck clean, production build succeeds, headless-browser
checks confirm Valmiera's field shows Priekuļi's real live value (14.8°C,
fresh timestamp, not flagged manual/stale) and now appears in the
weather-symbol exceptions list (13 cities, was 12).
2026-08-25 11:16:12 +03:00
b0txec e1ce1533bd Give Ūdens's resolution buttons the same visual language as the cards
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 44s
They used the app's generic flat button style (transparent border, solid
gray fill) while the temperature cards next to them have a considered
look (border, soft glass background, label hierarchy) -- so the buttons
read as a lightweight, generic control next to more polished content.
Matched border, border-radius, and background to the cards, and gave them
a bit more height/padding so the block reads with similar visual weight.

(Measured actual box positions before touching anything: button and card
tops are pixel-identical at the user's real viewport width, confirmed via
Playwright bounding boxes -- the "buttons look higher" read was about
visual weight/style mismatch, not a coordinate offset.)
2026-08-25 11:04:46 +03:00
b0txec e62895ad68 Narrow Ūdens's temperature cards so all six fit on one row
CI / backend (push) Successful in 1m2s
CI / frontend (push) Successful in 39s
The No/Līdz number-input boxes were sized for a hardcoded 3-column grid
(minmax(250px,1fr)) despite only ever holding 2-3 digit temperatures.
Switched to grid-template-columns: repeat(auto-fit, minmax(165px,1fr)) --
the browser now fits however many 165px+ cards actually fit at the current
width, growing them to fill leftover space, with no manual breakpoints
needed (removed three now-redundant @media overrides for .rangeGrid).

Caught a real bug while tuning the floor value: a naive hardcoded
repeat(6, minmax(130px,1fr)) first pass produced 6 columns at every width
from 1050px to 1900px, but at some widths in that range (e.g. 1300px) the
resulting per-input width came out to ~38px -- native number-input spinner
arrows ate nearly all of it, and the digits, while genuinely present in the
DOM (verified via inputValue()), were visually clipped to nothing. The map
preview below (fed by the same signal) still rendered correctly, which is
what made clear this was a paint/width issue, not a data bug. Fixed two
ways: hid the native spinner arrows (reclaims space, matches the project's
existing pattern of fully custom-styling other native inputs like
checkboxes) and switched to auto-fit so the actual computed column width
can never fall below the safe floor regardless of viewport width -- the
exact class of bug a hand-rolled breakpoint ladder is prone to.

Verified via a programmatic sweep (14 widths from 375px to 1900px, reading
each input's real computed width and value, not just eyeballing
screenshots) that every width keeps at least ~53px per input -- confirmed
by direct visual check to be comfortably legible, well clear of the ~38px
threshold that broke -- and headless-browser screenshots at 1600/1300/700px
confirm all six cards render correctly with real live data.
2026-08-25 10:48:14 +03:00
b0txec 1e83e48cba Fix a real CSS collision and remove dead frontend code
CI / backend (push) Successful in 1m8s
CI / frontend (push) Successful in 43s
Result.css (Kartes' results grid/list): a newer consolidated .item rule
used background:var(--surface)!important to force out an older duplicate
rule instead of removing it -- the old rule's background-color was fully
dead but its padding/text-align/max-width were still silently active
underneath. Consolidated into one rule per view, dropped the !important.

Dead code, verified via a systematic check of every class in each
stylesheet against actual .tsx usage (not just the classes a prior review
happened to flag -- that list undercounted by several in mapGraphics.css
alone):
- mapGraphics.css: an entire leftover "Production layout" block (9 classes:
  graphicsSetup, graphicsMain, dataOptions, dataOptionsGrid, productionOptions,
  productionToolbar, loadMapButton, choiceGroup, windChoice), including a
  scoped selector (.mapGraphicsPage .workspaceRail) that never matched
  because no such element exists in the current Faktiskā markup -- leftover
  from an earlier panel restructure.
- index.css / aggregator.css: unused .grid-1-1/.grid-1-2 utility classes
  and a standalone .panel rule superseded by the more specific
  cityPanel/queryPanel/resultsPanel variants.
- calendar.module.css: .calendarNav (never rendered) and .nextMonth (no
  next-month dimming logic exists, only prevMonth).
- DateList.tsx: an empty <ul> wrapping fully commented-out code, superseded
  by the Calendar component already rendered above it.

Verified: typecheck clean, production build succeeds, and headless-browser
screenshots of every affected page (Kartes including an actual query
result, Faktiskā, Arhīvs) show no visual regression.
2026-08-25 10:38:07 +03:00
b0txec 464e03581c Add shared side padding to appContent for breathing room on full-bleed pages
CI / backend (push) Successful in 1m17s
CI / frontend (push) Successful in 46s
Kartes, Stacijas, and Apskats had no CSS of their own and rendered fully
edge-to-edge (no other layout control was actually shared across all pages
before this -- appContent only set min-height). clamp(16px,3vw,48px) gives
consistent side padding globally while staying full-width, not adding a
centered max-width column -- the ask was breathing room, not a narrower
layout. Verified on Rocky: Kartes now has visible margins, Home (which
already set its own max-width/padding) shows no visible double-padding
issue at 1600px viewport width.
2026-08-25 10:22:04 +03:00
b0txec ea583ed57d Correct two doc sections written before the real CI bugs were found
CI / backend (push) Successful in 1m27s
CI / frontend (push) Successful in 52s
The changelog claimed ci.yml #1 verified green (it didn't -- that was a
wrong WebFetch summary of the Gitea Actions page, reported as fact without
checking it visually) and CONTINUOUS_INTEGRATION.md described the
HOME/cache permission warning as harmless (it wasn't -- it blocked
resolving actions/checkout entirely). Both corrected with what actually
happened: three real, different bugs, found only by running the workflow
and reading user-provided screenshots of the actual failures.
2026-08-24 22:27:39 +03:00
b0txec 27ab795f54 Add a combined 'all' push remote and document the new push habit
CI / backend (push) Successful in 1m32s
CI / frontend (push) Successful in 55s
Pushing to rocky alone doesn't trigger CI (Gitea can't see that private bare
repo), and two separate git push commands are easy to half-remember. 'all'
has two push URLs (rocky, gitea) so one push updates the staging checkout's
source and triggers CI together. Deliberately excludes origin (GitHub)
pending the still-open question about that repo's ownership/involvement.
2026-08-24 22:25:35 +03:00
b0txec 2c78ae8286 Install a real Node 20 in the backend CI job, not Debian bullseye's default
CI / backend (push) Successful in 1m28s
CI / frontend (push) Successful in 56s
Node got installed (previous fix worked) but actions/checkout@v4's bundled
JS uses modern syntax (class static blocks) requiring Node >=16.11.
hseeberger/scala-sbt is Debian bullseye-based, whose default apt "nodejs"
package is a stale Node 12 -- SyntaxError: Unexpected token '{' at "static
{". HOP's own image is bookworm-based (newer default), which is why they
never hit this. Use NodeSource's setup script to get an actual current Node
20 regardless of the distro's packaged version.
2026-08-24 22:11:47 +03:00
b0txec 7e1b492a0c Install Node in the backend CI job before checkout
CI / backend (push) Failing after 11s
CI / frontend (push) Successful in 53s
The frontend job succeeded after the runner HOME fix, but backend failed at
a different point: actions/checkout is a JavaScript action and needs a Node
runtime inside the job container to run it at all (exec: "node": executable
file not found in $PATH), and hseeberger/scala-sbt has no Node installed.
Same lesson HOP's own CI setup already documented ("JavaScript actions need
Node in the job image") -- mirroring their fix: install git+nodejs via apt
before the checkout step.
2026-08-24 22:04:06 +03:00
b0txec 1a99515182 Trigger CI to verify the runner HOME fix
CI / backend (push) Failing after 2s
CI / frontend (push) Successful in 53s
Both jobs in the first two CI runs failed identically at the checkout step:
"Unable to clone https://github.com/actions/checkout ... mkdir /.cache:
permission denied". The runner container runs as a fixed uid with no
writable $HOME, so act cloning action sources into its cache directory
failed outright -- this affects every remote action reference, not just the
actions/cache action type as I'd assumed earlier. Fixed by setting
HOME=/data (the already-writable bind-mounted data directory) on the
runner container; not a source change, just re-triggering CI to confirm.
2026-08-24 22:01:10 +03:00
b0txec a989d2a6a8 Document the Gitea Actions CI setup and the follow-up security review
CI / backend (push) Failing after 1s
CI / frontend (push) Failing after 1s
Adds CONTINUOUS_INTEGRATION.md (mirrors HOP's CI doc structure), links it
from README, and records the CI changelog entry plus the follow-up review's
open findings (scheduler crash-loop, System.gc(), connection pooling, the
PostgresService field-routing invariant, and the mislabeled main-method
"tests") in the roadmap's Phase 2/Phase 5 checklists.
2026-08-24 21:55:48 +03:00
b0txec 5b88e69c40 Add Gitea Actions CI workflow
CI / backend (push) Failing after 15s
CI / frontend (push) Failing after 18s
Runs on push/PR to codex/staging-baseline plus manual dispatch: sbt test for
the backend, and npm ci/typecheck/build/audit (full + production-only) for
the frontend, matching the checks already documented as the manual Rocky
verification routine. Verified sbt test compiles and passes with no .env
file present (matching what a checkout-only CI job will actually have) via
a git-archive dry run before writing this.

Two separate jobs, each with its own container image, rather than one
shared runner-label image with ad-hoc installs — reuses the exact
hseeberger/scala-sbt image already used all session for local backend
builds, and a plain node:20-bookworm for the frontend, so nothing needs
apt-get bootstrapping of a second language runtime into the same container.
2026-08-24 21:52:29 +03:00
b0txec 1e6f5298ae Record the missed FTP route and its fix in the roadmap changelog 2026-08-24 12:20:29 +03:00
b0txec 9bab93daa6 Gate /api/show/lvgmc-forecast behind ENABLE_LVGMC_FTP_JOBS too
Caught by an independent follow-up review: the FTP auth-bypass fix in
6b9c7cf only gated /api/fetch/lvgmc/stations, the one route the original
security review named. This sibling route calls the same fetch.fetchFile,
which opens a real, unauthenticated FTP login to LVGMC regardless of any
caller-supplied filename, and was only getting the traversal fix
(ValidateFileName) applied to it, not the auth-bypass fix. Traced every
caller of fetch.fetchFile/fetchWeatherStations in Server.scala/Main.scala
this time to confirm these are the only two HTTP-reachable call sites and
both are now gated.
2026-08-24 12:15:56 +03:00
b0txec e33e78e2d4 Record the VPS deployment of the security review fixes
Release 0be325f (image weathertool:0be325fbbbf92b03bc8d574dc6f7b9449ef310ea)
ships the path-traversal/auth-bypass/DoS and SQL-injection fixes to
production; b0b58d2 remains the rollback target.
2026-08-24 12:06:07 +03:00
b0txec 0be325fbbb Document the security review fixes and the Rocky Postgres password rotation
Records the path-traversal/auth-bypass/DoS fixes (6b9c7cf), the SQL
injection fix (8c45d8d), and the subsequent Postgres password rotation on
Rocky in the roadmap changelog and Phase 4 checklist, and notes current
status in README/DEVELOPMENT_AND_STAGING. VPS deployment of these fixes is
still pending.
2026-08-24 12:02:27 +03:00
b0txec 8c45d8de1d Fix SQL injection in the /query/city aggregation route
field on that route reached PostgresService.query unvalidated, which splices
it into SQL via Fragment.const (unescaped) whenever the aggregate key is
min/max/avg/sum/distinct, or whenever granularity is "hour" in the list
branch. Add ValidateField (allowlists WeatherData's known field names, same
pattern AggFieldList already uses for /query/country) and apply it to the
field path segment. AggregateKey values reaching Fragment.const elsewhere are
already safe since they come from a closed ADT, not raw user input.
2026-08-24 11:55:52 +03:00
b0txec 6b9c7cf4ae Fix path traversal, auth-bypass, and DoS findings from the security review
- Add ValidateFileName (allowlist regex, rejects .. and separators) and apply
  it to every route that concatenates a raw path segment into a filesystem or
  remote FTP path: /show/lvgmc-forecast, /show/grib, /grib/binary-chunk, and
  /debug/file. Previously an unauthenticated caller could read arbitrary
  files, including /proc/self/environ (leaks LVGMC_PASSWORD/POSTGRES_PASSWORD).
- Harden ValidateInt to reject negative integers.
- Gate /api/fetch/lvgmc/stations behind ENABLE_LVGMC_FTP_JOBS so it can no
  longer trigger a real, unauthenticated FTP login regardless of the flag;
  stop leaking error.getMessage in its response.
- Add an explicit /api/* catch-all (NotFound) so an unmatched API route can
  never fall through to the SPA fallback and be served index.html as a 200.
- Cap binary-chunk read length at 64MB to prevent an unbounded allocation.
2026-08-24 11:50:53 +03:00
b0txec fdd5508e43 Fix a stale README line still claiming FTP runs on schedule 2026-08-24 11:00:40 +03:00
b0txec 546ad46546 Record the SPA fallback fix and the morning FTP re-test finding
The SPA fallback (2c44888/b0b58d2) is deployed to VPS as b0b58d2,
verified via curl and headless-browser on real routes, a missing
asset, and /api, both locally and through the public domain.

Also updated the FTP saga: re-testing this morning found a Rocky-only
failure with the VPS confirmed off, which the two-machine-collision
theory from last night can't explain. Decided to stop self-testing
and wait for a real answer from LVGMC about the account's connection
policy rather than keep guessing through trial and error.
2026-08-24 10:42:12 +03:00
b0txec b0b58d2f1e Keep a missing /assets file a real 404 in the SPA fallback
A stale browser tab referencing a bundle removed by a later deploy
should get a clean 404, not HTML served where JS was expected.
2026-08-24 10:35:34 +03:00
b0txec 2c4488846e Add a general SPA fallback instead of an explicit per-route list
Direct hits on client-side routes not in the hardcoded list (e.g. a
browser refresh on /faktiska or /udens-temperatura) 404ed instead of
loading the app — a known limitation that was actually hit in
production. Real files now serve as-is; anything else falls back to
index.html so the SolidJS router handles it, matching the existing
"TODO rewrite in more generic way" comment. Future routes need no
backend changes.
2026-08-24 10:32:38 +03:00
22 changed files with 748 additions and 214 deletions
+47
View File
@@ -0,0 +1,47 @@
name: CI
on:
push:
branches:
- codex/staging-baseline
pull_request:
branches:
- codex/staging-baseline
workflow_dispatch:
jobs:
backend:
runs-on: docker
container:
image: hseeberger/scala-sbt:17.0.2_1.6.2_2.13.8
steps:
- name: Install checkout runtime
run: |
apt-get update
apt-get install --yes --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install --yes --no-install-recommends nodejs
rm -rf /var/lib/apt/lists/*
- uses: actions/checkout@v4
- name: sbt test
run: sbt -batch test
frontend:
runs-on: docker
container:
image: node:20-bookworm
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- name: npm ci
run: npm ci
- name: typecheck
run: npm run typecheck
- name: build
run: npm run build
- name: audit (full)
run: npm audit
- name: audit (production only)
run: npm audit --omit=dev
+40 -66
View File
@@ -1,77 +1,51 @@
# Weather Tool
# WeatherTool
Tool for downloading historical and real-time data from weather stations in Latvia. It aggregates and displays data for different time periods, cities, and weather parameters.
WeatherTool prepares the weather graphics used on air: current temperatures,
water temperatures, and severe-weather warnings, rendered as fixed-size PNG
exports ready to drop into a broadcast. It's a large update to the tool
currently running in production at `laikazinas.lsm.lv`.
## Setup
Rename `.env-sample` to `.env` and fill in credentials
## What it does
## Environment boundary
- Pulls real observation data from LVĢMC (Latvia's national weather
service) — current station temperatures, water temperatures, and active
warnings — from their public open-data feed.
- Lets an operator review and, where needed, correct individual values
before export, without touching the underlying data.
- Renders that data onto fixed newsroom map templates and exports
broadcast-ready PNGs at the station's required resolutions.
- Also includes analytical tools (historical station comparisons, a full
data archive) for exploring the underlying weather data beyond what goes
on air.
- **Windows:** source editing, review, and Git operations only. Do not compile, build, run containers, install project dependencies, or test the application here.
- **Rocky Linux:** the only development runtime; compile, build, run, seed, and test WeatherTool here.
- **Ubuntu VPS:** deployment target only. It receives the reviewed release artifact built and verified on Rocky; it is not a development or build host.
## How it's built
## Run
```
docker-compose up --build --no-cache --force-recreate
```
A Scala backend (cats-effect, http4s, PostgreSQL) ingests and serves the
data; a SolidJS frontend is where operators build the graphics. Both are
described in full under [`docs/`](docs/README.md).
## Web
[http://0.0.0.0:9090/](http://0.0.0.0:9090/)
## Status
## nginx proxy config
```
server {
listen 80;
server_name laikazinas.lsm.lv;
Actively in development. All development, testing, and staging currently
happens on a single Rocky Linux machine; a temporary public test
environment for the new version is at `laikapstak.li`, gated behind
authentication, separate from the live `laikazinas.lsm.lv` service it will
eventually replace. Continuous integration runs automatically on every
push via Gitea Actions.
location / {
proxy_pass http://localhost:9090; # Forward requests to the Scala app
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
## Security
## Rocky development runtime
```
// 1st terminal
podman-compose up postgres
The application has been through an independent security review, with
findings (including a critical one) found and fixed — see
[`docs/UPDATE_ROADMAP.md`](docs/UPDATE_ROADMAP.md) for the full history.
The public test environment sits behind Cloudflare, an authentication
gate, and a private database with no public port. Deployments are manual,
checksummed, and smoke-tested before going live — see
[`docs/VPS_RELEASE_RUNBOOK.md`](docs/VPS_RELEASE_RUNBOOK.md).
// 2nd terminal
sbt run
## Getting started
// 3rd terminal
cd web/
npm run dev
```
Run one file
```sbt "runMain grib.GribParserTest"```
## Backend Tech:
- scala: cats-effects, http4s, fs2, circe, scalatest
- postgres
## Frontend Tech:
- SolidJS
- Vite
## Fly commands
```
// suspend instance
fly scale count 0
```
```
// check display
fly ssh console
df -h
```
```
fly scale memory 512
```
See [`docs/README.md`](docs/README.md) for current status, architecture,
and the full documentation set, and [`docs/DEVELOPMENT_AND_STAGING.md`](docs/DEVELOPMENT_AND_STAGING.md)
for exact build, run, and deployment commands. For local setup, copy
`.env-sample` to `.env` and fill in real values.
+170
View File
@@ -0,0 +1,170 @@
# Continuous integration
Last verified: 2026-08-24
## Current topology
WeatherTool uses Gitea Actions for continuous integration:
```text
bot/WeatherTool repository on gitea.packet.garden
→ repository-scoped weathertool-ci-rocky-01 runner on the coding PC
→ dedicated Docker-in-Docker daemon
→ disposable per-job containers (backend: hseeberger/scala-sbt, frontend: node:20-bookworm)
```
Gitea does not execute workflow commands itself. The runner polls Gitea for
jobs and executes them. The runner is registered only to `bot/WeatherTool`;
it is not a user-, organization-, or instance-wide runner.
The runner host is the Rocky Linux coding PC — the same machine already used
for all compile/build/test/staging work. This is a second, independent
runner instance on that host: HOP already runs its own Forgejo runner there
(`hop-forgejo-runner-*`, registered to a different Forgejo instance's
`bot/hop` repo). The two don't conflict — each is a separate long-polling
client registered to a different server with its own token, own Docker
network, and own Docker-in-Docker daemon; they only share host CPU/RAM,
which is a non-issue for occasional CI runs.
## Isolation boundary
Same pattern as HOP's Forgejo runner: the runner uses a dedicated
Docker-in-Docker daemon rather than exposing the coding PC's normal Docker
socket to workflow containers. The nested daemon:
- runs in its own privileged container because a nested Docker daemon
requires that capability;
- exposes no port on the host or LAN;
- is reachable only through the runner's private Compose network; and
- stores its images and state in the `weathertool-gitea-runner_dind_data`
volume.
This prevents ordinary WeatherTool workflow jobs from enumerating or
mutating the coding PC's normal development containers (including the
Rocky staging stack itself).
## Host-local runner files
The active runner is operated from:
```text
/home/sandbox/.local/share/weathertool-gitea-runner/
compose.yml
runner-token
```
The directory is mode `0700`. `runner-token` is mode `0600`. It is
deliberately outside the WeatherTool Git repository and was written directly
to disk from an interactive prompt — it was never printed, pasted into
chat, or copied into project documentation.
Normal operator commands are run from the directory above:
```bash
docker compose up -d
docker compose ps
docker compose logs --tail=100 runner
docker compose pull
```
The expected steady state is:
- `docker-in-docker`: `Up (healthy)`;
- `runner`: `Up`; and
- `weathertool-ci-rocky-01`: `Idle` in Gitea when no job is queued.
## Triggering CI
CI only runs on a push (or PR) reaching the `gitea` remote — pushing to
`rocky` alone does not trigger it, since Gitea has no visibility into that
private bare repo. To avoid needing two separate `git push` commands (and
forgetting one), a combined `all` remote pushes to both `rocky` and `gitea`
in one command:
```bash
git push all codex/staging-baseline
```
`all` deliberately does not include `origin` (the GitHub
`guntisdev/WeatherTool` repo) — see `DEVELOPMENT_AND_STAGING.md`'s commit
workflow section for why.
`Idle` is healthy. It means the runner is authenticated and polling for
work. The runner is available only while the coding PC, Docker, and these
containers are running.
`compose.yml` sets `HOME: /data` on the runner container. Without it, the
first real run failed every job, both times it was tried, with `Unable to
clone https://github.com/actions/checkout ...: mkdir /.cache: permission
denied` — this was initially misdiagnosed (both in the runner's own startup
log, which only calls it "cache server disabled", and in this doc's first
draft) as merely disabling the optional `actions/cache` action type. It
actually blocks resolving *any* remote action at all: with uid 1000 and no
writable `$HOME`, the runner has nowhere to git-clone an action's source
into before running it, and `actions/checkout` is exactly that. `HOME=/data`
(the already-writable bind-mounted data directory) fixed it.
## WeatherTool workflow
The version-controlled workflow is:
```text
.gitea/workflows/ci.yml
```
It runs for:
- pushes to `codex/staging-baseline`;
- pull requests targeting `codex/staging-baseline`; and
- manual `workflow_dispatch` requests.
Two independent jobs, each with its own container image rather than one
shared runner-label image with ad-hoc installs — this reuses images already
proven for this project rather than bootstrapping a second language runtime
into a single shared container:
- **`backend`** (`hseeberger/scala-sbt:17.0.2_1.6.2_2.13.8`, the same image
used all session for local Rocky builds): installs a real Node 20 via
NodeSource's setup script (see below for why), checkout, `sbt -batch
test`.
- **`frontend`** (`node:20-bookworm`): checkout, `npm ci`, `npm run
typecheck`, `npm run build`, `npm audit`, `npm audit --omit=dev` — the
same five checks already documented as the manual Rocky verification
routine in `DEVELOPMENT_AND_STAGING.md`.
Verified before writing the workflow that `sbt test` compiles and passes
with no `.env` file present at all (via a `git archive HEAD` dry run into a
clean scratch directory) — matching exactly what a checkout-only CI job
actually has, since real credentials must never reach CI. The sbt-dotenv
plugin logs a graceful warning and continues; nothing in the current test
suite (`FileNameServiceSpec`; `ParserSpec` has no live assertions) touches
`sys.env` or a live database.
`actions/checkout` is a JavaScript action — it needs a Node runtime inside
the job container to execute at all, which `hseeberger/scala-sbt` doesn't
have. `frontend`'s image already has one, so it passed as soon as the
`HOME` fix landed. `backend` needed an explicit install step first
(`apt-get install nodejs` alone wasn't enough either: `hseeberger/scala-sbt`
is Debian **bullseye**-based, whose default apt `nodejs` package is a stale
Node 12, too old to parse `actions/checkout@v4`'s modern JS —
`SyntaxError: Unexpected token '{'` on a class static block. NodeSource's
`setup_20.x` script installs an actual current Node 20 regardless of the
distro's packaged version).
None of the three bugs above were caught by writing the workflow carefully
or by the pre-write `sbt test` dry run — all three only surfaced by actually
running it and reading the real failure in the Gitea Actions UI, once per
bug, in order: the `HOME`/cache fix, then the missing-Node fix, then the
wrong-Node-version fix. The first four runs (`ci.yml #1``#4`) all failed.
The first complete green run was `ci.yml #5`, commit `2c78ae8`, 2m24s,
verified 2026-08-24.
## Current boundary
This workflow is CI only. A green result proves the committed source
compiles, passes its (currently minimal) test suite, typechecks, builds,
and has no known frontend dependency vulnerabilities. It does not deploy to
Rocky staging or the VPS, publish an image, or access any staging/production
secret — the runner's job containers never see `.env`, `.env.staging`, or
any real provider credential. Continuous delivery, and expanding actual test
coverage (see the roadmap's Phase 2), remain separate, not yet started work.
+14 -2
View File
@@ -54,9 +54,19 @@ git status --short
git diff --check
git add <files>
git commit -m "Describe the coherent change"
git push rocky codex/staging-baseline
git push all codex/staging-baseline
```
`all` is a combined remote (added 2026-08-24, see `CONTINUOUS_INTEGRATION.md`)
with two push URLs — `rocky` and `gitea` — so one push updates both the
staging checkout's source and triggers CI in a single command, instead of
two separate `git push` calls that are easy to forget one half of. It does
not include `origin` (the GitHub `guntisdev/WeatherTool` repo) — whether
that joins later depends on a still-open question about that repo's
ownership/involvement, expected to resolve around 2026-08-26. Push there
manually (`git push origin codex/staging-baseline`) if needed in the
meantime; the plain `rocky`/`gitea` remotes still exist individually too.
On Rocky, update the staging checkout:
```bash
@@ -116,7 +126,9 @@ docker compose run --rm --no-deps node npm audit --omit=dev
At commit `de6f279`, all five checks pass and both audit scopes report zero known vulnerabilities. The npm major-version availability notice is informational and does not require changing the npm version independently of the pinned Node build image.
Known limitation: direct browser refreshes on newer client-side routes such as `/faktiska` can return 404 because the backend static-route list does not yet provide a general SPA fallback. Navigate from the home page until that backend behavior is fixed.
Fixed 2026-08-24 (`2c44888`): direct browser refreshes on any client-side route now correctly serve the app via a general SPA fallback, replacing the old hardcoded per-route static-file list.
Fixed 2026-08-24 (`6b9c7cf`, `8c45d8d`): a fresh-eyes security review found a live-verified CRITICAL path-traversal vulnerability and several HIGH/MEDIUM findings (unauthenticated FTP-trigger route, SQL injection via an unvalidated `field` path segment, unbounded-allocation DoS, `/api/*` falling through to the SPA shell) — all fixed and live-verified on Rocky; see the roadmap changelog for details. Rocky's `POSTGRES_PASSWORD` was rotated afterward via `ALTER ROLE` against the running container, since the old value had been exposed into the review's output.
## Authenticated API requests
+4 -5
View File
@@ -46,7 +46,7 @@ Current operator flow:
1. Open Faktiskā; the fixed station set and latest `tempAvg` observations load automatically.
2. Review the observation timestamp, missing/stale indicators, and temperatures.
3. Override any temperature manually when editorial correction is required; reset restores the fetched value.
3. Override any temperature manually when editorial correction is required, either by clicking the badge directly on the map preview (fast path for the common one-or-two-value tweak) or via the "Rādīt stacijas" panel (all 13 at once); reset restores the fetched value. Both paths share the same override state.
4. Enter wind values manually.
5. Select a weather symbol, apply it to all stations where appropriate, and adjust exceptions.
6. Review and download `faktiska-3840x1440.png`.
@@ -62,7 +62,7 @@ Fixed positions:
| 4 | Rīga |
| 5 | Jelgava |
| 6 | Ainaži |
| 7 | Valmiera |
| 7 | Valmiera, sourced from Priekuļi (temporary substitute, see below) |
| 8 | Madona |
| 9 | Alūksne |
| 10 | Zīlāni (temporary substitute for Jēkabpils) |
@@ -83,7 +83,7 @@ Completed Faktiskā checkpoint:
- automatic badge-relative image placement using consistent 256×256 asset geometry;
- guarded 3840×1440 export using the fixed wind production artwork.
Confirmed Monda Regular/Bold files are now bundled and used. The export dimensions are locked, but final pixel-level placement must still be compared against the production masters after the font change. As of 2026-08-23 the 12 other fixed positions load real station data; Valmiera has no matching real open-data station yet, so it currently still shows the last synthetic row generated before synthetic data generation was stopped (increasingly stale, since nothing refreshes it). Once the old synthetic rows are wiped from `weather` (a still-pending step — see `docs/UPDATE_ROADMAP.md` Phase 7), Valmiera will show no data until a real source is found.
Confirmed Monda Regular/Bold files are now bundled and used. The export dimensions are locked, but final pixel-level placement must still be compared against the production masters after the font change. All 13 fixed positions now load real station data. Valmiera itself has no matching LVĢMC open-data station (confirmed live 2026-08-23); as of 2026-08-25 that position queries Priekuļi's reading instead, chosen as the closest currently-reporting station by measuring against the app's own calibrated map coordinates (~56px away vs. the next-closest candidate's ~81px). Valmiera's marker position and on-map label are unaffected — only the underlying data source for that slot changed. This is a provisional stand-in pending direct confirmation from LVĢMC.
## Ūdens
@@ -151,7 +151,6 @@ Development limitation: representative forecast CSV fixtures are not currently i
## Decisions still required
- Whether weather conditions can eventually be populated automatically and which provider field is authoritative.
- Whether Stende and Zīlāni remain the production feeds after users test real provider data.
- Confirm how Valmiera is named and sourced when authorized provider data is available.
- Whether Stende, Zīlāni, and now Priekuļi (for Valmiera) remain the production feeds after users test real provider data — Priekuļi is a provisional pick pending direct confirmation from LVĢMC.
- Apply the same native-resolution production comparison to additional broadcast products as they are finalized.
- Required authentication and role separation for analytical, production, and administrative workspaces.
+13 -6
View File
@@ -1,28 +1,33 @@
# WeatherTool project documentation
This directory contains the working documentation for the WeatherTool modernization effort. The repository-root `README.md` is preserved as the original project overview; these documents describe the reviewed code, current staging environment, and changes being developed.
This directory contains the working documentation for the WeatherTool modernization effort. The repository-root `README.md` is a short, non-technical overview for anyone landing on the repo (purpose, status, security posture); these documents describe the reviewed code, current staging environment, and changes being developed in full technical depth.
## Current status
- Windows is restricted to source editing, review, and Git operations. Rocky is the sole compile, build, development-runtime, and test environment. The Ubuntu VPS is a deployment target only.
- A production-like staging copy runs through Docker Compose on Rocky Linux at `http://192.168.1.101:9190`.
- Rocky now ingests real data from two independent LVĢMC sources at once: the free `data.gov.lv` open-data feed and the private FTP feed (`ftp.meteo.lv`, real credentials obtained and verified 2026-08-23). The VPS currently runs open-data only — FTP was briefly enabled there too but reverted the same night after the `ltv` account failed to authenticate from the VPS specifically (see below). The two providers are gated independently (`ENABLE_LVGMC_FTP_JOBS`, `ENABLE_HARMONIE_JOBS`) since real credentials for FTP and DMI HARMONIE arrive on different timelines. HARMONIE's credentials turned out not to be needed at all (DMI dropped its API key requirement — confirmed live, see the roadmap changelog) but wiring it up is deferred to a dedicated verification session, since the GRIB-parsing and map-rendering code has real, untested risk (a hardcoded crop/rotation calibration that may not match the current model grid).
- Both Rocky and the VPS currently run on the free `data.gov.lv` open-data feed only. Real private FTP credentials (`ftp.meteo.lv`) were obtained 2026-08-23 and the code path is proven working (manual tests succeeded repeatedly, including once from Rocky as recently as 2026-08-24 morning), but enabling the *scheduled* fetch has failed unpredictably on both machines at different times, for reasons not yet understood — see the roadmap Phase 7 for the full timeline. `ENABLE_LVGMC_FTP_JOBS` stays `false` everywhere until the LVGMC contact can explain the account's actual connection/rate policy. DMI HARMONIE's credentials turned out not to be needed at all (DMI dropped its API key requirement — confirmed live, see the roadmap changelog) but wiring it up is deferred to a dedicated verification session, since the GRIB-parsing and map-rendering code has real, untested risk (a hardcoded crop/rotation calibration that may not match the current model grid).
- A real UTC-vs-local timezone mismatch between the two sources (open-data's timestamps were UTC, FTP's already local, both stored in the same column with no conversion) was found and fixed before enabling both together; both `weather` tables were backed up and wiped for a clean, consistently-timestamped restart.
- The safe scheduled jobs (open-data station ingestion, GRIB cleanup) always run; the FTP job now runs too since real credentials exist; HARMONIE's job stays off pending its own implementation work.
- The safe scheduled jobs (open-data station ingestion, GRIB cleanup) always run; the FTP and HARMONIE jobs both stay off — FTP pending the LVGMC answer above, HARMONIE pending its own implementation/verification work.
- PostgreSQL is private to the project Compose network; only the Scala application publishes a host port.
- The operator-facing workspaces now use the Latvian workflow names **Stacijas**, **Kartes**, **Faktiskā**, **Ūdens**, **Brīdinājumi**, **Apskats**, **Arhīvs**, **Harmonie**, and **LVĢMC**. Kartes retains custom analytical map outputs, while Faktiskā is a fixed 13-position, latest-temperature newsroom workflow with a locked 3840×1440 export.
- Faktiskā symbol placement is automatic after manual image selection and is anchored to each rendered temperature badge.
- Faktiskā symbol placement is automatic after manual image selection and is anchored to each rendered temperature badge. Temperature values can be corrected two ways: clicking a badge directly on the map preview (fast path for a one-off tweak) or the "Rādīt stacijas" panel (bulk view, timestamps, stale/missing indicators, reset) — both share the same override state. Valmiera's position sources from Priekuļi's real station reading as a provisional substitute, since LVĢMC has no station in or near Valmiera itself; pending confirmation from LVĢMC directly.
- A August-25 frontend pass added shared side padding on previously edge-to-edge pages (Kartes, Stacijas, Apskats), fixed a real CSS collision in Kartes' results grid, removed dead CSS/JSX found via a systematic per-class usage check, reworked Ūdens's six temperature cards to fit one row (`auto-fit` grid, catching and fixing a real narrow-width input-clipping bug along the way), and matched Ūdens's resolution buttons to the cards' visual style.
- **Ūdens** auto-populates its six ranges on load with real per-zone water-temperature min/max (65 LVĢMC stations classified into the 6 named zones), with manual override and reset still available. Uses separate authoritative 1920×1080 and 3840×1440 production templates; both exports have been visually validated.
- **Brīdinājumi** renders current LVĢMC warning polygons over a production border overlay with feathered severity fills, plus draggable/resizable per-warning weather-symbol placement. Its lon/lat-to-pixel projection is an affine fit calibrated against the same validated city pixel positions Kartes/Faktiskā already use, replacing an earlier bounding-box calibration that drifted up to ~200px on the 3840 canvas.
- Confirmed local Monda Regular/Bold files provide interface and generated-graphic typography; weather symbols use normalized transparent image assets.
- Release `3eddf95` is deployed as immutable image `weathertool:3eddf95008b103f34d50dbc86c5634d3c0fa3523`; release `e446f1ff` remains the immediate application rollback. On top of the earlier frontend design pass (visual palette rework, Faktiskā decluttering, header nav, weather-icon CSS consolidation), this release fixes the UTC-vs-local timezone mismatch described above, hardens the `weather` table upsert to be non-destructive across sources (`COALESCE` instead of a blind overwrite), and splits the scheduler flag so FTP and HARMONIE can be enabled independently. `deploy/vps/compose.yml` was also fixed to actually read `LVGMC_*` from `.env.staging` (it was hardcoding inert placeholders directly in the file before, so real credentials alone wouldn't have taken effect) — real credentials and `ENABLE_LVGMC_FTP_JOBS=true` are live on the VPS as of 2026-08-23.
- Release `b0b58d2` (`weathertool:b0b58d2f1e0ed47ca13795b64595386ec2f0e0c7`) replaced the hardcoded per-route static-file list with a general SPA fallback — a real, user-reported bug where refreshing `/faktiska` or `/udens-temperatura` 404ed instead of loading the app (those two routes were never added to the old list). A missing `/assets` file still 404s properly rather than silently serving HTML.
- An independent fresh-eyes security review (requested against the local Rocky version only) found a live-verified CRITICAL path-traversal vulnerability plus several HIGH/MEDIUM findings — see the roadmap changelog for `6b9c7cf`/`8c45d8d` for the full list. All are fixed and deployed. Rocky's `POSTGRES_PASSWORD` was rotated afterward since the old value had been exposed into the review's own output via `/proc/self/environ`. `LVGMC_PASSWORD` rotation is not yet done — that needs the user's own action via the LVGMC contact. A follow-up independent review then caught that the FTP auth-bypass fix was incomplete (a sibling route, `/api/show/lvgmc-forecast`, had the same live-FTP-trigger issue but had only gotten the traversal fix); fixed as `9bab93d`. That same review flagged several open architectural/testability findings (the `parMapN` scheduler crash-loop pattern, per-request `System.gc()`, an unenforced field-routing invariant, no JDBC connection pooling, and several files under `src/main/scala` that look like tests but aren't) — not yet acted on, tracked for a future session.
- Release `a6b2b84` is deployed as immutable image `weathertool:a6b2b8462c9adf1647ebddf92edb0e41d345d6e7`; release `001b014` remains the immediate application rollback. Fixes a real bug reported live by the user: Faktiskā showed blank temperatures for a stretch after every new hour started, because the "latest observation" query picked whichever row was newest regardless of whether its temperature field had actually landed yet (the open-data source publishes some fields, like snow, well before the hourly temperature aggregate). Isolated to Faktiskā — Kartes and Ūdens use different query shapes unaffected by the same timing gap. Also carries a small hover/focus-state and checkbox-styling polish pass. See the roadmap changelog for `a6b2b84`/`829319e`.
- Gitea Actions CI is live: a repository-scoped runner on Rocky (isolated behind its own Docker-in-Docker daemon, same pattern as HOP's Forgejo runner) runs `sbt test` plus the frontend typecheck/build/audit routine on every push/PR to `codex/staging-baseline`. See [Continuous integration](CONTINUOUS_INTEGRATION.md). CI-only for now — it does not deploy anywhere or touch any real credential.
- FTP (`ENABLE_LVGMC_FTP_JOBS`) is currently `false` on both Rocky and the VPS — re-testing the morning after enabling it turned up a second, unexplained failure (Rocky alone, VPS confirmed off, ~4 minutes after a successful manual test) that doesn't fit the original two-machine-collision theory. Decided to stop self-testing via trial and error and wait for the user to ask the LVGMC contact directly about the `ltv` account's connection/rate policy, rather than risk repeatedly tripping an unknown limit. See the roadmap Phase 7 for the full diagnosis timeline.
- The isolated VPS UAT stack is running and healthy: WeatherTool is bound to `127.0.0.1:8002`, Authelia to `127.0.0.1:9091`, and PostgreSQL has no host port. Public access is routed through Cloudflare, Nginx, and Authelia.
- Cloudflare delegation is active, strict origin TLS covers only `laikapstak.li` and `auth.laikapstak.li`, and the public Nginx/Authelia login flow is operational without changing the existing HOP site.
- The VPS `weather` table was wiped twice on 2026-08-23: first to remove the original 14-day synthetic dataset (backed up to `/srv/weathertool/backups/pre-real-data-release/`), then again after the UTC/local timezone fix (backed up to `/srv/weathertool/backups/pre-timezone-fix-wipe/`) so the two real sources it now holds — open-data (minutes 15/45) and FTP (minutes 11/13/23/30) — are consistently timestamped from a clean start.
- Approved 1920×1080 and 3840×1440 PNG production bases are now the rendering source for Faktiskā and Ūdens temperatūra; code draws only the changing values, selected weather symbols, and wind data over those fixed newsroom graphics.
- Browser branding assets and Latvian Open Graph/Twitter metadata are included for favicon, Apple home-screen icon, and link-preview support. Public crawler access still depends on the Nginx/Authelia policy used for the metadata and preview image.
- Browser and API verification is complete for the deployed `3eddf95` release: exact release image smoke-tested on Rocky before transfer (bundle hash and correct-local-time API responses matched the known-good local build), checksum verified on both ends, container health/loopback/public HTTPS confirmed.
- **VPS FTP is currently disabled again** (`ENABLE_LVGMC_FTP_JOBS=false`), despite being enabled earlier tonight. Watching for its first scheduled fetch (rather than assuming it was safe just because Rocky worked) caught a real problem: the `ltv` account fails to authenticate from the VPS specifically, and because a scheduled-task failure cascades through `parMapN` up to the app's top-level error handler, this was actively crash-looping the entire app in-process (not a Docker-level restart, but the whole app — DB, HTTP server, schedulers — tearing down and rebuilding every ~5s, repeating every ~2 minutes as the schedule re-fired). Reverted immediately; confirmed stable afterward. VPS network connectivity to `ftp.meteo.lv:21` itself is fine (verified with a raw connection test); this is specifically the `ltv` account failing from the VPS's IP, not a firewall issue — see the roadmap changelog for the full diagnosis and next steps (check the password transcription, or ask LVGMC whether the account is IP-restricted). FTP stays enabled on Rocky, where it's confirmed working.
- Every FTP failure so far has cascaded through `parMapN` into the app's top-level error handler, crash-looping the *entire* app in-process (DB, HTTP server, schedulers all torn down and rebuilt every ~5s) rather than just failing the one scheduled task — caught twice by watching logs after enabling the flag rather than assuming it was safe, reverted both times within minutes.
- Frontend dependency maintenance is complete: Solid runtime and Vite tooling were updated, obsolete packages were removed, TypeScript checking was added, and a clean Rocky `npm ci`, typecheck, production build, full audit, and production-only audit all pass with zero known vulnerabilities.
- This is not yet approved or hardened for workplace production.
@@ -32,8 +37,10 @@ This directory contains the working documentation for the WeatherTool modernizat
- [Development and staging](DEVELOPMENT_AND_STAGING.md) — Windows source/Git workflow, Rocky development and verification, VPS deployment, synthetic data, and rollback.
- [Product workflows](PRODUCT_WORKFLOWS.md) — the intended purpose and current status of each visible workspace.
- [Update roadmap](UPDATE_ROADMAP.md) — phased technical, security, dependency, testing, and UI work.
- [Continuous integration](CONTINUOUS_INTEGRATION.md) — Gitea Actions topology, isolation boundary, and the current CI workflow.
- [Third-party notices](THIRD_PARTY_NOTICES.md) — licenses and attribution for adapted interface components.
- [Temporary VPS staging plan](VPS_STAGING_PLAN.md) — isolation, authentication, prepared deployment bundle, release, backup, verification, and rollback model for external user testing.
- [VPS release runbook](VPS_RELEASE_RUNBOOK.md) — the literal, copy-pasteable command sequence for shipping and rolling back a release, step by step.
## Documentation rules
+50 -20
View File
@@ -7,7 +7,7 @@ This document tracks proposed WeatherTool improvements. Work should be delivered
- **Windows source workspace:** source editing, review, and Git operations only; do not install dependencies, compile, build, run, or test here.
- **Rocky development and staging:** the sole compile, build, development-runtime, and test environment, with production-like Docker staging at `http://192.168.1.101:9190`.
- **Git over SSH:** Windows pushes reviewed commits to a private bare repository on Rocky; the Rocky staging checkout pulls those commits and rebuilds.
- **Ubuntu VPS deployment:** release `3eddf95` is publicly operational behind Cloudflare strict TLS, Nginx, and Authelia, ingesting real LVĢMC open-data station/water-temperature observations on a schedule; the `weather` table was wiped and re-populated fresh once the UTC/local timezone mismatch between sources was fixed. FTP (`ENABLE_LVGMC_FTP_JOBS`) was briefly enabled 2026-08-23 but reverted to `false` the same night after the `ltv` account failed to authenticate from the VPS specifically (crash-looped the app in-process until caught and reverted) — real credentials work fine from Rocky, not yet from the VPS; see the changelog for full diagnosis. The VPS does not compile or build the project.
- **Ubuntu VPS deployment:** release `b0b58d2` is publicly operational behind Cloudflare strict TLS, Nginx, and Authelia, ingesting real LVĢMC open-data station/water-temperature observations on a schedule; the `weather` table was wiped and re-populated fresh once the UTC/local timezone mismatch between sources was fixed. FTP (`ENABLE_LVGMC_FTP_JOBS`) stays `false` on both Rocky and the VPS pending a real answer from the LVGMC contact about the `ltv` account's connection/rate policy — see Phase 7 for the full, still-unresolved diagnosis. The VPS does not compile or build the project.
- **Workplace production:** remains separate until changes are reviewed, tested, and explicitly approved for workplace use.
Do not synchronize `.env`, database directories, generated dependencies, build output, or provider credentials between machines.
@@ -57,9 +57,10 @@ Status: in progress
- [ ] Add database integration tests for aggregation and export behavior.
- [ ] Restore and expand CSV parser tests.
- [ ] Add GRIB parser boundary and malformed-file tests.
- [ ] Add security tests for invalid fields, filenames, offsets, lengths, and date ranges.
- [ ] Add frontend type checking and critical workflow smoke tests.
- [ ] Run tests automatically before staging deployment.
- [ ] Add security tests for invalid fields, filenames, offsets, lengths, and date ranges. Highest-priority target per an independent review: `ValidateFileName`/`ValidateField`/`ValidateInt` are pure functions with zero I/O and are currently the entire path-traversal/SQL-injection security boundary, verified only by manual curl.
- [ ] `PostgresService.query`'s "list" branch (`byField` match, ~13 hardcoded literal cases, no `case _ =>`) is only safe today because `ValidateField`'s allowlist and `WeatherData`'s case class fields happen to stay in sync with it — nothing enforces that. Add a field to `WeatherData` without updating this match and any request for it with `granularity=hour` throws an uncaught `MatchError` (a real risk given the DMI/open-data provider work in progress). A test iterating every `WeatherData.getKeys` value through this path would catch it before it ships; needs either a test-container Postgres or refactoring SQL-fragment-building apart from execution so it's testable without a DB.
- [x] Add frontend type checking (`npm run typecheck`, part of the dependency-maintenance work in Phase 3) — now also runs automatically in CI (see below). Critical workflow smoke tests remain manual/headless-browser only, not automated.
- [ ] Run tests automatically before staging deployment. Partial: Gitea Actions CI (see `CONTINUOUS_INTEGRATION.md`) now runs `sbt test` and the frontend typecheck/build/audit routine automatically on every push/PR to `codex/staging-baseline` — this is CI, not CD; it isn't yet wired as a required gate before Rocky/VPS deployment, which remains a manual decision independent of CI status.
## Phase 3 — Dependency modernization
@@ -88,12 +89,12 @@ Dependency changes must not be combined with a visual redesign unless a package
Status: pending
- [ ] Rotate and remove the API key exposed in a source comment (`src/main/scala/fetch/dmi/FetchService.scala:56`, a DMI HARMONIE key introduced 2025-02-01; confirmed the repo's GitHub `origin` is public, so this has been externally exposed for roughly 18 months — decision on rotation vs. a git-history rewrite is still open).
- [ ] Remove credentials from connection-error messages.
- [ ] Protect or remove debug and administrative endpoints.
- [x] Remove credentials/internal error details from connection-error messages: `/api/fetch/lvgmc/stations` was returning `error.getMessage` straight to the caller; now logs it server-side and returns a generic message.
- [ ] Protect or remove debug and administrative endpoints. Partial: `/api/fetch/lvgmc/stations` (the one that could trigger a real FTP login) is now gated behind `ENABLE_LVGMC_FTP_JOBS`, but `/api/debug/time`, `/api/debug/folder-structure`, and `/api/debug/delete-tmp` remain unauthenticated.
- [ ] Convert state-changing `GET` routes to appropriate methods.
- [ ] Introduce closed, validated weather-field and aggregation types.
- [ ] Eliminate raw user-controlled SQL identifiers.
- [ ] Validate and constrain filenames, resolved paths, offsets, and byte lengths.
- [ ] Introduce closed, validated weather-field and aggregation types. Partial: the one route that let a raw field string reach SQL now validates against `WeatherData.getKeys` (see below), but this is a route-level allowlist, not a closed type threaded through the codebase.
- [x] Eliminate raw user-controlled SQL identifiers: `/api/query/city/.../{field}/...` let an arbitrary path segment reach `Fragment.const` unescaped; added `ValidateField`, the same allowlist-against-`WeatherData.getKeys` pattern `/query/country` already used.
- [x] Validate and constrain filenames, resolved paths, offsets, and byte lengths: added `ValidateFileName` (allowlist regex, rejects `..` and separators including decoded `%2f`) and applied it to every route that concatenated a raw path segment into a filesystem or FTP path (`/debug/file`, `/show/grib`, `/grib/binary-chunk`, `/show/lvgmc-forecast`); hardened `ValidateInt` to reject negative integers; capped `binary-chunk` read length at 64MB to remove an unbounded allocation.
- [ ] Add query-range, response-size, request-rate, and timeout limits.
- [ ] Restrict CORS to intended origins.
- [ ] Define authentication and authorization requirements for workplace deployment.
@@ -103,8 +104,9 @@ Status: pending
Status: pending
- [ ] Manage custom executors as resources and close them cleanly.
- [ ] Remove explicit `System.gc()` calls.
- [ ] Supervise scheduled jobs independently instead of recursively restarting the application.
- [ ] Remove explicit `System.gc()` calls. Confirmed still present in `DataService.getBinaryChunk` (runs on every GRIB binary-chunk request — likely a latency source on what's probably a hot path; a commented-out `logMemory` block next to it suggests leftover debugging code, not an intentional design choice), per a 2026-08-24 independent review.
- [ ] Supervise scheduled jobs independently instead of recursively restarting the application. Confirmed still fully live by a 2026-08-24 independent review: `Scheduler.scheduleTask` has zero `.attempt`/error handling on any task, none of the task bodies wired into it (FTP fetch, open-data fetch, cleanup, GRIB fetch) handle their own errors either, and `(serverTask, scheduledTasks).parMapN(...)` means any one failure cancels the HTTP server itself — this is the same defect that caused the actual VPS/Rocky crash-loop incidents earlier this session, patched around with feature flags rather than closed. `OpenDataStationService` also has no client timeout configured, unlike the DMI fetch service. `WarningService`/`WaterTemperatureService` already show the correct pattern (`.attempt` + stale-cache fallback) the scheduler tasks should follow.
- [ ] Add a pooled JDBC transactor. `DBConnection.scala` uses `Transactor.fromDriverManager`, which opens a brand-new physical connection for every query — doobie's documented behavior for scripts/tests, not production services. Not urgent at current traffic, but an easy, well-known swap (`HikariTransactor`) worth doing before real load.
- [ ] Add application health and readiness endpoints.
- [ ] Add structured logging without leaking secrets.
- [ ] Define PostgreSQL and GRIB-data backup/restore procedures.
@@ -272,13 +274,24 @@ running in parallel until each real source is proven, not cut over in one step.
IP isn't on that list even though Rocky's apparently is. The VPS's
outbound IP is `57.128.250.38`, in case it needs to be given to the
LVGMC contact for allowlisting.
**Next step (morning, user): double-check the password in
`/srv/weathertool/.env.staging` on the VPS character-for-character
against the decoded value; if it's correct, ask the LVGMC contact
whether the `ltv` account is IP-restricted and whether the VPS's
outbound IP needs adding.** FTP stays on for Rocky (working there);
VPS runs open-data only until this is resolved and re-verified the
same careful way — watch-and-confirm, not assume-and-move-on.
**2026-08-24 morning update: the two-machine-collision theory above
does not fully hold.** Independently confirmed the `ltv` account
itself isn't hard-locked (a FileZilla login from a separate PC on the
same network succeeded, browsed `/ltv/tabulas`, saw
`Latvija_faktiskais_laiks.csv` freshly modified — LVGMC's feed is
alive). A manual isolated fetch from Rocky then succeeded too. But
re-enabling Rocky's *scheduler* (`ENABLE_LVGMC_FTP_JOBS=true`, VPS
confirmed still `false` the whole time) failed on its very next
scheduled attempt, ~4 minutes after the successful manual test, on
Rocky alone with nothing else touching the account. Three attempts in
~10 minutes, two succeeded and one didn't, with no pattern found yet
(not simultaneity, not which machine, not obviously attempt spacing).
Reverted `ENABLE_LVGMC_FTP_JOBS` to `false` on Rocky again immediately.
**Decision: stop self-testing this via trial and error — every
attempt might be feeding an unknown rate/session limit — and wait for
the user to ask the LVGMC contact directly what the account's
connection policy actually is.** Both Rocky and the VPS run open-data
only until there's a real answer.
- [x] Researched DMI HARMONIE credentials while waiting on the above and
confirmed live (not just from search results, which claimed a specific
date that wasn't independently verified): DMI's Forecast Data EDR and
@@ -369,9 +382,8 @@ Status: in progress
- Both the VPS and Rocky `weather` tables now hold only real open-data station observations; their original synthetic rows were backed up and wiped 2026-08-23.
- LVGMC forecast CSV fixtures are not yet available.
- HARMONIE GRIB fixtures are not yet available.
- The private LVGMC FTP feed has real credentials as of 2026-08-23 and is enabled on Rocky (`ENABLE_LVGMC_FTP_JOBS=true`); the DMI HARMONIE forecast feed remains gated (`ENABLE_HARMONIE_JOBS`, default off) pending real credentials.
- The private LVGMC FTP feed has real credentials as of 2026-08-23, but `ENABLE_LVGMC_FTP_JOBS` stays `false` on both Rocky and the VPS pending a real answer from LVGMC about the `ltv` account's connection/rate policy (see Phase 7). The DMI HARMONIE forecast feed remains gated (`ENABLE_HARMONIE_JOBS`, default off) pending real credentials — turned out not to need any (DMI dropped its key requirement), but wiring it up is deferred to a dedicated verification session (see Phase 7).
- Existing automated test coverage is minimal.
- Direct refreshes on newer frontend routes can return 404 until the backend gains a general SPA fallback.
- Full Docker build context scanning on Rocky can fail on the container-owned `postgres/` bind directory; do not loosen its permissions.
## Change log
@@ -431,3 +443,21 @@ Record completed work here by date and commit after the Git workflow is establis
| 2026-08-23 | `3315f00` | Fix the same UTC-vs-local mismatch in Ūdens's `observedAt` display (`WaterTemperatureService`) — same open-data portal, same root cause, display-only (internal recency filtering was already self-consistent either way) | Yes — Scala tests; verified live via `/api/water-temperatures`: `observedAt` now matches real local time |
| 2026-08-23 | `3eddf95` | Split `ENABLE_LEGACY_PROVIDER_JOBS` into independent `ENABLE_LVGMC_FTP_JOBS`/`ENABLE_HARMONIE_JOBS` — real LVGMC FTP credentials arrived today, real DMI HARMONIE credentials haven't, and the combined flag would have enabled both together, crash-looping the app on HARMONIE's still-placeholder values via `parMapN` | Yes — Scala tests; caught before it could happen (the Grib job was ~15 min from its first scheduled run when noticed) and reverted within under a minute; re-verified after the fix that only "Fetch Weather Stations" scheduled, not "Fetch Grib" |
| 2026-08-23 | `dc04f66` | Fix `deploy/vps/compose.yml` hardcoding `LVGMC_USER`/`PASSWORD`/`URL` to inert placeholder strings directly in the file (unlike `POSTGRES_*`, which already read from `.env.staging`) — real credentials added to `.env.staging` alone would have had no effect until this switched to the same `${VAR}` substitution pattern. Enabled `ENABLE_LVGMC_FTP_JOBS` on the VPS | Yes — `docker compose config` syntax valid; VPS `app` container came up healthy with real credentials loaded (would have crashed immediately on missing-config if not) |
| 2026-08-24 | `6b9c7cf` | Fix path traversal, auth-bypass, and DoS findings from an independent fresh-eyes security review (requested by the user specifically to get a second, skeptical pass against the local Rocky version): a live-verified CRITICAL — `/api/debug/file/{fileName}` allowed unauthenticated arbitrary file read, confirmed by reading `/proc/self/environ` and leaking `LVGMC_PASSWORD`/`POSTGRES_PASSWORD` into the review's own output — plus the same raw-filename pattern in `/show/grib`, `/grib/binary-chunk`, and `/show/lvgmc-forecast`. Added `ValidateFileName` (allowlist regex; rejects `..` and separators including decoded `%2f`) and applied it everywhere a path segment reached a filesystem or FTP path; hardened `ValidateInt` to reject negatives; gated `/api/fetch/lvgmc/stations` behind `ENABLE_LVGMC_FTP_JOBS` (it could otherwise trigger a real, unauthenticated FTP login regardless of the flag) and stopped it leaking `error.getMessage`; added an explicit `/api/*` catch-all so an unmatched API route can never fall through to the SPA shell; capped `binary-chunk` read length at 64MB | Yes — Scala tests, Rocky rebuild, live curl verification of `%2f`-encoded and literal traversal payloads (404), the gated FTP route (503 while the flag is off), an unmatched `/api/*` route (404, not `index.html`), and legitimate filenames/queries still returning correct 200s |
| 2026-08-24 | `8c45d8d` | Fix SQL injection surfaced by the same security review: `/api/query/city/.../{field}/...` passed its `field` path segment unvalidated into `PostgresService.query`, which splices it into SQL via `Fragment.const` (unescaped) whenever the aggregate key is min/max/avg/sum/distinct, or whenever granularity is `hour` in the list branch. Added `ValidateField`, reusing the `WeatherData.getKeys` allowlist `AggFieldList` already applied to `/query/country`. `AggregateKey` values reaching `Fragment.const` elsewhere were already safe — they come from a closed ADT (`AggregateKey.fromString`), not raw input | Yes — Scala tests, Rocky rebuild, live curl verification that SQL-injection payloads in the `field` segment 404 and a legitimate query still returns correct data |
| 2026-08-24 | *(operational, no commit)* | Rotated Rocky's `POSTGRES_PASSWORD` following the security review, since the old value had been exposed into this session's context multiple times (two of my own sloppy shell commands, plus the `/proc/self/environ` read the review used as its traversal proof). Changed the role's actual password via `ALTER ROLE` against the running container (editing `.env` alone has no effect on an already-initialized PostgreSQL data directory), then updated `.env` and recreated the `scala` service. The Postgres container itself was also recreated as a side effect (its own `POSTGRES_PASSWORD` env interpolation changed too), which is harmless — that variable only takes effect on a fresh, empty data directory, not an existing one — but is worth knowing about | Yes — clean scala container startup with no auth errors, and a real query (`/api/query/latest-temperatures/Rīga`) returning live data over the new password |
| 2026-08-24 | `0be325f` | Deploy the security-review fixes (`6b9c7cf`, `8c45d8d`) to the VPS as image `weathertool:0be325fbbbf92b03bc8d574dc6f7b9449ef310ea`; release `b0b58d2` remains the immediate application rollback. Application-only release — PostgreSQL and Authelia were not restarted | Yes — exact release image smoke-tested on Rocky against a throwaway local Postgres before transfer (traversal/injection payloads 404, gated FTP route 503, legitimate queries 200); checksum verified on both ends; `app` service recreated cleanly (healthy, no auth/DB errors in logs); loopback re-verification of the same traversal/injection/FTP-gate checks plus real `/api/warnings` and `/api/water-temperatures` responses; public HTTPS confirmed unchanged (302 unauthenticated page, 401 unauthenticated API) |
| 2026-08-24 | `9bab93d` | A follow-up independent fresh-eyes review (requested specifically as a second pass, not a rerun of the same pentest) caught that the `6b9c7cf` FTP auth-bypass fix was incomplete: only `/api/fetch/lvgmc/stations` (the one route the original review named) was gated behind `ENABLE_LVGMC_FTP_JOBS`. A sibling route, `/api/show/lvgmc-forecast/{fileName}`, calls the same `fetch.fetchFile` — a real, unauthenticated LVGMC FTP login — and had only gotten the traversal fix (`ValidateFileName`), not the auth-bypass fix, because the two routes were fixed along different mental categories (traversal batch vs. the one named auth-bypass fix) instead of by tracing every caller of the dangerous method. Gated this route the same way; traced every HTTP-reachable caller of `fetch.fetchFile`/`fetchWeatherStations` this time to confirm no others remain. Deployed to both Rocky and the VPS (image `weathertool:9bab93daa6b3a0fb99165a8fac9accfa3a3ccbf1`) immediately given this was live and exploitable in production | Yes — Scala tests, Rocky rebuild and curl verification (both FTP routes 503 while the flag is off), VPS image smoke-tested against a throwaway local Postgres before transfer, checksum verified, `app` service recreated cleanly, loopback re-verification of both gated routes, public HTTPS unchanged (302/401) |
| 2026-08-24 | *(review, no commit)* | A follow-up independent fresh-eyes review (explicitly scoped as a second, different pass — not a rerun of the original pentest) sanity-checked the `6b9c7cf`/`8c45d8d` fixes and did a broader skeptical pass. Caught the incomplete FTP fix documented under `9bab93d` above. Confirmed `ValidateFileName`'s minor `"."` edge case is harmless (resolves to a directory, not exploitable) and found no other unvalidated `Fragment.const` usages. Surfaced several open architectural/testability findings now tracked in Phase 2/Phase 5 above: the `parMapN` scheduler crash-loop pattern (still fully live), per-request `System.gc()` in `getBinaryChunk`, `PostgresService.query`'s unenforced field-routing invariant, no JDBC connection pooling, and several files under `src/main/scala` (`DataServiceTest`, `FetchServiceTest` ×2, `OpenDataStationServiceTest`, `GribParserTest`) that look like tests but are actually `main`-method scripts never run by `sbt test` — compiled straight into the production jar, at least one capable of writing to the real database or hitting live endpoints if run by mistake |
| 2026-08-24 | `5b88e69` | Add Gitea Actions CI: a repository-scoped `weathertool-ci-rocky-01` runner on Rocky (isolated behind its own Docker-in-Docker daemon, mirroring the isolation pattern already proven by HOP's Forgejo runner on the same host — the two coexist without conflict, each an independent client registered to a different server) runs `sbt test` and the frontend typecheck/build/audit routine on every push/PR to `codex/staging-baseline` plus manual dispatch. Verified `sbt test` compiles and passes with no `.env` present at all (via a `git archive HEAD` dry run) before writing the workflow, since CI must never see real credentials. Repo mirrored to Gitea (`bot/WeatherTool`) as an additional remote alongside the existing `rocky`/`origin` ones — deliberately not replacing either, pending an open question about the GitHub `origin` (`guntisdev/WeatherTool`) that won't be resolved until 2026-08-26. See `CONTINUOUS_INTEGRATION.md` | **Correction**: this row originally claimed the first run (`ci.yml #1`) verified green, based on a WebFetch summary of the Gitea Actions page that turned out to be simply wrong — reported as fact without visually checking it first. It actually failed, along with the next three runs, on three different real bugs (see `7e1b492`/`2c78ae8` below and `CONTINUOUS_INTEGRATION.md`). First actual green run was `ci.yml #5`, commit `2c78ae8` |
| 2026-08-24 | `7e1b492``2c78ae8` | Fix three real CI bugs surfaced by actually running the new workflow, found via user-provided screenshots of the Gitea Actions UI after the WebFetch-based "success" claim above turned out false: (1) the runner's `HOME`/cache-directory permission error, initially misdiagnosed as only disabling the optional `actions/cache` action type, actually blocked resolving `actions/checkout` at all — fixed with `HOME=/data` on the runner container (operational fix, in `compose.yml`, not this repo); (2) `actions/checkout` is a JavaScript action needing Node in the job container, which `backend`'s `hseeberger/scala-sbt` image doesn't have — fixed with an `apt-get install nodejs` step before checkout (`7e1b492`); (3) that apt-installed Node was Debian bullseye's own stale default (Node 12), too old for `actions/checkout@v4`'s modern JS — fixed by installing a real current Node 20 via NodeSource's setup script instead (`2c78ae8`). See `CONTINUOUS_INTEGRATION.md` for the full detail | Yes — `ci.yml #5` (commit `2c78ae8`) verified green in the Gitea Actions UI via user-provided screenshot (2m24s), both jobs passing |
| 2026-08-25 | `464e035` | Add shared side padding (`clamp(16px,3vw,48px)`) to the one truly shared layout wrapper (`.appContent`) so full-bleed pages (Kartes, Stacijas, Apskats — the only three with no CSS of their own) get breathing room on the sides instead of running edge-to-edge, while staying full-width rather than a narrower centered column | Yes — Rocky rebuild, headless-browser screenshot of Kartes before/after; confirmed no double-padding on pages that already set their own margins (Home) |
| 2026-08-25 | `1e83e48` | Fix a real CSS collision in Kartes' results grid (`Result.css`): a newer consolidated `.item` rule used `background:var(--surface)!important` to force out an older duplicate rule instead of removing it, leaving the old rule's `padding`/`text-align`/`max-width` still silently active underneath. Also removed dead frontend code found via a systematic check of every class in every stylesheet against actual `.tsx` usage — undercounted by a prior review by several: a whole leftover "Production layout" block in `mapGraphics.css` (9 classes, including a scoped selector that never matched anything in the current markup), unused `.grid-1-1`/`.grid-1-2`/`.panel` utility classes, dead calendar-navigation CSS, and dead commented-out JSX in `DateList.tsx` | Yes — typecheck clean, production build succeeds, headless-browser screenshots of Kartes (including a real query result), Faktiskā, and Arhīvs show no visual regression |
| 2026-08-25 | `e62895a` | Narrow Ūdens's six temperature cards so all fit on one row instead of wrapping — switched `grid-template-columns` to `repeat(auto-fit, minmax(165px,1fr))`. Caught a real bug while tuning the floor value: an earlier hardcoded `repeat(6, minmax(130px,1fr))` pass produced 6 columns at every width from 10501900px, but at some in-between widths (e.g. 1300px) the resulting input boxes came out to ~38px — narrow enough that the native number-spinner arrows ate nearly all of it, clipping digits that were genuinely present in the DOM (confirmed via `inputValue()`) to nothing visible. Fixed by hiding the native spinner arrows and switching to `auto-fit`, which can't produce that in-between too-narrow zone the way a hand-rolled breakpoint ladder can | Yes — a programmatic sweep of 14 viewport widths (3751900px) reading each input's real computed width and value, not just screenshots, confirmed every width keeps a comfortably legible input |
| 2026-08-25 | `e1ce153` | Give Ūdens's resolution-choice buttons the same visual language as the temperature cards next to them (border, radius, soft glass background) instead of the app's generic flat button style, after measuring (via Playwright bounding boxes, not eyeballing) that the perceived "buttons sit higher" was a visual-weight mismatch, not an actual coordinate offset — the tops were already pixel-identical | Yes — Rocky rebuild, headless-browser screenshot confirmation |
| 2026-08-25 | `07b16ac` | Source Valmiera's Faktiskā temperature from Priekuļi as a temporary substitute, since LVĢMC's open-data station list has no station in or near Valmiera at all. Priekuļi chosen by measuring against the app's own calibrated map coordinates (~56px away vs. the next-closest candidate's ~81px), not a guess — same pattern as Stende/Zīlāni substituting for Talsi/Jēkabpils. Implemented client-side in Faktiskā's fetch only; Valmiera's marker position and on-map label are untouched. Also fixes a second symptom for free: the weather-symbol picker only lists cities with a value, so Valmiera never appeared there either. Provisional pending direct confirmation from LVĢMC | Yes — typecheck clean, production build succeeds, headless-browser checks confirm Valmiera's field shows Priekuļi's real live value (fresh, not flagged manual/stale) and now appears in the weather-symbol exceptions list (13 cities, was 12) |
| 2026-08-25 | `4c40c24` | Add click-to-edit temperature badges directly on the Faktiskā map, as a fast path for the common "one or two values need a tweak" case instead of always opening the 13-field "Rādīt stacijas" panel. Mirrors Brīdinājumi's existing canvas-coordinate pattern exactly (`getBoundingClientRect` scaling) rather than inventing a new one, hit-testing against Faktiskā's already-fixed per-resolution marker positions. The floating input is a plain DOM element positioned over the canvas, never drawn onto it, so it stays preview-only like Brīdinājumi's drag handles. Reuses the existing `updateValue` function via a new `onEditValue` callback prop, so the "Rādīt stacijas" panel's override tracking/reset/manual-count badge all stay in sync automatically. Kept the panel rather than removing it after this shipped — it's the only place that surfaces missing/stale data and offers a reset, neither of which click-to-edit does | Yes — end-to-end Playwright test: hover shows a pointer cursor, click shows a correctly positioned input prefilled with the real value, Enter commits and the canvas redraws, the change appears in the panel as a manual override, Escape cancels without committing, works correctly at both export resolutions, no console errors, Kartes (a different `MapView` mode) unaffected |
| 2026-08-25 | `829319e` | Polish hover/focus states on inputs and checkboxes (matching the checkbox/button interaction language already established elsewhere), restyle Brīdinājumi's warning checkbox to match, and drop a stray hardcoded Inter font override on Faktiskā's reset-temperature button | Made directly by the user, included in the next release |
| 2026-08-25 | `a6b2b84` | Fix Faktiskā showing blank temperatures for a stretch after every new hour starts, reported live by the user on both Rocky and the VPS. Root cause: `queryLatestTemperatures` picked the single most-recent row per city with no regard for whether that row's `tempAvg` was actually populated — the open-data source publishes fields on different schedules within an hour (snow arrives well before the hourly temperature aggregate), so the freshest row routinely has a real timestamp but a still-null `tempAvg` while the previous hour's row has good data. Confirmed live on both Rocky and the VPS before fixing: every city's newest row had `tempAvg` null but a real `snowAvg=0`. Fixed by requiring `tempAvg IS NOT NULL` in the query, so it finds the newest row that actually has a value instead of a fresher partial one. Isolated to Faktiskā — Kartes aggregates over a range (one partial hour doesn't blank the result) and Ūdens is a separate data source entirely, which is why only Faktiskā showed the symptom | Yes — confirmed the root cause directly (upstream open-data API check, full-row DB inspection on both Rocky and VPS) before writing the fix, not assumed; compiled; deployed to both Rocky and the VPS following `VPS_RELEASE_RUNBOOK.md` (which itself got a real bug fixed while running it for the first time — the checksum file recorded a full local path instead of a bare filename, failing verification on the VPS); confirmed all 13 Faktiskā stations show real `15:00` data on Rocky via headless browser, confirmed the same live on the VPS via curl, public HTTPS gate unchanged (302/401) |
| 2026-08-25 | `001b014` | Deploy everything accumulated since the last VPS push (`9bab93d`) as image `weathertool:001b014932a1c5e28080dd057613f8bb807a3b81`: the frontend padding/cleanup/Ūdens-layout/button-styling pass, the Valmiera→Priekuļi substitution, and click-to-edit on Faktiskā. The Gitea CI setup itself doesn't affect the deployed app. Release `b0b58d2` remains the rollback target | Yes — exact release image smoke-tested on Rocky against a throwaway local Postgres before transfer (traversal/injection payloads 404, gated FTP routes 503, `/faktiska` and `/udens-temperatura` direct hits 200); confirmed the built image's CSS bundle genuinely contains tonight's changes (not a stale cache hit) before shipping; checksum verified on both ends; `app` service recreated cleanly (healthy, schedulers registered, no errors in logs); loopback re-verification of the same security/routing checks plus confirmed Priekuļi has real live data server-side; public HTTPS unchanged (302/401). Browser-level verification of Valmiera/click-to-edit specifically relied on Rocky's already-passing headless-browser test of the identical code, since the VPS app port is loopback-only by design and not reachable for a direct browser check from this session |
| 2026-08-24 | `2c44888``b0b58d2` | Replace the hardcoded per-route static-file list (`/station`, `/cities`, `/latvia`, `/database`, `/harmonie`, `/lvgmc-forecast`, `/bridinajumi`) with a general SPA fallback: real files serve as-is, anything else falls back to `index.html` so the SolidJS router handles it client-side — fixes a real, user-reported bug where a direct hit (e.g. a browser refresh) on `/faktiska` or `/udens-temperatura` 404ed instead of loading the app, since those two routes were never added to the old list. A missing file under `/assets` specifically still 404s properly rather than silently serving HTML, so a stale tab after a future deploy gets a clean error instead of a confusing JS parse failure | Yes — first attempt silently no-op'd because the fix was built into a `git archive HEAD` image before being committed (classic mistake, caught immediately by re-testing and finding identical old behavior); after committing, verified via curl on all previously-working routes, both previously-broken routes, a missing asset (404), a real asset (200), and `/api` (200), then a full headless-browser render check (zero console errors, real data, correct nav state) on a genuine direct hit — not just HTTP status codes; deployed to VPS in release `b0b58d2`, verified the same way through both the loopback port and the public domain (Authelia gate still correctly redirects unauthenticated requests) |
+188
View File
@@ -0,0 +1,188 @@
# VPS release runbook
This is the literal, runnable command sequence for shipping a new WeatherTool
release to the VPS. `VPS_STAGING_PLAN.md` explains *why* the process looks
this way (topology, isolation boundary, acceptance checklist) and gives the
same steps at a policy level; this document is the *how* — copy-pasteable
commands, run manually, in order, every time. There is no automation here
deliberately: continuous deployment is out of scope while the VPS carries
real testers, and every release is a deliberate human checkpoint (see
`CONTINUOUS_INTEGRATION.md` for why CI does not perform this step either).
Run every command from the Rocky checkout at
`/home/sandbox/Documents/projects/WeatherTool` unless noted otherwise.
## 0. Preconditions
```bash
git status --short # must be empty — never build from an uncommitted tree
git log --oneline -1
```
If CI exists for this commit, check it's green before proceeding — nothing
else currently stops a red-CI commit from being deployed.
## 1. Build the release image
Always build from the committed tree via `git archive`, never from the
working directory — a dirty or stale build has bitten this project before.
```bash
SHA=$(git rev-parse HEAD)
echo "Building weathertool:$SHA"
git archive HEAD | docker build -f deploy/vps/Dockerfile -t weathertool:$SHA -
```
## 2. Smoke-test the exact image
Test the literal artifact that will ship, not just "the code" — against a
throwaway Postgres and placeholder credentials, never real ones.
```bash
docker network create weathertool-smoketest-net
docker run -d --name weathertool-smoketest-pg --network weathertool-smoketest-net \
-e POSTGRES_DB=smoketest -e POSTGRES_USER=smoketest -e POSTGRES_PASSWORD=smoketestpass \
postgres:16.1
until docker exec weathertool-smoketest-pg pg_isready -U smoketest -d smoketest >/dev/null 2>&1; do sleep 1; done
docker run -d --name weathertool-smoketest --network weathertool-smoketest-net \
-p 18080:8080 \
-e POSTGRES_DB=smoketest -e POSTGRES_USER=smoketest -e POSTGRES_PASSWORD=smoketestpass -e POSTGRES_HOST=weathertool-smoketest-pg \
-e LVGMC_URL=placeholder -e LVGMC_USER=placeholder -e LVGMC_PASSWORD=placeholder \
-e HARMONIE_EDR_URL=placeholder -e HARMONIE_EDR_API_KEY=placeholder -e HARMONIE_STAC_URL=placeholder -e HARMONIE_STAC_API_KEY=placeholder \
-e ENABLE_SCHEDULED_JOBS=false -e ENABLE_LVGMC_FTP_JOBS=false -e ENABLE_HARMONIE_JOBS=false \
weathertool:$SHA
sleep 6
docker logs weathertool-smoketest --tail 20 # expect clean startup, no "Fatal error"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18080/ # expect 200
```
Add release-specific checks here (route behavior, a known bug that's
supposedly fixed, a real data path). Then tear down:
```bash
docker stop weathertool-smoketest weathertool-smoketest-pg
docker rm weathertool-smoketest weathertool-smoketest-pg
docker network rm weathertool-smoketest-net
```
If anything looks wrong, stop here. Fix it, commit, and restart from step 1
with the new commit.
## 3. Save, checksum, and retain the artifact
```bash
mkdir -p /tmp/weathertool-release
cd /tmp/weathertool-release
docker save weathertool:$SHA | gzip > weathertool-$SHA.tar.gz
sha256sum weathertool-$SHA.tar.gz | tee weathertool-$SHA.tar.gz.sha256
```
Generate the checksum from a bare filename (after `cd`-ing into the
directory), not a full path — `sha256sum -c` on the VPS checks the exact
path string recorded in the `.sha256` file, and a local absolute path won't
exist there. (Found by running this step for real, not a hypothetical.)
## 4. Transfer to the VPS and verify
```bash
scp weathertool-$SHA.tar.gz weathertool-$SHA.tar.gz.sha256 vps:/tmp/
ssh vps "cd /tmp && sha256sum -c weathertool-$SHA.tar.gz.sha256"
# must print: weathertool-<SHA>.tar.gz: OK
```
Do not proceed past a checksum mismatch. Re-transfer or re-build.
## 5. Retain the release on the VPS
This is the step tonight's releases skipped — restore it. Keeping the
tarball means a future rollback never needs Rocky at all.
```bash
ssh vps "sudo mkdir -p /srv/weathertool/releases/$SHA && sudo mv /tmp/weathertool-$SHA.tar.gz /tmp/weathertool-$SHA.tar.gz.sha256 /srv/weathertool/releases/$SHA/"
```
## 6. Load the image
```bash
ssh vps "sudo docker load -i /srv/weathertool/releases/$SHA/weathertool-$SHA.tar.gz"
```
## 7. Back up `.env.staging` before editing it
Also restore this — it's how earlier releases (through `ef64895`) protected
against a bad edit to a file that lives outside Git and holds real secrets.
```bash
ssh vps "sudo mkdir -p /srv/weathertool/backups && sudo cp /srv/weathertool/.env.staging /srv/weathertool/backups/env.staging.before-$SHA && sudo chmod 600 /srv/weathertool/backups/env.staging.before-$SHA"
```
## 8. Point at the new image and roll out
```bash
ssh vps "cd /srv/weathertool && sudo sed -i 's|^WEATHERTOOL_IMAGE=.*|WEATHERTOOL_IMAGE=weathertool:$SHA|' .env.staging && grep WEATHERTOOL_IMAGE .env.staging"
ssh vps "cd /srv/weathertool && sudo docker compose --env-file .env.staging up -d --no-deps app"
```
`--no-deps app` is deliberate — this recreates only the WeatherTool
container. PostgreSQL and Authelia are never touched by an application
release.
If this release also needs a database change (schema, backfill, wipe),
back up PostgreSQL first — see `VPS_STAGING_PLAN.md`'s acceptance
checklist. That is a separate, explicit decision from the steps here.
## 9. Verify
```bash
ssh vps "sudo docker ps --filter name=weathertool-uat-app-1 --format '{{.Status}}'" # expect Up ... (healthy)
ssh vps "sudo docker logs weathertool-uat-app-1 --tail 20" # expect clean startup, no errors
ssh vps "curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8002/" # expect 200
# public boundary — confirm Authelia/Cloudflare gate is unchanged
curl -s -o /dev/null -w "%{http_code}\n" https://laikapstak.li/ # expect 302 (unauthenticated redirect)
curl -s -o /dev/null -w "%{http_code}\n" https://laikapstak.li/api/warnings # expect 401 (unauthenticated API)
```
Add release-specific loopback checks for whatever this release actually
changed — a bugfix should be checked against the live VPS data, not assumed
from the Rocky smoke test alone.
## 10. Clean up and record
```bash
rm -f /tmp/weathertool-release/weathertool-$SHA.tar.gz*
```
The transfer copy in `/tmp` on both ends is temporary and safe to delete —
the real, retained copy is `/srv/weathertool/releases/$SHA/` from step 5.
Then update:
- `docs/README.md` — the "Release `X` is deployed..." status line, with the
new image tag and rollback target (the *previous* release's SHA).
- `docs/UPDATE_ROADMAP.md`'s change log table — one row: date, SHA, what
shipped, and exactly how it was verified (not just "yes," the specific
checks run).
## Rollback
Only needed if verification in step 9 fails, or a problem surfaces after
release.
```bash
# PREVIOUS_SHA = the rollback target recorded in the last README release line
ssh vps "sudo docker load -i /srv/weathertool/releases/$PREVIOUS_SHA/weathertool-$PREVIOUS_SHA.tar.gz" # only if that image was pruned locally; usually still loaded
ssh vps "cd /srv/weathertool && sudo sed -i 's|^WEATHERTOOL_IMAGE=.*|WEATHERTOOL_IMAGE=weathertool:$PREVIOUS_SHA|' .env.staging"
ssh vps "cd /srv/weathertool && sudo docker compose --env-file .env.staging up -d --no-deps app"
```
Then repeat step 9's verification against the rolled-back release, and
record the rollback in the roadmap change log the same way as a forward
release.
A database change is not undone by an application rollback — if the release
being rolled back touched the database, that needs its own explicit
decision and the relevant PostgreSQL backup from step 8.
+9
View File
@@ -81,7 +81,16 @@ class DataService(log: Logger[IO]) {
Executors.newFixedThreadPool(8) // limit concurrency
)
private val MAX_BINARY_CHUNK_BYTES = 64 * 1024 * 1024
def getBinaryChunk(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = {
if (length > MAX_BINARY_CHUNK_BYTES || length < 0)
IO.raiseError(new IllegalArgumentException(s"Requested length $length is invalid (max $MAX_BINARY_CHUNK_BYTES bytes)"))
else
getBinaryChunkUnchecked(offset, length, fileName)
}
private def getBinaryChunkUnchecked(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = {
val fileResource = Resource.make(
IO.blocking(new RandomAccessFile(s"$GRIB_FOLDER/$fileName", "r"))
)(file => IO.blocking(file.close()))
+7
View File
@@ -29,10 +29,17 @@ object PostgresService {
class PostgresService(transactor: Transactor[IO], log: Logger[IO]) {
def queryLatestTemperatures(cities: NonEmptyList[String]): IO[List[(String, LocalDateTime, Option[Double])]] = {
// The open-data source publishes fields on different schedules within
// the same hour (e.g. snow arrives well before the hourly temperature
// aggregate), so the single newest row for a city can have a real
// dateTime but a still-null tempAvg. Skip straight to DISTINCT ON's
// "most recent" pick by requiring tempAvg itself to be present, so a
// fresh partial row never hides an older row that actually has data.
val query =
fr"SELECT DISTINCT ON (city) city, dateTime, tempAvg" ++
fr" FROM weather" ++
fr" WHERE " ++ Fragments.in(fr"city", cities) ++
fr" AND tempAvg IS NOT NULL" ++
fr" ORDER BY city, dateTime DESC"
query.query[(String, LocalDateTime, Option[Double])]
+59 -30
View File
@@ -2,6 +2,7 @@ package server
import cats.effect._
import cats.implicits.toTraverseOps
import cats.syntax.semigroupk._
import com.comcast.ip4s.IpLiteralSyntax
import data.DataService
import db.PostgresService
@@ -9,7 +10,7 @@ import fetch.csv.FileNameService
import fetch.lvgmc.{FetchService, WaterTemperatureService}
import fetch.warnings.WarningService
import fs2.io.file.{Files, Path}
import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateInt, ValidateMonths, ValidateZonedDateTime}
import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateField, ValidateFileName, ValidateInt, ValidateMonths, ValidateZonedDateTime}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
@@ -63,24 +64,39 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
}
// http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv
case GET -> Root / "show" / "lvgmc-forecast" / fileName =>
fetch.fetchFile(fileName).flatMap(bytes =>
Ok(bytes).map(_.withContentType(`Content-Type`(MediaType.text.csv)))
)
// Gated behind the same flag as the scheduled FTP task and
// /fetch/lvgmc/stations below: fetch.fetchFile opens a real FTP login to
// LVGMC regardless of any caller-supplied filename, so an unauthenticated
// caller could otherwise trigger unlimited outbound FTP sessions no
// matter what ENABLE_LVGMC_FTP_JOBS says.
case GET -> Root / "show" / "lvgmc-forecast" / ValidateFileName(fileName) =>
if (sys.env.get("ENABLE_LVGMC_FTP_JOBS").exists(_.equalsIgnoreCase("true")))
fetch.fetchFile(fileName).flatMap(bytes =>
Ok(bytes).map(_.withContentType(`Content-Type`(MediaType.text.csv)))
)
else
ServiceUnavailable("LVGMC FTP fetching is currently disabled")
// http://0.0.0.0:8080/api/fetch/lvgmc/stations
// Gated behind the same flag as the scheduled FTP task: this route would
// otherwise let anyone unauthenticated trigger a real LVGMC FTP login on
// demand, bypassing ENABLE_LVGMC_FTP_JOBS entirely.
case GET -> Root / "fetch" / "lvgmc" / "stations" =>
(
for {
fileName <- new FileNameService().generateCurrentHour
stationDataStr <- fetch.fetchWeatherStations()
_ <- postgresService.save(fileName, stationDataStr)
} yield stationDataStr
)
.flatMap(content => Ok(content))
.handleErrorWith(error =>
InternalServerError(s"Failed to fetch stations: ${error.getMessage}")
if (sys.env.get("ENABLE_LVGMC_FTP_JOBS").exists(_.equalsIgnoreCase("true")))
(
for {
fileName <- new FileNameService().generateCurrentHour
stationDataStr <- fetch.fetchWeatherStations()
_ <- postgresService.save(fileName, stationDataStr)
} yield stationDataStr
)
.flatMap(content => Ok(content))
.handleErrorWith(error =>
log.error(error)("Failed to fetch LVGMC stations") *>
InternalServerError("Failed to fetch stations")
)
else
ServiceUnavailable("LVGMC FTP fetching is currently disabled")
// http://0.0.0.0:8080/api/show/grib-all-structure
case GET -> Root / "show" / "grib-all-structure" =>
@@ -91,10 +107,10 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
dataService.getFileList().flatMap(fileList => Ok(fileList.asJson))
// http://0.0.0.0:8080/api/show/grib-name/harmonie_2025-02-01T1500Z_2025-02-01T180000Z.grib
case GET -> Root / "show" / "grib" / fileName =>
case GET -> Root / "show" / "grib" / ValidateFileName(fileName) =>
dataService.getGribStucture(fileName).flatMap(response => Ok(response.asJson))
case GET -> Root / "grib" / "binary-chunk" / ValidateInt(binaryOffset) / ValidateInt(binaryLength) / fileName =>
case GET -> Root / "grib" / "binary-chunk" / ValidateInt(binaryOffset) / ValidateInt(binaryLength) / ValidateFileName(fileName) =>
dataService.getBinaryChunk(binaryOffset, binaryLength, fileName).flatMap(buffer => Ok(buffer))
// http://0.0.0.0:8080/api/grib/delete-old-forecasts
@@ -116,7 +132,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
DebugUtils.getFolderStructure.flatMap(response => Ok(response.asJson))
// http://0.0.0.0:8080/api/debug/file/error_2025-03-16_152201.txt
case GET -> Root / "debug" / "file" / fileName =>
case GET -> Root / "debug" / "file" / ValidateFileName(fileName) =>
val filePath = Path(s"data/tmp/$fileName")
Files[IO].exists(filePath).flatMap {
@@ -131,7 +147,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
}
// http://0.0.0.0:8080/api/query/city/Liepāja,Rēzekne/20230414_2200-20230501_1230/hour/tempMax/max
case GET -> Root / "query" / "city" / CityList(cities) / DateTimeRange(from, to) / Granularity(granularity) / field / AggKey(key) =>
case GET -> Root / "query" / "city" / CityList(cities) / DateTimeRange(from, to) / Granularity(granularity) / ValidateField(field) / AggKey(key) =>
val userQuery = UserQuery(cities, field, key, granularity, from, to)
postgresService.query(userQuery)
@@ -192,6 +208,12 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
// http://0.0.0.0:8080/api/show/datetime/20230423_1300
case GET -> Root / "show" / "datetime" / ValidateDateTime(datetime) =>
postgresService.getDateTimeEntries(datetime).flatMap(content => Ok(content.asJson))
// Explicit catch-all: guarantees every /api/* request resolves inside
// this route set (never falls through to the SPA fallback below and
// gets served index.html as a false 200).
case _ =>
NotFound()
}
private val corsConfig = CORSConfig.default
@@ -203,18 +225,25 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
private val apiRoutesCors = CORS(apiRoutes, corsConfig)
private val httpApp = Router(
"/" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/api" -> apiRoutesCors,
// Real files (JS/CSS/images/favicon) are served as-is; anything else falls
// back to index.html so the SolidJS router can handle it client-side. This
// replaces an explicit per-route list that silently 404ed on a direct hit
// (e.g. a browser refresh) for any client-side route not on the list —
// real, observed on /faktiska and /udens-temperatura in production.
private val assets = staticcontent.fileService[IO](FileService.Config("./web/dist"))
private val spaFallback = HttpRoutes.of[IO] {
// A missing file under /assets (the only place Vite emits hashed build
// output) should stay a real 404, not silently serve HTML — otherwise a
// stale tab referencing a since-removed bundle after a future deploy
// would get a confusing "unexpected token" JS parse error instead.
case GET -> Root / "assets" / _ => NotFound()
case req @ GET -> _ =>
StaticFile.fromPath(Path("./web/dist/index.html"), Some(req)).getOrElseF(NotFound())
}
// TODO rewrite in more generic way
"/station" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/cities" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/latvia" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/database" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/harmonie" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/lvgmc-forecast" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/bridinajumi" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
private val httpApp = Router(
"/api" -> apiRoutesCors,
"/" -> (assets <+> spaFallback),
).orNotFound
def run: IO[ExitCode] =
+21 -1
View File
@@ -64,6 +64,14 @@ object ValidateRoutes {
}
}
// Restricts a raw path segment to a known WeatherData field name before it
// can reach Fragment.const (unescaped SQL splicing) in PostgresService.
object ValidateField {
def unapply(str: String): Option[String] = {
if (WeatherData.getKeys.contains(str)) Some(str) else None
}
}
object AggFieldList {
def unapply(str: String): Option[NonEmptyList[String]] = {
val weatherFields = WeatherData.getKeys
@@ -92,7 +100,19 @@ object ValidateRoutes {
object ValidateInt {
def unapply(str: String): Option[Int] = {
Option(str.toInt)
Try(str.toInt).toOption.filter(_ >= 0)
}
}
// Rejects path traversal and directory separators (including URL-decoded
// %2f, which arrives here as a literal '/' after http4s decodes the path
// segment) by allowlisting a safe filename character set, rather than
// trying to blocklist every encoding of "..". Used anywhere a path
// segment gets concatenated directly into a filesystem Path.
object ValidateFileName {
private val safeFileName = "^[A-Za-z0-9._-]+$".r
def unapply(str: String): Option[String] = {
if (str.nonEmpty && !str.contains("..") && safeFileName.matches(str)) Some(str) else None
}
}
}
+86 -4
View File
@@ -1,4 +1,4 @@
import { Component, createEffect, createSignal, onMount } from "solid-js";
import { Component, createEffect, createSignal, onMount, Show } from "solid-js";
import { ResultKeyVal } from "../../consts";
import { WindInputs, WindSignals } from "./WindInputs";
@@ -10,8 +10,10 @@ import { CityData, drawOnMap } from "./canvasDraw";
import { IconInputs } from "../weatherIcons/IconInputs";
import { preloadWeatherIcons } from "../weatherIcons/weatherIconAssets";
import { download } from '../../helpers/download'
import { faktiskaTemplates } from "../../pages/map-graphics/faktiskaTemplates";
import type { FaktiskaTemplate } from "../../pages/map-graphics/faktiskaTemplates";
export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska", productionTemplate?: boolean }> = ({ type, data, mode = "temperature", productionTemplate = false }) => {
export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska", productionTemplate?: boolean, onEditValue?: (city: string, value: string) => void }> = ({ type, data, mode = "temperature", productionTemplate = false, onEditValue }) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [getImg, setImg] = createSignal(new Image());
const [fontReady, setFontReady] = createSignal(false);
@@ -24,6 +26,64 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
let lastCoords: CityData[] = [];
const props = resolutionProps[type];
// Faktiskā only: click a temperature badge on the map to edit it inline,
// instead of needing the "Rādīt stacijas" panel for a one-off tweak.
// Same canvas-coordinate conversion as Brīdinājumi's drag/resize handles
// (getBoundingClientRect scaling) -- preview-only, this input is a plain
// DOM element positioned over the canvas and never reaches the export.
const clickToEditEnabled = mode === "faktiska" && productionTemplate;
const [hoveredCity, setHoveredCity] = createSignal<string>();
const [editingCity, setEditingCity] = createSignal<string>();
const [editValue, setEditValue] = createSignal("");
const canvasPoint = (event: { clientX: number, clientY: number }) => {
const canvas = getCanvas()!;
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height),
};
};
const badgeHitTest = (point: { x: number, y: number }): string | undefined => {
if (!clickToEditEnabled) return undefined;
const faktiskaTemplate = faktiskaTemplates[type as FaktiskaTemplate];
const half = faktiskaTemplate.badgeSize / 2;
for (const [city, marker] of Object.entries(faktiskaTemplate.markers)) {
if (Math.abs(point.x - marker.x) <= half && Math.abs(point.y - marker.y) <= half) return city;
}
return undefined;
};
const screenPointFromCanvas = (x: number, y: number) => {
const canvas = getCanvas()!;
const rect = canvas.getBoundingClientRect();
return {
left: rect.left + x * (rect.width / canvas.width),
top: rect.top + y * (rect.height / canvas.height),
};
};
const onCanvasPointerMove = (event: PointerEvent) => {
if (!clickToEditEnabled) return;
setHoveredCity(badgeHitTest(canvasPoint(event)));
};
const onCanvasClick = (event: MouseEvent) => {
if (!clickToEditEnabled) return;
const city = badgeHitTest(canvasPoint(event));
if (!city) return;
const current = data().find(([c]) => c === city)?.[1];
setEditValue(typeof current === "number" ? current.toString().replace(".", ",") : "");
setEditingCity(city);
};
const commitEdit = () => {
const city = editingCity();
if (city) onEditValue?.(city, editValue());
setEditingCity(undefined);
};
const arrowImg = new Image();
arrowImg.src = arrowUrl;
@@ -105,7 +165,7 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
return (
<div class="mapWorkspace">
<div class="mapToolbar"><div><strong>{productionTemplate ? "Kartes priekšskatījums" : "Map preview"}</strong><span>{props.width} × {props.height} px {productionTemplate ? "eksports" : "export"}</span></div><button class="primary" onClick={downloadPng}>{productionTemplate ? "Lejupielādēt PNG" : "Download PNG"}</button></div>
<div class="mapToolbar"><div><strong>{productionTemplate ? "Kartes priekšskatījums" : "Map preview"}</strong><span>{props.width} × {props.height} px {productionTemplate ? "eksports" : "export"}</span>{clickToEditEnabled && <span class="mapEditHint">Klikšķini uz temperatūras kartē, lai to mainītu</span>}</div><button class="primary" onClick={downloadPng}>{productionTemplate ? "Lejupielādēt PNG" : "Download PNG"}</button></div>
<details class="broadcastControls" open={!productionTemplate}>
<summary><span><strong>{productionTemplate ? "Kartes noformējums" : "Broadcast overlay controls"}</strong>{!productionTemplate && <small>Optional manual wind and weather-symbol overrides</small>}</span></summary>
{!productionTemplate && <p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>}
@@ -136,8 +196,30 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
ref={setCanvas}
width={props.width}
height={props.height}
style={getStyleSize()}
style={{ ...getStyleSize(), cursor: hoveredCity() ? "pointer" : "default" }}
onPointerMove={onCanvasPointerMove}
onPointerLeave={() => setHoveredCity(undefined)}
onClick={onCanvasClick}
/>
<Show when={editingCity()}>{city => {
const marker = () => faktiskaTemplates[type as FaktiskaTemplate].markers[city() as keyof typeof faktiskaTemplates[FaktiskaTemplate]["markers"]];
const pos = () => screenPointFromCanvas(marker().x, marker().y);
return <input
class="mapValueEditor"
ref={el => { setTimeout(() => { el.focus(); el.select(); }, 0); }}
style={{ position: "fixed", left: `${pos().left}px`, top: `${pos().top}px`, transform: "translate(-50%, -50%)" }}
type="text"
inputmode="decimal"
value={editValue()}
onInput={e => setEditValue(e.currentTarget.value)}
onKeyDown={e => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditingCity(undefined);
}}
onBlur={commitEdit}
aria-label={`${city()} temperatūra`}
/>;
}}</Show>
</div>
</div>
);
+1 -5
View File
@@ -4,7 +4,7 @@
justify-content: space-between;
padding: 10px 0;
}
.resultTitle>span:first-child{display:flex;flex-wrap:wrap;gap:7px}.resultTitle>span:first-child br{display:none}.resultTitle input{margin:0}.grid-view{margin-top:12px}.grid-view .item,.list-view .item{border:1px solid var(--border);border-radius:12px;background:var(--surface)!important;box-shadow:var(--shadow-sm)}
.resultTitle>span:first-child{display:flex;flex-wrap:wrap;gap:7px}.resultTitle>span:first-child br{display:none}.resultTitle input{margin:0}.grid-view{margin-top:12px}.grid-view .item,.list-view .item{border:1px solid var(--border);border-radius:12px;background:var(--surface);box-shadow:var(--shadow-sm);padding:10px}
.resultTitle h3 {
margin: 0;
@@ -21,8 +21,6 @@
}
.grid-view .item {
background-color: #f2f2f2;
padding: 10px;
text-align: center;
}
@@ -33,8 +31,6 @@
}
.list-view .item {
background-color: #f2f2f2;
padding: 10px;
max-width: 300px;
}
+1 -1
View File
@@ -15,7 +15,7 @@
.overlayFields{display:grid;grid-template-columns:minmax(260px,2fr) minmax(160px,1fr);gap:12px;padding:16px 16px 0}.overlayFields label,.overlayFields span{display:block}.overlayFields span{margin-bottom:6px;font-size:12px;font-weight:700}.overlayFields input{width:100%}
.mapControlSections{display:grid;gap:16px;padding:16px;background:var(--surface-soft)}.valueControls{padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.valueControls>strong{display:block;margin-bottom:2px}.windControlRow{display:grid;grid-template-columns:minmax(170px,.8fr) repeat(3,minmax(180px,1fr));gap:14px;align-items:end;margin-top:10px}.roundControl{display:flex;align-items:center;gap:8px;min-height:40px}.windField{display:grid;gap:6px}.windField span{font-size:12px;font-weight:700;color:var(--text-muted)}.windField input{width:100%;min-width:0}@media(max-width:900px){.windControlRow{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.windControlRow{grid-template-columns:1fr}}
.pageWorkspace{max-width:1800px;margin:auto;padding:34px 28px 70px}.pageHeading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.pageHeading h1{margin:5px 0 6px;font-size:34px}.pageHeading p{margin:0;color:var(--text-muted)}.selectionCount{padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font-weight:700}.aggregator>.cityWorkspace{display:grid;grid-template-columns:240px 300px minmax(0,1fr);gap:18px;align-items:start}.panel{padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);box-shadow:var(--shadow-sm)}.panel h2{margin:0 0 18px;font-size:16px}.cityPanel{max-height:calc(100vh - 160px);overflow:auto}.cityPanel ul{margin:12px 0;padding:0;columns:1}.cityPanel li{padding:3px 0}.queryPanel h4{margin:22px 0 9px}.queryPanel p{margin:10px 0}.resultsPanel{min-width:0}.emptyState{display:flex;min-height:260px;align-items:center;justify-content:center;flex-direction:column;gap:8px;margin-top:18px;padding:30px;border:1px dashed #bac6d4;border-radius:16px;background:rgba(255,255,255,.55);color:var(--text-muted);text-align:center}.emptyState strong{color:var(--text);font-size:18px}.mapWorkspace{margin-top:14px}.mapToolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.mapToolbar strong,.mapToolbar span{display:block}.mapToolbar span{margin-top:3px;color:var(--text-muted);font-size:12px}.broadcastControls{margin-bottom:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.broadcastControls summary{padding:14px 16px;cursor:pointer}.broadcastControls summary span,.broadcastControls summary strong,.broadcastControls summary small{display:block}.broadcastControls summary small,.controlHelp,.fieldHint{color:var(--text-muted);font-size:12px}.broadcastControls[open] summary{border-bottom:1px solid var(--border)}.broadcastControls .grid-1-2{padding:16px}.controlHelp{margin:16px 16px 0}.canvasFrame{overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#d8dee6;box-shadow:var(--shadow-sm)}.canvasFrame canvas{display:block}.weatherIconButton{border-radius:7px}.fieldHint{margin:4px 0 10px}@media(max-width:1200px){.aggregator>.cityWorkspace{grid-template-columns:220px 1fr}.resultsPanel{grid-column:1/-1}.cityPanel{max-height:none}}@media(max-width:700px){.pageWorkspace{padding:24px 14px}.pageHeading{align-items:start;flex-direction:column}.aggregator>.cityWorkspace{grid-template-columns:1fr}.resultsPanel{grid-column:auto}}
.pageWorkspace{max-width:1800px;margin:auto;padding:34px 28px 70px}.pageHeading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.pageHeading h1{margin:5px 0 6px;font-size:34px}.pageHeading p{margin:0;color:var(--text-muted)}.selectionCount{padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font-weight:700}.aggregator>.cityWorkspace{display:grid;grid-template-columns:240px 300px minmax(0,1fr);gap:18px;align-items:start}.cityPanel{max-height:calc(100vh - 160px);overflow:auto}.cityPanel ul{margin:12px 0;padding:0;columns:1}.cityPanel li{padding:3px 0}.queryPanel h4{margin:22px 0 9px}.queryPanel p{margin:10px 0}.resultsPanel{min-width:0}.emptyState{display:flex;min-height:260px;align-items:center;justify-content:center;flex-direction:column;gap:8px;margin-top:18px;padding:30px;border:1px dashed #bac6d4;border-radius:16px;background:rgba(255,255,255,.55);color:var(--text-muted);text-align:center}.emptyState strong{color:var(--text);font-size:18px}.mapWorkspace{margin-top:14px}.mapToolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.mapToolbar strong,.mapToolbar span{display:block}.mapToolbar span{margin-top:3px;color:var(--text-muted);font-size:12px}.broadcastControls{margin-bottom:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.broadcastControls summary{padding:14px 16px;cursor:pointer}.broadcastControls summary span,.broadcastControls summary strong,.broadcastControls summary small{display:block}.broadcastControls summary small,.controlHelp,.fieldHint{color:var(--text-muted);font-size:12px}.broadcastControls[open] summary{border-bottom:1px solid var(--border)}.controlHelp{margin:16px 16px 0}.canvasFrame{overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#d8dee6;box-shadow:var(--shadow-sm)}.canvasFrame canvas{display:block}.weatherIconButton{border-radius:7px}.fieldHint{margin:4px 0 10px}@media(max-width:1200px){.aggregator>.cityWorkspace{grid-template-columns:220px 1fr}.resultsPanel{grid-column:1/-1}.cityPanel{max-height:none}}@media(max-width:700px){.pageWorkspace{padding:24px 14px}.pageHeading{align-items:start;flex-direction:column}.aggregator>.cityWorkspace{grid-template-columns:1fr}.resultsPanel{grid-column:auto}}
/* City Analysis follows the same rail + full-width workspace geometry as Faktiskā. */
.aggregator>.cityWorkspace{
+4 -12
View File
@@ -49,10 +49,13 @@ thead {
}
.primary{background:var(--accent);color:#fff}.primary:hover{background:var(--accent-strong)}
.secondary{background:var(--surface);color:var(--accent-strong);border-color:var(--accent-soft)}.secondary:hover{background:var(--accent-pale);border-color:var(--accent)}
button,input[type=button]{transition:transform 260ms cubic-bezier(.23,1,.32,1),box-shadow 260ms,background-color 180ms,border-color 180ms;touch-action:manipulation;will-change:transform}button:not(:disabled):hover,input[type=button]:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(26,74,128,.16)}button:not(:disabled):active,input[type=button]:not(:disabled):active{transform:translateY(0);box-shadow:none}
input[type=checkbox]{--checkbox-size:18px;appearance:none;width:var(--checkbox-size);height:var(--checkbox-size);margin:0 6px 0 0;vertical-align:-3px;border:1px solid #aebed1;border-radius:5px;background:#fff;cursor:pointer;position:relative;transition:all .25s}input[type=checkbox]:hover{border-color:var(--accent)}input[type=checkbox]:checked{border-color:transparent;background:linear-gradient(145deg,#3388d7,#1763aa);box-shadow:0 0 0 3px rgba(36,119,197,.13)}input[type=checkbox]:checked:before{content:"";position:absolute;top:45%;left:50%;width:4px;height:8px;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:translate(-50%,-50%) rotate(45deg) scale(1);animation:check-pop .22s cubic-bezier(.12,.4,.29,1.46)}input[type=checkbox]:focus-visible{outline:3px solid var(--accent-soft);outline-offset:2px}@keyframes check-pop{from{opacity:0;transform:translate(-50%,-50%) rotate(45deg) scale(0)}to{opacity:1;transform:translate(-50%,-50%) rotate(45deg) scale(1)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
input[type=text],input[type=number],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff;font-family:inherit}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px)}
input[type=text],input[type=number],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff;font-family:inherit;transition:border-color 180ms,box-shadow 180ms}
input[type=text]:hover,input[type=number]:hover,input[type=date]:hover,input[type=datetime-local]:hover,select:hover{border-color:var(--accent-soft)}
input[type=text]:focus,input[type=number]:focus,input[type=date]:focus,input[type=datetime-local]:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(36,119,197,.15)}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px);padding-inline:clamp(16px,3vw,48px)}
table td {
padding: 3px 7px;
@@ -74,14 +77,3 @@ table tr:hover {
display: block;
}
.grid-1-1 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.grid-1-2 {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 10px;
}
-9
View File
@@ -33,15 +33,6 @@ export const DateList: Component<{getDate: Accessor<Date>, setDate: Setter<Date>
setCurrentMonth={setCurrentMonth}
datesWithData={() => datesResource().map((str: string) => new Date(str))}
/> }
{/* Backup date view */}
{ datesResource() && (
<ul>
{/* { (datesResource() as any).map((date: any) =>
<li onClick={() => clickDate(date)}>{date}</li>
)} */}
</ul>
)}
</div>
);
}
+1 -15
View File
@@ -11,19 +11,6 @@
margin-bottom: 10px;
}
.calendarNav {
display: flex;
align-items: center;
}
.calendarNav button {
margin-right: 5px;
background-color: #fff;
border: none;
padding: 5px;
cursor: pointer;
}
.calendarYearMonth {
text-align: center;
}
@@ -36,8 +23,7 @@
background-color: #fff;
}
.prevMonth,
.nextMonth {
.prevMonth {
opacity: 0.3;
}
+24 -3
View File
@@ -7,10 +7,26 @@ import type { FaktiskaTemplate } from "./faktiskaTemplates";
import "./mapGraphics.css";
type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null };
type RawLatestTemperature = { city: string; observedAt: string; value: number | null };
const emptyValues = (): Record<FaktiskaStation, string> =>
Object.fromEntries(faktiskaStations.map(city => [city, ""])) as Record<FaktiskaStation, string>;
// LVĢMC's open-data station list has no station in or near Valmiera at all
// (confirmed live 2026-08-23, see OpenDataStationService.scala). Priekuļi is
// the closest currently-reporting station (verified against the app's own
// calibrated map coordinates: ~56px away vs. the next-closest candidate's
// ~81px). Valmiera's marker position and on-map label stay exactly where
// they are -- only the queried data source for that one slot changes.
// Temporary, same pattern as Stende/Zīlāni substituting for Talsi/Jēkabpils
// (see PRODUCT_WORKFLOWS.md); pending confirmation directly from LVĢMC.
const stationSubstitutes: Partial<Record<FaktiskaStation, string>> = {
"Valmiera": "Priekuļi",
};
const substituteToStation = new Map(
Object.entries(stationSubstitutes).map(([station, substitute]) => [substitute, station as FaktiskaStation])
);
export function Faktiska() {
const [values, setValues] = createSignal(emptyValues());
const [overrides, setOverrides] = createSignal<Set<FaktiskaStation>>(new Set());
@@ -21,9 +37,14 @@ export function Faktiska() {
: { width: 3840, height: 1440 };
const fetchLatest = async (): Promise<LatestTemperature[]> => {
const response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`);
const queryCities = faktiskaStations.map(city => stationSubstitutes[city] ?? city);
const response = await fetch(`${apiHost}/api/query/latest-temperatures/${queryCities.join(",")}`);
if (!response.ok) throw new Error(`Latest observations failed (${response.status})`);
return response.json();
const observations: RawLatestTemperature[] = await response.json();
return observations.map(observation => ({
...observation,
city: substituteToStation.get(observation.city) ?? observation.city as FaktiskaStation,
}));
};
const [latest, { refetch }] = createResource(fetchLatest);
@@ -106,7 +127,7 @@ export function Faktiska() {
<button classList={{ active: template() === "faktiska_3840x1440" }} onClick={() => setTemplate("faktiska_3840x1440")}>3840 × 1440</button>
</div>
<Show when={template()} keyed>{currentTemplate =>
<MapView type={currentTemplate} data={cityData} mode="faktiska" productionTemplate/>
<MapView type={currentTemplate} data={cityData} mode="faktiska" productionTemplate onEditValue={(city, value) => updateValue(city as FaktiskaStation, value)}/>
}</Show>
</section>
</div>;
+4 -32
View File
@@ -1,39 +1,11 @@
.graphicsSetup{display:grid;grid-template-columns:240px minmax(0,1fr);gap:18px;align-items:start}.graphicsMain{display:grid;gap:18px}.productionOptions,.dataOptions{padding:20px}.choiceGroup{display:grid;grid-template-columns:130px 1fr;gap:16px;align-items:start;margin-top:16px}.choiceGroup>span,.dataOptions label>span{display:block;font-size:12px;font-weight:700}.choiceGroup>div{display:flex;flex-wrap:wrap;gap:9px}.choiceGroup button{min-width:150px;text-align:left}.choiceGroup button small{display:block;margin-top:3px;color:var(--text-muted);font-family:Inter,sans-serif}.choiceGroup button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong)}.windChoice{display:flex;align-items:center;gap:7px;padding:9px 12px}.dataOptionsGrid{display:grid;grid-template-columns:minmax(250px,1.4fr) 1fr 1fr;gap:18px;align-items:end;margin-bottom:16px}.dataOptions select{width:100%;margin-top:6px}.mapProduction>h2{margin:0 0 10px}.symbolEditor{min-width:0}.symbolEditorHeading{display:flex;align-items:start;justify-content:space-between;gap:12px}.citySymbols{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.citySymbolRow{display:grid;grid-template-columns:1fr 52px 54px;gap:6px;align-items:center;padding:7px;border:1px solid var(--border);border-radius:9px}.clearSymbol{padding:5px;font-size:11px}@media(max-width:900px){.graphicsSetup{grid-template-columns:1fr}.dataOptionsGrid{grid-template-columns:1fr}.choiceGroup{grid-template-columns:1fr}}
.mapProduction>h2{margin:0 0 10px}.symbolEditor{min-width:0}.symbolEditorHeading{display:flex;align-items:start;justify-content:space-between;gap:12px}.citySymbols{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.citySymbolRow{display:grid;grid-template-columns:1fr 52px 54px;gap:6px;align-items:center;padding:7px;border:1px solid var(--border);border-radius:9px}.clearSymbol{padding:5px;font-size:11px}
.symbolEditorHeading{gap:16px}.bulkSymbol{display:flex;align-items:center;gap:8px}.assignmentHeading{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}.assignmentHeading span{color:var(--text-muted);font-size:12px}.citySymbols{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}.citySymbolRow{grid-template-columns:minmax(90px,1fr) 48px 100px 36px;gap:8px;min-height:62px;padding:8px 10px;border-radius:12px;background:#fff}.cityName{overflow:hidden;text-overflow:ellipsis;font-weight:700}.assignAction{padding:7px 9px;font-size:12px}.clearSymbol{width:36px;padding:0;font-size:20px;color:var(--text-muted)}@media(max-width:900px){.symbolEditorHeading{flex-direction:column}.citySymbols{grid-template-columns:1fr}}
.symbolEditor{display:grid;gap:16px}.symbolCard{padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.showAllCitiesToggle{margin-top:4px}
.mapEditHint{font-style:italic}
.mapValueEditor{z-index:20;width:74px;padding:6px 8px;border:2px solid var(--accent);border-radius:8px;background:#fff;font-size:18px;font-weight:700;text-align:center;box-shadow:0 6px 16px rgba(16,24,40,.22)}
/* Production layout: a compact control band above one uninterrupted workspace. */
.graphicsSetup{grid-template-columns:230px minmax(0,1fr);column-gap:28px}
.mapGraphicsPage .workspaceRail{
padding:4px 24px 10px 0;
border-right:1px solid rgba(90,126,166,.2);
}
.mapGraphicsPage .workspaceRail h2{margin:0 0 18px;font-size:16px}
.graphicsMain{gap:16px}
.productionToolbar{
padding:2px 0 20px;
border-bottom:1px solid rgba(90,126,166,.22);
}
.productionOptions,.dataOptions{padding:0}
.productionOptions h2,.dataOptions h2{margin:0;font-size:18px}
.productionOptions{display:grid;grid-template-columns:210px minmax(0,1fr);gap:24px;align-items:center}
.productionOptions .choiceGroup{margin:0}
.dataOptions{display:grid;grid-template-columns:210px minmax(0,1fr);gap:24px;align-items:center;margin-top:18px;padding-top:18px;border-top:1px solid rgba(90,126,166,.14)}
.dataOptionsGrid{grid-template-columns:minmax(300px,1.35fr) minmax(180px,1fr) minmax(160px,.9fr) auto;gap:16px;margin:0;align-items:end}
.loadMapButton{white-space:nowrap;align-self:end}
.mapProduction>h2{font-size:22px}
@media(max-width:1200px){
.productionOptions,.dataOptions{grid-template-columns:1fr;gap:12px}
.dataOptionsGrid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:900px){
.graphicsSetup{grid-template-columns:1fr}
.mapGraphicsPage .workspaceRail{padding-right:0;border-right:0;border-bottom:1px solid rgba(90,126,166,.2)}
.dataOptionsGrid{grid-template-columns:1fr}
.loadMapButton{justify-self:start}
}
/* Fixed Faktiskā newsroom workflow. The preview is responsive; export geometry is not. */
.faktiskaWorkspace{max-width:1900px}
.productionSize{padding:10px 14px;border:1px solid rgba(77,130,190,.28);border-radius:10px;background:rgba(255,255,255,.66);font-weight:700}
@@ -53,7 +25,7 @@
.temperatureField.missing{border-style:dashed}
.temperatureFieldHeading{grid-area:heading;min-width:0}.temperatureFieldHeading strong,.temperatureFieldHeading small{display:block}.temperatureFieldHeading small{margin-top:2px;color:var(--text-muted);font-size:10px}
.temperatureInput{grid-area:input;display:flex;align-items:center;gap:7px}.temperatureInput input{width:100%;min-width:0;font-size:19px;font-weight:700}.temperatureInput>span{color:var(--text-muted);font-weight:700}
.resetTemperature{grid-area:reset;align-self:start;min-height:0;padding:3px 5px;background:transparent;color:var(--accent-strong);font-family:Inter,sans-serif;font-size:10px}
.resetTemperature{grid-area:reset;align-self:start;min-height:0;padding:3px 5px;background:transparent;color:var(--accent-strong);font-size:10px}
.inlineNotice{margin:10px 0;padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.62)}.inlineNotice.error{color:var(--danger)}
.faktiskaProduction{margin-top:20px}.faktiskaProduction>h2{font-size:22px}
.faktiskaResolutionChoices{display:flex;gap:8px;margin:0 0 14px}.faktiskaResolutionChoices button{min-width:150px}.faktiskaResolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong)}
+4 -2
View File
@@ -23,10 +23,12 @@
.warningCheckboxLabel{position:relative;display:inline-flex;flex:0 0 auto;width:22px;height:22px;cursor:pointer}
.warningCheckboxLabel .warningCheckboxInput{position:absolute;opacity:0;height:0;width:0;margin:0}
.warningCheckmark{position:absolute;inset:0;background-color:#eee;border-radius:5px;transition:background-color .3s;box-shadow:0 2px 5px rgba(0,0,0,.2)}
.warningCheckmark{position:absolute;inset:0;background:#fff;border:1px solid #aebed1;border-radius:5px;transition:all .25s}
.warningCheckmark:after{content:"";position:absolute;display:none;left:7px;top:3px;width:5px;height:10px;border:solid #fff;border-width:0 3px 3px 0;transform:rotate(45deg)}
.warningCheckboxInput:checked ~ .warningCheckmark{background-color:#2196F3;box-shadow:0 3px 7px rgba(33,150,243,.3)}
.warningCheckboxLabel:hover .warningCheckmark{border-color:var(--accent)}
.warningCheckboxInput:checked ~ .warningCheckmark{border-color:transparent;background:linear-gradient(145deg,#3388d7,#1763aa);box-shadow:0 0 0 3px rgba(36,119,197,.13)}
.warningCheckboxInput:checked ~ .warningCheckmark:after{display:block;animation:warningCheckAnim .2s forwards}
.warningCheckboxInput:focus-visible ~ .warningCheckmark{outline:3px solid var(--accent-soft);outline-offset:2px}
@keyframes warningCheckAnim{0%{height:0}100%{height:10px}}
.warningBackdrop{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(10,20,35,.55);backdrop-filter:blur(6px);animation:warningFadeIn 180ms ease}
@@ -1 +1 @@
.waterPage{max-width:1900px}.waterHeading{margin-bottom:18px}.waterSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.waterSetup h2{margin:0 0 14px;font-size:20px}.resolutionChoices{display:grid;gap:9px}.resolutionChoices button{text-align:left}.resolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong);box-shadow:inset 0 0 0 1px var(--accent)}.rangeGrid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:12px}.rangeField{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:end;margin:0;padding:10px 12px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46);transition:border-color 180ms,background 180ms}.rangeField.manual{border-color:rgba(36,119,197,.58);background:rgba(229,241,255,.76)}.rangeField.missing{border-style:dashed}.rangeField legend{padding:0 5px;font-weight:700}.rangeFieldMeta{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:8px;margin:-2px 0 2px}.rangeFieldMeta span{font-size:10px;color:var(--text-muted)}.rangeField label{display:grid;gap:4px}.rangeField label span{font-size:11px;color:var(--text-muted)}.rangeField input{width:100%;font-size:18px;font-weight:700}.rangeSeparator{padding-bottom:9px;font-weight:700}.waterDataBand{padding:2px 0 22px;border-bottom:1px solid rgba(90,126,166,.22)}.waterStatus{display:flex;align-items:center;justify-content:space-between;gap:24px}.waterStatus>div,.waterStatus strong,.waterStatus small{display:block}.waterStatus strong{margin-top:5px;font-size:18px}.waterStatus small{margin-top:3px;color:var(--text-muted)}.waterPreview{padding-top:24px}.waterNotice{margin:0 0 10px;color:var(--text-muted);font-size:13px}.waterPreview canvas{display:block;width:100%;height:auto}@media(max-width:1050px){.waterSetup{grid-template-columns:1fr}.resolutionChoices{grid-template-columns:repeat(2,minmax(0,1fr))}.rangeGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}}@media(max-width:650px){.rangeGrid,.resolutionChoices{grid-template-columns:1fr}.waterStatus{align-items:flex-start;flex-direction:column}}
.waterPage{max-width:1900px}.waterHeading{margin-bottom:18px}.waterSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.waterSetup h2{margin:0 0 14px;font-size:20px}.resolutionChoices{display:grid;gap:10px}.resolutionChoices button{text-align:left;min-height:46px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46);font-weight:700;transition:border-color 180ms,background 180ms}.resolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong);box-shadow:inset 0 0 0 1px var(--accent)}.rangeGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(165px,1fr));gap:12px}.rangeField{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:end;margin:0;padding:10px 12px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46);transition:border-color 180ms,background 180ms}.rangeField.manual{border-color:rgba(36,119,197,.58);background:rgba(229,241,255,.76)}.rangeField.missing{border-style:dashed}.rangeField legend{padding:0 5px;font-weight:700}.rangeFieldMeta{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:8px;margin:-2px 0 2px}.rangeFieldMeta span{font-size:10px;color:var(--text-muted)}.rangeField label{display:grid;gap:4px}.rangeField label span{font-size:11px;color:var(--text-muted)}.rangeField input{width:100%;font-size:18px;font-weight:700;-moz-appearance:textfield}.rangeField input::-webkit-inner-spin-button,.rangeField input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.rangeSeparator{padding-bottom:9px;font-weight:700}.waterDataBand{padding:2px 0 22px;border-bottom:1px solid rgba(90,126,166,.22)}.waterStatus{display:flex;align-items:center;justify-content:space-between;gap:24px}.waterStatus>div,.waterStatus strong,.waterStatus small{display:block}.waterStatus strong{margin-top:5px;font-size:18px}.waterStatus small{margin-top:3px;color:var(--text-muted)}.waterPreview{padding-top:24px}.waterNotice{margin:0 0 10px;color:var(--text-muted);font-size:13px}.waterPreview canvas{display:block;width:100%;height:auto}@media(max-width:1050px){.waterSetup{grid-template-columns:1fr}.resolutionChoices{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.resolutionChoices{grid-template-columns:1fr}.waterStatus{align-items:flex-start;flex-direction:column}}