Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e408fad26c | |||
| cb7e9aa06b | |||
| a6b2b8462c | |||
| 829319edf8 | |||
| 39478bde1d | |||
| 5cf2ad58f5 | |||
| 001b014932 | |||
| b561bd800c | |||
| 4c40c244df | |||
| 07b16accb2 | |||
| e1ce1533bd | |||
| e62895ad68 | |||
| 1e83e48cba | |||
| 464e03581c | |||
| ea583ed57d | |||
| 27ab795f54 | |||
| 2c78ae8286 | |||
| 7e1b492a0c | |||
| 1a99515182 | |||
| a989d2a6a8 | |||
| 5b88e69c40 | |||
| 1e6f5298ae | |||
| 9bab93daa6 | |||
| e33e78e2d4 | |||
| 0be325fbbb | |||
| 8c45d8de1d | |||
| 6b9c7cf4ae | |||
| fdd5508e43 | |||
| 546ad46546 | |||
| b0b58d2f1e | |||
| 2c4488846e |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 1050–1900px, 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 (375–1900px) 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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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()))
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user