Compare commits
21 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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+9
-4
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -11,12 +11,15 @@ This directory contains the working documentation for the WeatherTool modernizat
|
||||
- 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 `b0b58d2` is deployed as immutable image `weathertool:b0b58d2f1e0ed47ca13795b64595386ec2f0e0c7`; release `3eddf95` remains the immediate application rollback. On top of the earlier frontend design pass and the timezone/upsert/scheduler-split fixes, this release replaces 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, compiled, live-verified on Rocky, and deployed to the VPS as release `0be325f` (image `weathertool:0be325fbbbf92b03bc8d574dc6f7b9449ef310ea`); release `b0b58d2` remains the immediate application rollback. 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.
|
||||
- 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.
|
||||
@@ -34,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
|
||||
|
||||
|
||||
+19
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -446,4 +448,16 @@ Record completed work here by date and commit after the Git workflow is establis
|
||||
| 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.
|
||||
@@ -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])]
|
||||
|
||||
@@ -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