feat(web): persist dashboard layout
This commit is contained in:
parent
3973a60233
commit
799c2e0a52
20 changed files with 1181 additions and 65 deletions
15
contracts/dashboard-layout-v1.json
Normal file
15
contracts/dashboard-layout-v1.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"format": "trainlog-dashboard-layout-contract",
|
||||
"version": 1,
|
||||
"columns": 12,
|
||||
"max_y": 200,
|
||||
"tiles": [
|
||||
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
||||
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
||||
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
||||
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
||||
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
||||
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
||||
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
||||
]
|
||||
}
|
||||
|
|
@ -302,6 +302,13 @@ It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI
|
|||
exports, or session synchronization. Small-screen layouts are derived
|
||||
responsive projections, not a second persisted truth.
|
||||
|
||||
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` implements this path as a strict private
|
||||
0600 JSON preference, atomically replaced without SQLite involvement. GET/PUT/
|
||||
DELETE `/api/v1/dashboard-layout` expose the canonical Trainlog shape. ETag and
|
||||
If-Match guard optimistic revisions; mutation additionally requires exact
|
||||
loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
|
||||
Invalid files are preserved for diagnosis while GET safely returns the default.
|
||||
|
||||
`WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
|
||||
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
|
||||
projection; statements are finalized before return. The seven domains mean:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ parallel implementation of its rules.
|
|||
| Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` |
|
||||
| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` |
|
||||
| Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
|
||||
| Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` |
|
||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
|
||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||
|
|
@ -195,9 +196,15 @@ and 12 Vitest tests for parsing and factual Footer integration.
|
|||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` uses `react-grid-layout` 2.2.4 behind an
|
||||
independent Trainlog model/validator. Its 12-column desktop layout has seven
|
||||
stable tile IDs, bounded per-tile dimensions, vertical collision compaction,
|
||||
explicit edit/cancel/reset/session-only-save behavior, mouse drag/two-axis
|
||||
explicit edit/cancel/reset behavior, mouse drag/two-axis
|
||||
resize, an announced keyboard alternative, and derived six/one-column
|
||||
responsive projections. No layout is persisted yet.
|
||||
responsive projections. Persistence is supplied by the following frozen slice.
|
||||
|
||||
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` adds a strict versioned seven-tile file
|
||||
under the private XDG configuration directory, backend revalidation, atomic
|
||||
0600 replacement, ETag/If-Match conflict control, Origin plus ephemeral CSRF
|
||||
protection, and durable React load/save/reset behavior. Only the desktop
|
||||
12-column canon persists.
|
||||
|
||||
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
||||
formatting, layout-invariance and source-derived text-boundary tests, plus
|
||||
|
|
@ -214,9 +221,9 @@ smoke test are not automated. The latter is why
|
|||
- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot,
|
||||
`/api/v1/dashboard`, explicit unavailable states and factual Footer data are
|
||||
implemented. The seven tile bodies intentionally retain shell placeholders.
|
||||
- `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`: layout changes live only in current
|
||||
React memory. Versioned disk/API persistence is the next tranche,
|
||||
`WEB_DASHBOARD_LAYOUT_V1`.
|
||||
- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is
|
||||
complete. The seven tile bodies remain placeholders for
|
||||
`WEB_DASHBOARD_TILES_V1`.
|
||||
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
||||
- AI proposal exchange still awaits one real Drive plus Android-triggered
|
||||
bidirectional smoke test.
|
||||
|
|
|
|||
79
docs/design/web_dashboard_layout_v1.md
Normal file
79
docs/design/web_dashboard_layout_v1.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Web Dashboard layout V1
|
||||
|
||||
Status: `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`
|
||||
|
||||
## File contract
|
||||
|
||||
The canonical UI preference is
|
||||
`$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json`, falling back to
|
||||
`$HOME/.config/trainlog/web/dashboard-layout-v1.json`. Trainlog-owned
|
||||
directories are mode 0700 and the regular file is mode 0600. It is not SQLite,
|
||||
training data, an exchange format or synchronization input.
|
||||
|
||||
The strict document has exactly `format`, `version`, `revision`, `columns` and
|
||||
`tiles`. Format is `trainlog-dashboard-layout`, version is 1, columns is 12,
|
||||
and all seven stable Grid V1 IDs occur exactly once. Tiles contain only
|
||||
`id,x,y,width,height`; responsive projections and translated titles never
|
||||
persist. The canonical constraint/default table is
|
||||
`contracts/dashboard-layout-v1.json`; TypeScript consumes it directly and a
|
||||
Meson contract test rejects C divergence.
|
||||
|
||||
Revision zero denotes no valid persisted preference. A successful PUT stores
|
||||
the expected revision plus one. The maximum is JavaScript's safe integer,
|
||||
9,007,199,254,740,991. Coordinates are integral, `y <= 200`, dimensions obey
|
||||
the frozen per-tile bounds, all tiles fit 12 columns and overlap is forbidden.
|
||||
|
||||
## Parser and boundedness
|
||||
|
||||
Trainlog uses system `yyjson` 0.13.0 (MIT), selected for strict typed C parsing,
|
||||
small surface and pkg-config/Meson integration. HTTP bodies and files are both
|
||||
limited to 4 KiB. Only the exact four-level schema is accepted; unexpected
|
||||
keys, types, array sizes and unsupported versions are rejected. IDs fit a
|
||||
32-byte bounded buffer and the tile count is exactly seven. No general JSON
|
||||
framework was introduced.
|
||||
|
||||
Reads use `O_NOFOLLOW|O_CLOEXEC|O_NONBLOCK`, `fstat`, regular-file and size
|
||||
checks. A missing file is normal. An invalid, oversized, non-regular or symlink
|
||||
preference yields the complete default with source `invalid_persisted`; it is
|
||||
not silently removed or replaced, and the server emits one bounded diagnostic.
|
||||
|
||||
## Atomic mutation
|
||||
|
||||
PUT validates before filesystem mutation, creates a same-directory `mkstemp`,
|
||||
sets 0600 and close-on-exec, writes fully, fsyncs, closes, atomically renames,
|
||||
then attempts directory fsync. Failures before rename unlink the temporary and
|
||||
leave the prior file untouched. After rename the visible commit is authoritative
|
||||
even if directory fsync cannot prove crash durability, so clients are never
|
||||
invited to retry against a stale revision.
|
||||
|
||||
DELETE checks the same revision then unlinks only the preference and attempts
|
||||
directory fsync. It never touches SQLite. Saving the default through PUT is
|
||||
valid and deliberate; frontend Reset remains a draft until Save.
|
||||
|
||||
## HTTP and mutation security
|
||||
|
||||
`GET /api/v1/dashboard-layout` returns default, persisted or invalid-persisted
|
||||
source, an ETag containing the revision and an ephemeral CSRF token header.
|
||||
`PUT` and `DELETE` require the loaded ETag in `If-Match`; stale revisions return
|
||||
412. Missing/malformed preconditions return 428.
|
||||
|
||||
Mutations additionally require an exact Origin of either the direct active
|
||||
loopback URL or `http://trainlog.perf`, plus the 256-bit startup-random token in
|
||||
`X-Trainlog-CSRF-Token`. The token is never persisted, logged or placed in a
|
||||
URL. nginx may keep rewriting backend Host to the already accepted
|
||||
`127.0.0.1:8080`; Origin remains the browser origin and requires no proxy
|
||||
change. Host validation, loopback binding, no-CORS behavior and request bounds
|
||||
remain unchanged.
|
||||
|
||||
## Frontend lifecycle
|
||||
|
||||
The Dashboard withholds the grid behind a discreet loading state until GET
|
||||
finishes, avoiding default-to-persisted flashing. Network failure falls back to
|
||||
the default with an accessible warning and disables Save because no safe
|
||||
revision/token was loaded. Entry into edit mode snapshots the loaded canon;
|
||||
Cancel is local, Reset changes only the draft, and Save validates then PUTs.
|
||||
Success adopts the server response and revision. A 412 keeps editing open,
|
||||
announces the conflict and exposes `Recharger l'agencement`.
|
||||
|
||||
Only the 12-column desktop canon is sent. Six-column/tablet and one-column/phone
|
||||
projections remain derived and cannot trigger mutation.
|
||||
|
|
@ -26,13 +26,13 @@ implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
|||
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
|
||||
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
||||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||
contract and the frozen interactive grid exist. Final tile rendering and layout
|
||||
persistence remain future slices.
|
||||
contract, frozen interactive grid and versioned layout persistence exist. Final
|
||||
tile rendering remains a future slice.
|
||||
|
||||
## Current cursor
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_LAYOUT_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_TILES_V1
|
||||
```
|
||||
|
||||
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
|
||||
|
|
@ -43,6 +43,9 @@ undifferentiated implementation of every Dashboard concern.
|
|||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
|
||||
explicit edit mode, drag/resize, keyboard alternative and derived responsive
|
||||
projections without persisting layout state.
|
||||
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop
|
||||
layout with atomic XDG storage, optimistic revisions and protected HTTP
|
||||
mutations; responsive projections remain derived.
|
||||
|
||||
## TRAINLOG_WEB_V1 implementation gate
|
||||
|
||||
|
|
@ -75,7 +78,7 @@ must consume them without reconstructing or redefining their mathematics.
|
|||
```text
|
||||
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
|
||||
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
|
||||
-> WEB_DASHBOARD_LAYOUT_V1
|
||||
-> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN]
|
||||
-> real-data tile delivery
|
||||
-> real-data visualization delivery
|
||||
```
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ normal-mode handles, edit actions, cancel/reset/session-only save, visible
|
|||
keyboard focus and live announcements. Existing shell tests continue to prove
|
||||
the factual Footer, navigation and absence of invented data.
|
||||
|
||||
`WEB_DASHBOARD_LAYOUT_V1` adds Core-independent filesystem tests for default,
|
||||
strict parsing, bounds, corruption, oversized/non-regular/symlink inputs,
|
||||
private permissions, atomic replacement, revision conflicts, preservation on
|
||||
invalid writes and reset. A canonical JSON parity test prevents C/TypeScript
|
||||
constraint drift. HTTP tests cover GET/PUT/DELETE, ETag/If-Match, two stale
|
||||
clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest
|
||||
covers default/persisted/invalid sources, ETag/token parsing, canonical-only
|
||||
PUT, conflict translation, loading, local cancel/reset and durable Save.
|
||||
|
||||
## 2. Frozen Trainlog JSON v1
|
||||
|
||||
Run:
|
||||
|
|
|
|||
24
tools/validate_dashboard_layout_contract.py
Normal file
24
tools/validate_dashboard_layout_contract.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
contract = json.loads((root / "contracts/dashboard-layout-v1.json").read_text())
|
||||
source = (root / "tui/src/dashboard_layout.c").read_text()
|
||||
frontend = (root / "web/src/dashboard/dashboardLayout.ts").read_text()
|
||||
|
||||
if contract.get("columns") != 12 or contract.get("max_y") != 200 or len(contract.get("tiles", [])) != 7:
|
||||
sys.exit("invalid canonical dashboard layout contract")
|
||||
for tile in contract["tiles"]:
|
||||
constraint = '{{"{}", {}U, {}U, {}U, {}U}}'.format(
|
||||
tile["id"], tile["min_width"], tile["max_width"], tile["min_height"], tile["max_height"])
|
||||
default = tile["default"]
|
||||
default_fragment = '{{"{}", {}U, {}U, {}U, {}U}}'.format(
|
||||
tile["id"], default["x"], default["y"], default["width"], default["height"])
|
||||
if constraint not in source or default_fragment not in source:
|
||||
sys.exit(f"C contract drift: {tile['id']}")
|
||||
if "dashboard-layout-v1.json" not in frontend:
|
||||
sys.exit("frontend does not consume canonical dashboard layout contract")
|
||||
print("PASS dashboard layout contract C/TypeScript parity")
|
||||
59
tui/include/trainlog/dashboard_layout.h
Normal file
59
tui/include/trainlog/dashboard_layout.h
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#ifndef TRAINLOG_DASHBOARD_LAYOUT_H
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT 7U
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_COLUMNS 12U
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_MAX_Y 200U
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY 4096U
|
||||
#define TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION 9007199254740991ULL
|
||||
|
||||
typedef struct TrainlogDashboardTileLayout {
|
||||
char id[32];
|
||||
uint16_t x;
|
||||
uint16_t y;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
} TrainlogDashboardTileLayout;
|
||||
|
||||
typedef struct TrainlogDashboardLayout {
|
||||
uint64_t revision;
|
||||
TrainlogDashboardTileLayout tiles[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT];
|
||||
} TrainlogDashboardLayout;
|
||||
|
||||
typedef enum TrainlogDashboardLayoutSource {
|
||||
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT = 0,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
|
||||
} TrainlogDashboardLayoutSource;
|
||||
|
||||
typedef enum TrainlogDashboardLayoutResult {
|
||||
TRAINLOG_DASHBOARD_LAYOUT_OK = 0,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_INVALID,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_CONFLICT,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR
|
||||
} TrainlogDashboardLayoutResult;
|
||||
|
||||
/* CONTRACT: this is the sole backend owner of the versioned Dashboard UI
|
||||
* preference. It never accesses SQLite. Callers own all buffers and layouts.
|
||||
* Revision zero denotes the default/no-valid-file state. */
|
||||
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout);
|
||||
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout);
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
|
||||
const char *json, size_t size, TrainlogDashboardLayout *layout);
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
|
||||
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source);
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
|
||||
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
|
||||
TrainlogDashboardLayout *saved);
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(
|
||||
uint64_t expected_revision);
|
||||
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
|
||||
TrainlogDashboardLayoutSource source, bool include_source,
|
||||
char *output, size_t capacity, size_t *size);
|
||||
bool trainlog_dashboard_layout_path(char *output, size_t capacity);
|
||||
|
||||
#endif
|
||||
|
|
@ -8,6 +8,7 @@ mtp_dep = dependency('libmtp', required: true)
|
|||
|
||||
notcurses_dep = dependency('notcurses-core', required: true)
|
||||
microhttpd_dep = dependency('libmicrohttpd', required: true)
|
||||
yyjson_dep = dependency('yyjson', required: true)
|
||||
|
||||
utf8proc_dep = dependency('libutf8proc', required: false)
|
||||
if not utf8proc_dep.found()
|
||||
|
|
@ -38,12 +39,14 @@ if web_assets_enabled
|
|||
'../web/tsconfig.json',
|
||||
'../web/tsconfig.app.json',
|
||||
'../web/tsconfig.node.json',
|
||||
'../contracts/dashboard-layout-v1.json',
|
||||
'../web/vite.config.ts',
|
||||
'../web/index.html',
|
||||
'../web/src/main.tsx',
|
||||
'../web/src/vite-env.d.ts',
|
||||
'../web/src/api/health.ts',
|
||||
'../web/src/api/dashboard.ts',
|
||||
'../web/src/api/dashboardLayout.ts',
|
||||
'../web/src/app/App.tsx',
|
||||
'../web/src/app/routes.ts',
|
||||
'../web/src/app/useRoute.ts',
|
||||
|
|
@ -239,6 +242,7 @@ trainlog_tui_sources = files(
|
|||
'src/terminal.c',
|
||||
'src/theme.c',
|
||||
'src/tui.c',
|
||||
'src/dashboard_layout.c',
|
||||
'src/web_server.c',
|
||||
)
|
||||
trainlog_tui_sources += web_assets_source
|
||||
|
|
@ -250,6 +254,7 @@ trainlog_exe = executable(
|
|||
trainlog_core_dep,
|
||||
notcurses_dep,
|
||||
microhttpd_dep,
|
||||
yyjson_dep,
|
||||
],
|
||||
include_directories: trainlog_private_include,
|
||||
c_args: strict_c_args,
|
||||
|
|
@ -330,12 +335,23 @@ test('cli', test_cli)
|
|||
|
||||
test_web_server = executable(
|
||||
'test_web_server',
|
||||
['tests/test_web_server.c', 'src/web_server.c', web_assets_source],
|
||||
dependencies: [trainlog_core_dep, microhttpd_dep],
|
||||
['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c', web_assets_source],
|
||||
dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep],
|
||||
include_directories: trainlog_private_include,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('web_server', test_web_server, timeout: 30)
|
||||
|
||||
test_dashboard_layout = executable(
|
||||
'test_dashboard_layout',
|
||||
['tests/test_dashboard_layout.c', 'src/dashboard_layout.c'],
|
||||
dependencies: yyjson_dep,
|
||||
include_directories: trainlog_include,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('dashboard_layout', test_dashboard_layout)
|
||||
test('dashboard_layout_contract', find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tools/validate_dashboard_layout_contract.py'])
|
||||
test('cli_help', trainlog_exe, args: ['--help'])
|
||||
test('cli_version', trainlog_exe, args: ['--version'])
|
||||
|
||||
|
|
|
|||
325
tui/src/dashboard_layout.c
Normal file
325
tui/src/dashboard_layout.c
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
#include "trainlog/dashboard_layout.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <yyjson.h>
|
||||
|
||||
typedef struct TileContract {
|
||||
const char *id;
|
||||
uint16_t min_width;
|
||||
uint16_t max_width;
|
||||
uint16_t min_height;
|
||||
uint16_t max_height;
|
||||
} TileContract;
|
||||
|
||||
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||
{"next-session", 4U, 8U, 3U, 7U},
|
||||
{"activity", 5U, 12U, 3U, 7U},
|
||||
{"progression", 6U, 12U, 4U, 9U},
|
||||
{"last-session", 3U, 7U, 3U, 6U},
|
||||
{"max-records", 3U, 8U, 3U, 7U},
|
||||
{"muscle-distribution", 5U, 12U, 4U, 9U},
|
||||
{"cardio-recovery", 5U, 12U, 4U, 8U}
|
||||
};
|
||||
|
||||
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||
{"next-session", 0U, 0U, 5U, 4U}, {"activity", 5U, 0U, 7U, 4U},
|
||||
{"progression", 0U, 4U, 8U, 5U}, {"last-session", 8U, 4U, 4U, 3U},
|
||||
{"max-records", 8U, 7U, 4U, 3U},
|
||||
{"muscle-distribution", 0U, 9U, 6U, 5U},
|
||||
{"cardio-recovery", 6U, 10U, 6U, 4U}
|
||||
};
|
||||
|
||||
static int contract_index(const char *id)
|
||||
{
|
||||
size_t index;
|
||||
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index)
|
||||
if (strcmp(id, contracts[index].id) == 0) return (int)index;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout)
|
||||
{
|
||||
if (layout == NULL) return;
|
||||
layout->revision = 0U;
|
||||
(void)memcpy(layout->tiles, defaults, sizeof(defaults));
|
||||
}
|
||||
|
||||
static bool overlap(const TrainlogDashboardTileLayout *a,
|
||||
const TrainlogDashboardTileLayout *b)
|
||||
{
|
||||
return a->x < b->x + b->width && a->x + a->width > b->x &&
|
||||
a->y < b->y + b->height && a->y + a->height > b->y;
|
||||
}
|
||||
|
||||
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout)
|
||||
{
|
||||
bool seen[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {false};
|
||||
size_t index;
|
||||
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
|
||||
return false;
|
||||
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
|
||||
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
|
||||
int found = contract_index(tile->id);
|
||||
const TileContract *contract;
|
||||
size_t other;
|
||||
if (found < 0 || seen[(size_t)found]) return false;
|
||||
seen[(size_t)found] = true;
|
||||
contract = &contracts[(size_t)found];
|
||||
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y ||
|
||||
tile->width < contract->min_width || tile->width > contract->max_width ||
|
||||
tile->height < contract->min_height || tile->height > contract->max_height ||
|
||||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS)
|
||||
return false;
|
||||
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other)
|
||||
if (overlap(tile, &layout->tiles[other])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool exact_object(const yyjson_val *value, size_t expected)
|
||||
{
|
||||
return yyjson_is_obj(value) && yyjson_obj_size(value) == expected;
|
||||
}
|
||||
|
||||
static bool read_u16(yyjson_val *value, uint16_t *output)
|
||||
{
|
||||
uint64_t number;
|
||||
if (!yyjson_is_uint(value)) return false;
|
||||
number = yyjson_get_uint(value);
|
||||
if (number > UINT16_MAX) return false;
|
||||
*output = (uint16_t)number;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool equals_uint(yyjson_val *value, uint64_t expected)
|
||||
{
|
||||
return yyjson_is_uint(value) && yyjson_get_uint(value) == expected;
|
||||
}
|
||||
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
|
||||
const char *json, size_t size, TrainlogDashboardLayout *layout)
|
||||
{
|
||||
yyjson_read_err error;
|
||||
yyjson_doc *document;
|
||||
yyjson_val *root;
|
||||
yyjson_val *tiles;
|
||||
size_t index;
|
||||
if (json == NULL || layout == NULL || size == 0U ||
|
||||
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY)
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
document = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error);
|
||||
if (document == NULL) return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
root = yyjson_doc_get_root(document);
|
||||
if (!exact_object(root, 5U) ||
|
||||
!yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-dashboard-layout") ||
|
||||
!equals_uint(yyjson_obj_get(root, "version"), 1U) ||
|
||||
!equals_uint(yyjson_obj_get(root, "columns"), 12U) ||
|
||||
!yyjson_is_uint(yyjson_obj_get(root, "revision"))) {
|
||||
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
}
|
||||
layout->revision = yyjson_get_uint(yyjson_obj_get(root, "revision"));
|
||||
tiles = yyjson_obj_get(root, "tiles");
|
||||
if (!yyjson_is_arr(tiles) || yyjson_arr_size(tiles) != TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT) {
|
||||
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
}
|
||||
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
|
||||
yyjson_val *tile = yyjson_arr_get(tiles, index);
|
||||
yyjson_val *id = yyjson_obj_get(tile, "id");
|
||||
const char *text;
|
||||
size_t length;
|
||||
if (!exact_object(tile, 5U) || !yyjson_is_str(id) ||
|
||||
!read_u16(yyjson_obj_get(tile, "x"), &layout->tiles[index].x) ||
|
||||
!read_u16(yyjson_obj_get(tile, "y"), &layout->tiles[index].y) ||
|
||||
!read_u16(yyjson_obj_get(tile, "width"), &layout->tiles[index].width) ||
|
||||
!read_u16(yyjson_obj_get(tile, "height"), &layout->tiles[index].height)) {
|
||||
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
}
|
||||
text = yyjson_get_str(id); length = yyjson_get_len(id);
|
||||
if (length == 0U || length >= sizeof(layout->tiles[index].id)) {
|
||||
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
}
|
||||
(void)memcpy(layout->tiles[index].id, text, length);
|
||||
layout->tiles[index].id[length] = '\0';
|
||||
}
|
||||
yyjson_doc_free(document);
|
||||
return trainlog_dashboard_layout_validate(layout)
|
||||
? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
}
|
||||
|
||||
bool trainlog_dashboard_layout_path(char *output, size_t capacity)
|
||||
{
|
||||
const char *base = getenv("XDG_CONFIG_HOME");
|
||||
const char *home;
|
||||
int length;
|
||||
if (output == NULL || capacity == 0U) return false;
|
||||
if (base != NULL && base[0] != '\0')
|
||||
length = snprintf(output, capacity, "%s/trainlog/web/dashboard-layout-v1.json", base);
|
||||
else {
|
||||
home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') return false;
|
||||
length = snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
|
||||
}
|
||||
return length > 0 && (size_t)length < capacity;
|
||||
}
|
||||
|
||||
static bool ensure_private_directories(const char *path)
|
||||
{
|
||||
char web[4096];
|
||||
char trainlog[4096];
|
||||
char *last;
|
||||
struct stat status;
|
||||
if (path == NULL || strlen(path) >= sizeof(web)) return false;
|
||||
(void)snprintf(web, sizeof(web), "%s", path);
|
||||
last = strrchr(web, '/'); if (last == NULL) return false; *last = '\0';
|
||||
(void)snprintf(trainlog, sizeof(trainlog), "%s", web);
|
||||
last = strrchr(trainlog, '/'); if (last == NULL) return false; *last = '\0';
|
||||
/* CONTRACT: only the two Trainlog-owned directories are created/chmodded;
|
||||
* an existing symlink or non-directory is never followed. */
|
||||
{
|
||||
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) return false;
|
||||
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
|
||||
if (chmod(trainlog, 0700) != 0) return false;
|
||||
}
|
||||
if (mkdir(web, 0700) != 0 && errno != EEXIST) return false;
|
||||
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
|
||||
return chmod(web, 0700) == 0;
|
||||
}
|
||||
|
||||
static bool sync_parent(const char *path)
|
||||
{
|
||||
char parent[4096]; char *slash; int fd; bool ok;
|
||||
if (strlen(path) >= sizeof(parent)) return false;
|
||||
(void)snprintf(parent, sizeof(parent), "%s", path);
|
||||
slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0';
|
||||
fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (fd < 0) return false;
|
||||
ok = fsync(fd) == 0; if (close(fd) != 0) ok = false; return ok;
|
||||
}
|
||||
|
||||
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
|
||||
TrainlogDashboardLayoutSource source, bool include_source,
|
||||
char *output, size_t capacity, size_t *size)
|
||||
{
|
||||
size_t used = 0U; size_t index; int count;
|
||||
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED
|
||||
? "persisted" : source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
|
||||
? "invalid_persisted" : "default";
|
||||
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL)
|
||||
return false;
|
||||
#define APPEND(...) do { count = snprintf(output + used, capacity - used, __VA_ARGS__); \
|
||||
if (count < 0 || (size_t)count >= capacity - used) return false; used += (size_t)count; } while (0)
|
||||
APPEND("{\"format\":\"trainlog-dashboard-layout\",\"version\":1,"
|
||||
"\"revision\":%llu,\"columns\":12", (unsigned long long)layout->revision);
|
||||
if (include_source) APPEND(",\"source\":\"%s\"", source_name);
|
||||
APPEND(",\"tiles\":[");
|
||||
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
|
||||
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
|
||||
APPEND("%s{\"id\":\"%s\",\"x\":%u,\"y\":%u,\"width\":%u,\"height\":%u}",
|
||||
index == 0U ? "" : ",", tile->id, tile->x, tile->y,
|
||||
tile->width, tile->height);
|
||||
}
|
||||
APPEND("]}\n");
|
||||
#undef APPEND
|
||||
*size = used; return true;
|
||||
}
|
||||
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
|
||||
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source)
|
||||
{
|
||||
char path[4096]; char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
struct stat status; ssize_t count; int fd; TrainlogDashboardLayoutResult result;
|
||||
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path)))
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
trainlog_dashboard_layout_default(layout); *source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
|
||||
fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT || errno == ELOOP) {
|
||||
if (errno == ELOOP) *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
}
|
||||
if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 ||
|
||||
status.st_size >= (off_t)sizeof(buffer)) {
|
||||
(void)close(fd); *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
count = read(fd, buffer, (size_t)status.st_size);
|
||||
if (close(fd) != 0 || count != status.st_size) {
|
||||
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
result = trainlog_dashboard_layout_parse(buffer, (size_t)count, layout);
|
||||
if (result != TRAINLOG_DASHBOARD_LAYOUT_OK) {
|
||||
trainlog_dashboard_layout_default(layout);
|
||||
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
|
||||
} else *source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
|
||||
static bool write_all(int fd, const char *data, size_t size)
|
||||
{
|
||||
size_t used = 0U;
|
||||
while (used < size) { ssize_t count = write(fd, data + used, size - used);
|
||||
if (count < 0 && errno == EINTR) continue; if (count <= 0) return false; used += (size_t)count; }
|
||||
return true;
|
||||
}
|
||||
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
|
||||
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
|
||||
TrainlogDashboardLayout *saved)
|
||||
{
|
||||
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source;
|
||||
char path[4096], temporary[4128], json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
size_t size; int fd; bool ok;
|
||||
if (layout == NULL || saved == NULL || !trainlog_dashboard_layout_validate(layout) ||
|
||||
layout->revision != expected_revision || expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
|
||||
if (trainlog_dashboard_layout_load(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
|
||||
*saved = *layout; saved->revision = expected_revision + 1U;
|
||||
if (!trainlog_dashboard_layout_path(path, sizeof(path)) ||
|
||||
!ensure_private_directories(path) ||
|
||||
!trainlog_dashboard_layout_serialize(saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
|
||||
false, json, sizeof(json), &size) ||
|
||||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary))
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
fd = mkstemp(temporary);
|
||||
if (fd < 0) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
ok = fchmod(fd, 0600) == 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 &&
|
||||
write_all(fd, json, size) && fsync(fd) == 0;
|
||||
if (close(fd) != 0) ok = false;
|
||||
if (!ok || rename(temporary, path) != 0) {
|
||||
(void)unlink(temporary); return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
}
|
||||
/* INVARIANT: after rename the valid file is committed. Directory fsync is
|
||||
* attempted for crash durability, but its failure cannot roll back or make
|
||||
* a successful visible commit safe to retry against the old revision. */
|
||||
(void)sync_parent(path);
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
|
||||
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision)
|
||||
{
|
||||
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; char path[4096];
|
||||
if (trainlog_dashboard_layout_load(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
|
||||
if (!trainlog_dashboard_layout_path(path, sizeof(path))) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
if (unlink(path) != 0) {
|
||||
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK
|
||||
: TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
|
||||
}
|
||||
(void)sync_parent(path);
|
||||
return TRAINLOG_DASHBOARD_LAYOUT_OK;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
#include <errno.h>
|
||||
#include <microhttpd.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/random.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
#include <time.h>
|
||||
|
||||
#include "trainlog/web_dashboard.h"
|
||||
#include "trainlog/dashboard_layout.h"
|
||||
#include "web_assets.h"
|
||||
|
||||
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
|
||||
|
|
@ -26,11 +28,14 @@
|
|||
typedef struct TrainlogWebContext {
|
||||
uint16_t port;
|
||||
TrainlogDatabase *database;
|
||||
char csrf_token[65];
|
||||
bool invalid_layout_reported;
|
||||
} TrainlogWebContext;
|
||||
|
||||
typedef struct TrainlogHttpRequestState {
|
||||
size_t body_size;
|
||||
bool body_too_large;
|
||||
char body[TRAINLOG_HTTP_BODY_LIMIT + 1U];
|
||||
} TrainlogHttpRequestState;
|
||||
|
||||
static void web_diagnostic(char *output, size_t capacity, const char *message)
|
||||
|
|
@ -39,6 +44,35 @@ static void web_diagnostic(char *output, size_t capacity, const char *message)
|
|||
(void)snprintf(output, capacity, "%s", message);
|
||||
}
|
||||
|
||||
static enum MHD_Result queue_layout_json(struct MHD_Connection *connection,
|
||||
unsigned int status, const char *body, uint64_t revision,
|
||||
const char *csrf_token)
|
||||
{
|
||||
struct MHD_Response *response;
|
||||
enum MHD_Result result;
|
||||
char etag[32];
|
||||
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
|
||||
MHD_RESPMEM_MUST_COPY);
|
||||
if (response == NULL) return MHD_NO;
|
||||
(void)snprintf(etag, sizeof(etag), "\"%llu\"",
|
||||
(unsigned long long)revision);
|
||||
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"application/json; charset=utf-8") != MHD_YES ||
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
|
||||
"no-store") != MHD_YES ||
|
||||
MHD_add_response_header(response,
|
||||
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
|
||||
MHD_add_response_header(response, "Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES ||
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag) != MHD_YES ||
|
||||
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
|
||||
MHD_destroy_response(response); return MHD_NO;
|
||||
}
|
||||
result = MHD_queue_response(connection, status, response);
|
||||
MHD_destroy_response(response); return result;
|
||||
}
|
||||
|
||||
static enum MHD_Result queue_json(struct MHD_Connection *connection,
|
||||
unsigned int status, const char *body, const char *allow)
|
||||
{
|
||||
|
|
@ -139,6 +173,50 @@ static bool declared_body_too_large(struct MHD_Connection *connection)
|
|||
size > TRAINLOG_HTTP_BODY_LIMIT;
|
||||
}
|
||||
|
||||
static bool valid_origin(const TrainlogWebContext *context, const char *origin)
|
||||
{
|
||||
char direct[48];
|
||||
if (origin == NULL) return false;
|
||||
(void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u",
|
||||
(unsigned int)context->port);
|
||||
return strcmp(origin, direct) == 0 || strcmp(origin, "http://trainlog.perf") == 0;
|
||||
}
|
||||
|
||||
static bool valid_csrf(const TrainlogWebContext *context, const char *provided)
|
||||
{
|
||||
size_t index; unsigned char difference = 0U;
|
||||
if (provided == NULL || strlen(provided) != 64U) return false;
|
||||
for (index = 0U; index < 64U; ++index)
|
||||
difference |= (unsigned char)(provided[index] ^ context->csrf_token[index]);
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static bool generate_csrf_token(char output[65])
|
||||
{
|
||||
unsigned char random_bytes[32];
|
||||
size_t used = 0U;
|
||||
size_t index;
|
||||
while (used < sizeof(random_bytes)) {
|
||||
ssize_t count = getrandom(random_bytes + used, sizeof(random_bytes) - used, 0);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0) return false;
|
||||
used += (size_t)count;
|
||||
}
|
||||
for (index = 0U; index < sizeof(random_bytes); ++index)
|
||||
(void)snprintf(output + index * 2U, 3U, "%02x", random_bytes[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_if_match(const char *value, uint64_t *revision)
|
||||
{
|
||||
char *end = NULL; unsigned long long parsed;
|
||||
if (value == NULL || value[0] != '"') return false;
|
||||
errno = 0; parsed = strtoull(value + 1, &end, 10);
|
||||
if (errno != 0 || end == value + 1 || end[0] != '"' || end[1] != '\0' ||
|
||||
parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) return false;
|
||||
*revision = (uint64_t)parsed; return true;
|
||||
}
|
||||
|
||||
static enum MHD_Result handle_request(void *closure,
|
||||
struct MHD_Connection *connection, const char *url, const char *method,
|
||||
const char *version, const char *upload_data, size_t *upload_data_size,
|
||||
|
|
@ -167,8 +245,11 @@ static enum MHD_Result handle_request(void *closure,
|
|||
(state->body_size > TRAINLOG_HTTP_BODY_LIMIT
|
||||
? TRAINLOG_HTTP_BODY_LIMIT : state->body_size))
|
||||
state->body_too_large = true;
|
||||
else state->body_size += *upload_data_size;
|
||||
(void)upload_data;
|
||||
else {
|
||||
(void)memcpy(state->body + state->body_size, upload_data, *upload_data_size);
|
||||
state->body_size += *upload_data_size;
|
||||
state->body[state->body_size] = '\0';
|
||||
}
|
||||
*upload_data_size = 0U;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
|
@ -216,6 +297,94 @@ static enum MHD_Result handle_request(void *closure,
|
|||
"{\"error\":\"dashboard_unavailable\"}\n", NULL);
|
||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
||||
TrainlogDashboardLayout layout;
|
||||
TrainlogDashboardLayout saved;
|
||||
TrainlogDashboardLayoutSource source;
|
||||
TrainlogDashboardLayoutResult layout_result;
|
||||
char json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
size_t json_size;
|
||||
uint64_t expected;
|
||||
const char *origin;
|
||||
const char *csrf;
|
||||
const char *if_match;
|
||||
/* WHY: loopback alone does not stop a hostile webpage from submitting
|
||||
* requests to a local service. CONTRACT: every mutation needs exact
|
||||
* trusted Origin, the startup-random header token and an If-Match ETag.
|
||||
* INVARIANT: proxy Host rewriting remains strict and no CORS is added. */
|
||||
if (is_get) {
|
||||
layout_result = trainlog_dashboard_layout_load(&layout, &source);
|
||||
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
|
||||
!trainlog_dashboard_layout_serialize(&layout, source, true,
|
||||
json, sizeof(json), &json_size) || json_size == 0U)
|
||||
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"layout_unavailable\"}\n", NULL);
|
||||
if (source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED &&
|
||||
!context->invalid_layout_reported) {
|
||||
(void)fprintf(stderr, "Trainlog Web : agencement persisté invalide, défaut utilisé\n");
|
||||
context->invalid_layout_reported = true;
|
||||
}
|
||||
return queue_layout_json(connection, MHD_HTTP_OK, json,
|
||||
layout.revision, context->csrf_token);
|
||||
}
|
||||
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
||||
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0)
|
||||
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n", "GET, PUT, DELETE");
|
||||
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
|
||||
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
|
||||
"X-Trainlog-CSRF-Token");
|
||||
if (!valid_origin(context, origin) || !valid_csrf(context, csrf))
|
||||
return queue_json(connection, MHD_HTTP_FORBIDDEN,
|
||||
"{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
if_match = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
|
||||
MHD_HTTP_HEADER_IF_MATCH);
|
||||
if (!parse_if_match(if_match, &expected))
|
||||
return queue_json(connection, MHD_HTTP_PRECONDITION_REQUIRED,
|
||||
"{\"error\":\"precondition_required\"}\n", NULL);
|
||||
if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) {
|
||||
if (state->body_size != 0U)
|
||||
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"error\":\"unexpected_body\"}\n", NULL);
|
||||
layout_result = trainlog_dashboard_layout_delete(expected);
|
||||
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
|
||||
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
|
||||
"{\"error\":\"revision_conflict\"}\n", NULL);
|
||||
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK)
|
||||
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"layout_delete_failed\"}\n", NULL);
|
||||
trainlog_dashboard_layout_default(&layout);
|
||||
if (!trainlog_dashboard_layout_serialize(&layout,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, true, json, sizeof(json), &json_size))
|
||||
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"layout_unavailable\"}\n", NULL);
|
||||
return queue_layout_json(connection, MHD_HTTP_OK, json, 0U,
|
||||
context->csrf_token);
|
||||
}
|
||||
{
|
||||
const char *content_type = MHD_lookup_connection_value(connection,
|
||||
MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
|
||||
if (content_type == NULL || strcmp(content_type, "application/json") != 0)
|
||||
return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
||||
"{\"error\":\"unsupported_media_type\"}\n", NULL);
|
||||
}
|
||||
layout_result = trainlog_dashboard_layout_parse(state->body,
|
||||
state->body_size, &layout);
|
||||
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected)
|
||||
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"error\":\"invalid_layout\"}\n", NULL);
|
||||
layout_result = trainlog_dashboard_layout_save(&layout, expected, &saved);
|
||||
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
|
||||
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
|
||||
"{\"error\":\"revision_conflict\"}\n", NULL);
|
||||
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
|
||||
!trainlog_dashboard_layout_serialize(&saved,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, true, json, sizeof(json), &json_size))
|
||||
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"layout_save_failed\"}\n", NULL);
|
||||
return queue_layout_json(connection, MHD_HTTP_OK, json, saved.revision,
|
||||
context->csrf_token);
|
||||
}
|
||||
/* INVARIANT: API paths never fall through to the SPA index. */
|
||||
if (strncmp(url, "/api/", 5U) == 0)
|
||||
return queue_json(connection, MHD_HTTP_NOT_FOUND,
|
||||
|
|
@ -276,6 +445,12 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
|
|||
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
context.port = port;
|
||||
context.database = database;
|
||||
context.invalid_layout_reported = false;
|
||||
if (!generate_csrf_token(context.csrf_token)) {
|
||||
web_diagnostic(diagnostic, diagnostic_capacity,
|
||||
"impossible de générer la protection CSRF");
|
||||
return -1;
|
||||
}
|
||||
errno = 0;
|
||||
daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY,
|
||||
0U, accept_loopback_only, NULL, handle_request, &context,
|
||||
|
|
|
|||
117
tui/tests/test_dashboard_layout.c
Normal file
117
tui/tests/test_dashboard_layout.c
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trainlog/dashboard_layout.h"
|
||||
|
||||
#define CHECK(value) do { if (!(value)) { (void)fprintf(stderr, \
|
||||
"CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #value); return false; } } while (0)
|
||||
|
||||
static bool serialize_file_layout(TrainlogDashboardLayout *layout,
|
||||
char *json, size_t capacity, size_t *size)
|
||||
{
|
||||
return trainlog_dashboard_layout_serialize(layout,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, false, json, capacity, size);
|
||||
}
|
||||
|
||||
static bool test_validation(void)
|
||||
{
|
||||
TrainlogDashboardLayout layout;
|
||||
char json[4096]; size_t size;
|
||||
trainlog_dashboard_layout_default(&layout);
|
||||
CHECK(trainlog_dashboard_layout_validate(&layout));
|
||||
CHECK(serialize_file_layout(&layout, json, sizeof(json), &size));
|
||||
CHECK(trainlog_dashboard_layout_parse(json, size, &layout) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
layout.tiles[0].width = 1U; CHECK(!trainlog_dashboard_layout_validate(&layout));
|
||||
trainlog_dashboard_layout_default(&layout);
|
||||
(void)snprintf(layout.tiles[1].id, sizeof(layout.tiles[1].id), "%s", layout.tiles[0].id);
|
||||
CHECK(!trainlog_dashboard_layout_validate(&layout));
|
||||
trainlog_dashboard_layout_default(&layout); layout.tiles[1].x = 0U;
|
||||
CHECK(!trainlog_dashboard_layout_validate(&layout));
|
||||
trainlog_dashboard_layout_default(&layout); layout.tiles[0].y = 201U;
|
||||
CHECK(!trainlog_dashboard_layout_validate(&layout));
|
||||
CHECK(trainlog_dashboard_layout_parse("{}", 2U, &layout) == TRAINLOG_DASHBOARD_LAYOUT_INVALID);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool write_raw(const char *path, const char *text)
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); size_t size = strlen(text);
|
||||
ssize_t count; if (fd < 0) return false; count = write(fd, text, size);
|
||||
return close(fd) == 0 && count == (ssize_t)size;
|
||||
}
|
||||
|
||||
static bool test_persistence(void)
|
||||
{
|
||||
char root[] = "/tmp/trainlog-layout-test-XXXXXX";
|
||||
char path[4096]; char target[4096]; char json[4096]; size_t size;
|
||||
TrainlogDashboardLayout layout, saved, loaded;
|
||||
TrainlogDashboardLayoutSource source; struct stat status;
|
||||
CHECK(mkdtemp(root) != NULL); CHECK(setenv("XDG_CONFIG_HOME", root, 1) == 0);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT && loaded.revision == 0U);
|
||||
trainlog_dashboard_layout_default(&layout);
|
||||
layout.tiles[3].y = 14U;
|
||||
CHECK(trainlog_dashboard_layout_validate(&layout));
|
||||
CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
CHECK(saved.revision == 1U);
|
||||
CHECK(trainlog_dashboard_layout_path(path, sizeof(path)));
|
||||
CHECK(lstat(path, &status) == 0 && S_ISREG(status.st_mode) &&
|
||||
(status.st_mode & 0777U) == 0600U);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED && loaded.revision == 1U);
|
||||
layout = loaded; layout.tiles[0].width = 1U;
|
||||
CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_INVALID);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
loaded.revision == 1U);
|
||||
trainlog_dashboard_layout_default(&layout);
|
||||
layout.tiles[3].y = 14U;
|
||||
CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT);
|
||||
layout = loaded; layout.tiles[3].y = 18U;
|
||||
CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
CHECK(saved.revision == 2U);
|
||||
CHECK(trainlog_dashboard_layout_delete(1U) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT);
|
||||
CHECK(trainlog_dashboard_layout_delete(2U) == TRAINLOG_DASHBOARD_LAYOUT_OK);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT);
|
||||
CHECK(snprintf(target, sizeof(target), "%s/target", root) > 0);
|
||||
CHECK(write_raw(target, "sentinel")); CHECK(symlink(target, path) == 0);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
|
||||
CHECK(unlink(path) == 0);
|
||||
CHECK(write_raw(path, "{broken"));
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
|
||||
CHECK(access(path, F_OK) == 0);
|
||||
CHECK(unlink(path) == 0);
|
||||
{
|
||||
char oversized[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
(void)memset(oversized, 'x', sizeof(oversized));
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600);
|
||||
CHECK(fd >= 0 && write(fd, oversized, sizeof(oversized)) == (ssize_t)sizeof(oversized));
|
||||
CHECK(close(fd) == 0);
|
||||
}
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
|
||||
}
|
||||
CHECK(unlink(path) == 0 && mkdir(path, 0700) == 0);
|
||||
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
|
||||
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
|
||||
CHECK(rmdir(path) == 0);
|
||||
CHECK(serialize_file_layout(&loaded, json, sizeof(json), &size));
|
||||
CHECK(size < sizeof(json));
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
return test_validation() && test_persistence() ? 0 : 1;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
#include <sqlite3.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/dashboard_layout.h"
|
||||
#include "trainlog/web_server.h"
|
||||
#include "web_assets.h"
|
||||
|
||||
|
|
@ -123,11 +124,21 @@ static bool asset_path(const char *html, const char *suffix, char *output,
|
|||
return false;
|
||||
}
|
||||
|
||||
static bool response_header(const char *response, const char *name,
|
||||
char *output, size_t capacity)
|
||||
{
|
||||
const char *start = strstr(response, name); const char *end; size_t length;
|
||||
if (start == NULL) return false; start += strlen(name);
|
||||
end = strstr(start, "\r\n"); if (end == NULL) return false;
|
||||
length = (size_t)(end - start); if (length >= capacity) return false;
|
||||
(void)memcpy(output, start, length); output[length] = '\0'; return true;
|
||||
}
|
||||
|
||||
static bool test_http_contract(TrainlogDatabase *database)
|
||||
{
|
||||
char response[32768];
|
||||
char large_request[12000];
|
||||
char request[512];
|
||||
char request[8192];
|
||||
char javascript_path[256];
|
||||
char stylesheet_path[256];
|
||||
uint16_t port;
|
||||
|
|
@ -167,6 +178,55 @@ static bool test_http_contract(TrainlogDatabase *database)
|
|||
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
|
||||
CHECK(strstr(response, "\"window_days\":90") != NULL);
|
||||
CHECK(strstr(response, "\"partial\":false") != NULL);
|
||||
{
|
||||
TrainlogDashboardLayout layout;
|
||||
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
||||
char token[80]; char etag[32]; size_t body_size;
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
|
||||
CHECK(strstr(response, "\"source\":\"default\"") != NULL);
|
||||
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) && strcmp(etag, "\"0\"") == 0);
|
||||
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) && strlen(token) == 64U);
|
||||
trainlog_dashboard_layout_default(&layout); layout.tiles[3].y = 14U;
|
||||
CHECK(trainlog_dashboard_layout_serialize(&layout,
|
||||
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, false, body, sizeof(body), &body_size));
|
||||
(void)snprintf(request, sizeof(request),
|
||||
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
|
||||
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: wrong\r\n"
|
||||
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, body_size, body);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL);
|
||||
(void)snprintf(request, sizeof(request),
|
||||
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Origin: http://127.0.0.1:%u\r\nContent-Type: text/plain\r\n"
|
||||
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\n"
|
||||
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, token, body_size, body);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 415") != NULL);
|
||||
(void)snprintf(request, sizeof(request),
|
||||
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
|
||||
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: %zu\r\n\r\n%s",
|
||||
(unsigned int)port, token, body_size, body);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
|
||||
CHECK(strstr(response, "\"revision\":1") != NULL && strstr(response, "\"source\":\"persisted\"") != NULL);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
|
||||
(void)snprintf(request, sizeof(request),
|
||||
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Origin: http://trainlog.perf\r\nIf-Match: \"0\"\r\n"
|
||||
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
|
||||
(void)snprintf(request, sizeof(request),
|
||||
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Origin: http://trainlog.perf\r\nIf-Match: \"1\"\r\n"
|
||||
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
|
||||
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL &&
|
||||
strstr(response, "\"source\":\"default\"") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||
"Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
|
||||
response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL);
|
||||
}
|
||||
CHECK(exchange(port,
|
||||
"POST /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
|
||||
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL &&
|
||||
|
|
@ -296,8 +356,11 @@ static bool test_dashboard_core_error_translation(void)
|
|||
|
||||
int main(void)
|
||||
{
|
||||
char config_root[] = "/tmp/trainlog-web-config-XXXXXX";
|
||||
TrainlogDatabase *database = NULL;
|
||||
bool passed;
|
||||
if (mkdtemp(config_root) == NULL || setenv("XDG_CONFIG_HOME", config_root, 1) != 0)
|
||||
return 1;
|
||||
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK)
|
||||
return 1;
|
||||
if (!trainlog_web_assets_available()) {
|
||||
|
|
|
|||
57
web/src/api/dashboardLayout.test.ts
Normal file
57
web/src/api/dashboardLayout.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_DASHBOARD_LAYOUT } from '../dashboard/dashboardLayout'
|
||||
import { fetchDashboardLayout, parseDashboardLayout, saveDashboardLayout } from './dashboardLayout'
|
||||
|
||||
const token = 'a'.repeat(64)
|
||||
const payload = (source: 'default' | 'persisted' | 'invalid_persisted' = 'default', revision = 0) => ({
|
||||
format: 'trainlog-dashboard-layout', version: 1, revision, columns: 12,
|
||||
source, tiles: DEFAULT_DASHBOARD_LAYOUT,
|
||||
})
|
||||
|
||||
describe('API de persistance Dashboard', () => {
|
||||
it.each([['default', 0], ['persisted', 3], ['invalid_persisted', 0]] as const)(
|
||||
'valide une source backend %s', (source, revision) => {
|
||||
expect(parseDashboardLayout(payload(source, revision), `"${revision}"`, token).source).toBe(source)
|
||||
})
|
||||
|
||||
it('rejette une réponse sans ETag/token et un layout invalide', () => {
|
||||
expect(() => parseDashboardLayout(payload(), '', '')).toThrow()
|
||||
const invalid = payload(); invalid.tiles = [...DEFAULT_DASHBOARD_LAYOUT, DEFAULT_DASHBOARD_LAYOUT[0]]
|
||||
expect(() => parseDashboardLayout(invalid, '"0"', token)).toThrow()
|
||||
})
|
||||
|
||||
it('charge le layout et conserve ETag/token', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, status: 200,
|
||||
headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': token }),
|
||||
json: () => Promise.resolve(payload()) })))
|
||||
const loaded = await fetchDashboardLayout()
|
||||
expect(loaded.etag).toBe('"0"')
|
||||
expect(loaded.csrfToken).toBe(token)
|
||||
})
|
||||
|
||||
it('envoie uniquement le canon desktop avec If-Match et CSRF', async () => {
|
||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: new Headers({ ETag: '"1"', 'X-Trainlog-CSRF-Token': token }),
|
||||
json: () => Promise.resolve(payload('persisted', 1)),
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const current = parseDashboardLayout(payload(), '"0"', token)
|
||||
await saveDashboardLayout(current, DEFAULT_DASHBOARD_LAYOUT)
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit
|
||||
expect(init.method).toBe('PUT')
|
||||
expect((init.headers as Record<string, string>)['If-Match']).toBe('"0"')
|
||||
expect((init.headers as Record<string, string>)['X-Trainlog-CSRF-Token']).toBe(token)
|
||||
const body = JSON.parse(String(init.body))
|
||||
expect(body.columns).toBe(12)
|
||||
expect(body).not.toHaveProperty('source')
|
||||
expect(body).not.toHaveProperty('breakpoints')
|
||||
})
|
||||
|
||||
it('traduit 412 en conflit explicite', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, status: 412,
|
||||
headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) })))
|
||||
await expect(saveDashboardLayout(parseDashboardLayout(payload(), '"0"', token),
|
||||
DEFAULT_DASHBOARD_LAYOUT)).rejects.toThrow('revision_conflict')
|
||||
})
|
||||
})
|
||||
72
web/src/api/dashboardLayout.ts
Normal file
72
web/src/api/dashboardLayout.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { TILE_IDS, validateDashboardLayout, type TileLayout } from '../dashboard/dashboardLayout'
|
||||
|
||||
export interface DashboardLayoutSnapshot {
|
||||
format: 'trainlog-dashboard-layout'
|
||||
version: 1
|
||||
revision: number
|
||||
columns: 12
|
||||
source: 'default' | 'persisted' | 'invalid_persisted'
|
||||
tiles: TileLayout[]
|
||||
etag: string
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
export function parseDashboardLayout(value: unknown, etag: string, csrfToken: string): DashboardLayoutSnapshot {
|
||||
if (!record(value) || value.format !== 'trainlog-dashboard-layout' || value.version !== 1 ||
|
||||
value.columns !== 12 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 ||
|
||||
!['default', 'persisted', 'invalid_persisted'].includes(String(value.source)) || !Array.isArray(value.tiles))
|
||||
throw new TypeError('agencement invalide')
|
||||
const tiles = value.tiles.map((tile) => {
|
||||
if (!record(tile) || typeof tile.id !== 'string' || !TILE_IDS.includes(tile.id as TileLayout['id']) ||
|
||||
!Number.isInteger(tile.x) || !Number.isInteger(tile.y) ||
|
||||
!Number.isInteger(tile.width) || !Number.isInteger(tile.height))
|
||||
throw new TypeError('tuile d’agencement invalide')
|
||||
return { id: tile.id, x: tile.x, y: tile.y, width: tile.width, height: tile.height } as TileLayout
|
||||
})
|
||||
if (validateDashboardLayout(tiles).length !== 0 || !/^"\d+"$/.test(etag) || csrfToken.length !== 64)
|
||||
throw new TypeError('contrat d’agencement invalide')
|
||||
return { ...value, tiles, etag, csrfToken } as DashboardLayoutSnapshot
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response): Promise<DashboardLayoutSnapshot> {
|
||||
if (!response.ok) throw new Error(`dashboard-layout HTTP ${response.status}`)
|
||||
return parseDashboardLayout(await response.json(), response.headers.get('ETag') ?? '',
|
||||
response.headers.get('X-Trainlog-CSRF-Token') ?? '')
|
||||
}
|
||||
|
||||
export async function fetchDashboardLayout(signal?: AbortSignal): Promise<DashboardLayoutSnapshot> {
|
||||
return parseResponse(await fetch('/api/v1/dashboard-layout', {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function saveDashboardLayout(snapshot: DashboardLayoutSnapshot,
|
||||
tiles: readonly TileLayout[]): Promise<DashboardLayoutSnapshot> {
|
||||
const body = {
|
||||
format: 'trainlog-dashboard-layout', version: 1, revision: snapshot.revision,
|
||||
columns: 12, tiles,
|
||||
}
|
||||
const response = await fetch('/api/v1/dashboard-layout', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'If-Match': snapshot.etag, 'X-Trainlog-CSRF-Token': snapshot.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (response.status === 412) throw new Error('revision_conflict')
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteDashboardLayout(snapshot: DashboardLayoutSnapshot): Promise<DashboardLayoutSnapshot> {
|
||||
const response = await fetch('/api/v1/dashboard-layout', {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json', 'If-Match': snapshot.etag,
|
||||
'X-Trainlog-CSRF-Token': snapshot.csrfToken },
|
||||
})
|
||||
if (response.status === 412) throw new Error('revision_conflict')
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
|
@ -25,11 +25,30 @@ const dashboard: DashboardSnapshot = {
|
|||
meta: { partial: false, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' },
|
||||
}
|
||||
|
||||
const dashboardLayout = {
|
||||
format: 'trainlog-dashboard-layout', version: 1, revision: 0, columns: 12,
|
||||
source: 'default',
|
||||
tiles: [
|
||||
{ id: 'next-session', x: 0, y: 0, width: 5, height: 4 },
|
||||
{ id: 'activity', x: 5, y: 0, width: 7, height: 4 },
|
||||
{ id: 'progression', x: 0, y: 4, width: 8, height: 5 },
|
||||
{ id: 'last-session', x: 8, y: 4, width: 4, height: 3 },
|
||||
{ id: 'max-records', x: 8, y: 7, width: 4, height: 3 },
|
||||
{ id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 },
|
||||
{ id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 },
|
||||
],
|
||||
}
|
||||
|
||||
const layoutHeaders = new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) })
|
||||
|
||||
function responseValue(input: RequestInfo | URL, dashboardValue: unknown, healthValue: unknown) {
|
||||
const url = String(input)
|
||||
if (url.includes('/dashboard-layout')) return { ok: true, status: 200, headers: layoutHeaders, json: () => Promise.resolve(dashboardLayout) }
|
||||
return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(url.includes('/dashboard') ? dashboardValue : healthValue) }
|
||||
}
|
||||
|
||||
function mockHealth(value: unknown = health) {
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(input).includes('/dashboard') ? dashboard : value),
|
||||
})))
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, dashboard, value))))
|
||||
}
|
||||
|
||||
describe('shell Trainlog', () => {
|
||||
|
|
@ -44,7 +63,7 @@ describe('shell Trainlog', () => {
|
|||
expect(footer.parentElement).toHaveClass('app-shell')
|
||||
expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG')
|
||||
expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7)
|
||||
await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7))
|
||||
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
|
||||
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
|
||||
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')
|
||||
|
|
@ -96,10 +115,7 @@ describe('shell Trainlog', () => {
|
|||
it('alimente le Footer uniquement avec les faits Dashboard reçus', async () => {
|
||||
const actual = structuredClone(dashboard)
|
||||
actual.data.footer = { user: 'fy59', last_session_date: '2026-09-15T10:00:00+02:00', last_zones: ['DOS', 'BRAS'] }
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(input).includes('/dashboard') ? actual : health),
|
||||
})))
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health))))
|
||||
render(<App />)
|
||||
await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59'))
|
||||
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('2026-09-15T10:00:00+02:00')
|
||||
|
|
@ -108,10 +124,7 @@ describe('shell Trainlog', () => {
|
|||
|
||||
it('rejette les snapshots Dashboard invalides sans inventer de Footer', async () => {
|
||||
expect(() => parseDashboard({ api_version: 1, data: {}, meta: {} })).toThrow()
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(input).includes('/dashboard') ? { status: 'ok' } : health),
|
||||
})))
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, { status: 'ok' }, health))))
|
||||
render(<App />)
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté'))
|
||||
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
|
||||
|
|
|
|||
|
|
@ -1,30 +1,44 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DashboardGrid } from './DashboardGrid'
|
||||
import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const submitted = init?.method === 'PUT' ? JSON.parse(String(init.body)) : null
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: new Headers({ ETag: submitted ? '"1"' : '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
|
||||
json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1,
|
||||
revision: submitted ? 1 : 0, columns: 12, source: submitted ? 'persisted' : 'default',
|
||||
tiles: submitted?.tiles ?? DEFAULT_DASHBOARD_LAYOUT }),
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
describe('grille interactive du Dashboard', () => {
|
||||
it('est verrouillée par défaut sans poignées de redimensionnement', () => {
|
||||
it('est verrouillée par défaut sans poignées de redimensionnement', async () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
|
||||
expect(container.querySelector('.dashboard-layout')).not.toHaveClass('is-editing')
|
||||
expect(container.querySelectorAll('.react-resizable-handle')).toHaveLength(0)
|
||||
expect(screen.queryByRole('article', { name: /tuile modifiable/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('active drag, resize et les commandes transactionnelles en mode édition', () => {
|
||||
it('active drag, resize et les commandes transactionnelles en mode édition', async () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
expect(container.querySelector('.dashboard-layout')).toHaveClass('is-editing')
|
||||
expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Réinitialiser' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Enregistrer pour cette session' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.react-resizable-handle').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('tabindex', '0')
|
||||
})
|
||||
|
||||
it('déplace et redimensionne une tuile au clavier avec annonce accessible', () => {
|
||||
it('déplace et redimensionne une tuile au clavier avec annonce accessible', async () => {
|
||||
render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
|
||||
tile.focus()
|
||||
expect(tile).toHaveFocus()
|
||||
|
|
@ -34,11 +48,11 @@ describe('grille interactive du Dashboard', () => {
|
|||
expect(screen.getByText(/Progression :.*largeur 9/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Annuler restaure le layout d’entrée et Enregistrer conserve l’état React', () => {
|
||||
it('Annuler restaure le layout d’entrée et Enregistrer persiste l’état React', async () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
const progression = () => container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
const initial = progression().style.width
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
|
||||
expect(progression().style.width).not.toBe(initial)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
|
||||
|
|
@ -47,18 +61,47 @@ describe('grille interactive du Dashboard', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
|
||||
const edited = progression().style.width
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer pour cette session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument())
|
||||
expect(progression().style.width).toBe(edited)
|
||||
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Réinitialiser restaure le défaut dans le brouillon', () => {
|
||||
it('Réinitialiser restaure le défaut dans le brouillon', async () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
|
||||
fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true })
|
||||
const modified = (container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Réinitialiser' }))
|
||||
expect((container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width).not.toBe(modified)
|
||||
})
|
||||
|
||||
it('conserve le brouillon et propose un reload lors d’un conflit 412', async () => {
|
||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') return Promise.resolve({ ok: false, status: 412,
|
||||
headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) })
|
||||
return Promise.resolve({ ok: true, status: 200,
|
||||
headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
|
||||
json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1,
|
||||
revision: 0, columns: 12, source: 'default', tiles: DEFAULT_DASHBOARD_LAYOUT }) })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<DashboardGrid />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('modifié dans un autre onglet')
|
||||
expect(screen.getByRole('button', { name: 'Recharger l’agencement' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ne sauvegarde jamais depuis la projection téléphone', async () => {
|
||||
window.innerWidth = 390
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
render(<DashboardGrid />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
|
||||
expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeDisabled()
|
||||
expect(screen.getByText(/écran large/)).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
window.innerWidth = 1440
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { Tile } from '../components/Tile'
|
||||
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
|
||||
import {
|
||||
cloneLayout,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
tileSize,
|
||||
TILE_IDS,
|
||||
toGridLayout,
|
||||
validateDashboardLayout,
|
||||
type TileId,
|
||||
type TileLayout,
|
||||
} from './dashboardLayout'
|
||||
|
|
@ -36,6 +38,10 @@ export function DashboardGrid() {
|
|||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [snapshot, setSnapshot] = useState<DashboardLayoutSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [preferenceError, setPreferenceError] = useState('')
|
||||
const beforeEditing = useRef<TileLayout[]>(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
// The viewport-derived seed prevents a wide first frame from overflowing on
|
||||
// phones before ResizeObserver has measured the actual grid container.
|
||||
|
|
@ -49,6 +55,23 @@ export function DashboardGrid() {
|
|||
[layout, view.columns],
|
||||
)
|
||||
|
||||
async function reloadLayout() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const loaded = await fetchDashboardLayout()
|
||||
setSnapshot(loaded)
|
||||
setLayout(cloneLayout(loaded.tiles))
|
||||
setPreferenceError(loaded.source === 'invalid_persisted'
|
||||
? 'L’agencement enregistré est invalide. Le défaut Trainlog est utilisé.' : '')
|
||||
} catch {
|
||||
setSnapshot(null)
|
||||
setLayout(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
setPreferenceError('Impossible de charger l’agencement enregistré. Le défaut Trainlog est utilisé.')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { void reloadLayout() }, [])
|
||||
|
||||
function enterEditMode() {
|
||||
beforeEditing.current = cloneLayout(layout)
|
||||
setEditing(true)
|
||||
|
|
@ -66,9 +89,21 @@ export function DashboardGrid() {
|
|||
setAnnouncement('Agencement par défaut restauré dans le brouillon.')
|
||||
}
|
||||
|
||||
function saveEditing() {
|
||||
setEditing(false)
|
||||
setAnnouncement('Agencement appliqué pour cette session. Il reviendra à sa valeur par défaut après rechargement.')
|
||||
async function saveEditing() {
|
||||
if (!desktop || snapshot === null || validateDashboardLayout(layout).length !== 0) {
|
||||
setPreferenceError('Impossible d’enregistrer cet agencement.')
|
||||
return
|
||||
}
|
||||
setSaving(true); setPreferenceError('')
|
||||
try {
|
||||
const saved = await saveDashboardLayout(snapshot, layout)
|
||||
setSnapshot(saved); setLayout(cloneLayout(saved.tiles)); setEditing(false)
|
||||
setAnnouncement('Agencement enregistré durablement.')
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'revision_conflict')
|
||||
setPreferenceError('L’agencement a été modifié dans un autre onglet. Rechargez l’agencement courant.')
|
||||
else setPreferenceError('L’enregistrement de l’agencement a échoué.')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
function acceptLibraryLayout(next: Layout) {
|
||||
|
|
@ -94,6 +129,8 @@ export function DashboardGrid() {
|
|||
|
||||
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
|
||||
|
||||
if (loading) return <p className="layout-loading" role="status">Chargement de l’agencement…</p>
|
||||
|
||||
return (
|
||||
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
|
||||
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
|
||||
|
|
@ -105,10 +142,11 @@ export function DashboardGrid() {
|
|||
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
|
||||
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
|
||||
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</button>
|
||||
<button className="layout-action layout-action-primary" type="button" onClick={saveEditing}>Enregistrer pour cette session</button>
|
||||
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
|
||||
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
|
||||
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
|
||||
<GridLayout
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { moveElement, verticalCompactor, type Layout as GridLayout } from 'react-grid-layout'
|
||||
import contract from '../../../contracts/dashboard-layout-v1.json'
|
||||
|
||||
export const DASHBOARD_COLUMNS = 12
|
||||
export const DASHBOARD_MAX_Y = 200
|
||||
|
|
@ -30,25 +31,14 @@ export interface TileConstraint {
|
|||
maxHeight: number
|
||||
}
|
||||
|
||||
export const TILE_CONSTRAINTS: Readonly<Record<TileId, TileConstraint>> = {
|
||||
'next-session': { minWidth: 4, maxWidth: 8, minHeight: 3, maxHeight: 7 },
|
||||
activity: { minWidth: 5, maxWidth: 12, minHeight: 3, maxHeight: 7 },
|
||||
progression: { minWidth: 6, maxWidth: 12, minHeight: 4, maxHeight: 9 },
|
||||
'last-session': { minWidth: 3, maxWidth: 7, minHeight: 3, maxHeight: 6 },
|
||||
'max-records': { minWidth: 3, maxWidth: 8, minHeight: 3, maxHeight: 7 },
|
||||
'muscle-distribution': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 9 },
|
||||
'cardio-recovery': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 8 },
|
||||
}
|
||||
export const TILE_CONSTRAINTS = Object.fromEntries(contract.tiles.map((tile) => [tile.id, {
|
||||
minWidth: tile.min_width, maxWidth: tile.max_width,
|
||||
minHeight: tile.min_height, maxHeight: tile.max_height,
|
||||
}])) as Readonly<Record<TileId, TileConstraint>>
|
||||
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = [
|
||||
{ id: 'next-session', x: 0, y: 0, width: 5, height: 4 },
|
||||
{ id: 'activity', x: 5, y: 0, width: 7, height: 4 },
|
||||
{ id: 'progression', x: 0, y: 4, width: 8, height: 5 },
|
||||
{ id: 'last-session', x: 8, y: 4, width: 4, height: 3 },
|
||||
{ id: 'max-records', x: 8, y: 7, width: 4, height: 3 },
|
||||
{ id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 },
|
||||
{ id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 },
|
||||
] as const
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = contract.tiles.map((tile) => ({
|
||||
id: tile.id as TileId, ...tile.default,
|
||||
}))
|
||||
|
||||
const tileIdSet = new Set<string>(TILE_IDS)
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
|
|||
.layout-action-primary:hover { color: var(--crust); background: var(--blue); border-color: transparent; }
|
||||
.layout-help, .layout-breakpoint-note { margin: 0 auto 0 0; color: var(--subtext-0); font: 550 .7rem/1.4 var(--font-mono); }
|
||||
.layout-breakpoint-note { flex-basis: 100%; color: var(--yellow); }
|
||||
.layout-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
|
||||
.layout-message { margin: 0 0 1rem; padding: .7rem .85rem; display: flex; align-items: center; gap: .75rem; color: var(--subtext-0); background: var(--surface-0); border-radius: .55rem; font-size: .82rem; }
|
||||
.layout-message.is-error { color: var(--peach); }
|
||||
.layout-action:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.dashboard-grid-frame { min-width: 0; }
|
||||
.dashboard-grid { min-width: 0; }
|
||||
.dashboard-grid > .react-grid-item { min-width: 0; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue