feat(web): add user service integration

This commit is contained in:
fy59 2026-09-16 12:50:32 +02:00
parent 188aba2df7
commit 6443221327
5 changed files with 102 additions and 8 deletions

View file

@ -190,6 +190,54 @@ reports that frontend support is absent. Release builds must use
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
npm, `node_modules`, and `web/dist` are not runtime dependencies.
### Optional systemd user service for local Web development
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
available on `127.0.0.1:8080` during an interactive user session. This is a
development convenience, not a requirement of the final Trainlog distribution.
It intentionally runs the canonical user entry point
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
executable before the service is started.
The unit prevents privilege acquisition, gives the process a private temporary
directory, makes system paths read-only, and applies kernel/control-group and
setuid restrictions that are compatible with the local HTTP server. It does
not protect or hide the home directory because SQLite and Trainlog
configuration must remain writable under `~/.local/share/trainlog` and
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
limited to three attempts per minute, preventing a persistent port-8080
conflict from producing an uncontrolled loop.
Install the development unit as a symlink so repository updates are picked up
after `daemon-reload`:
```bash
mkdir -p ~/.config/systemd/user
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
systemctl --user enable --now trainlog-web.service
```
Useful lifecycle and diagnostic commands are:
```bash
systemctl --user status trainlog-web
systemctl --user restart trainlog-web
systemctl --user stop trainlog-web
systemctl --user start trainlog-web
journalctl --user -u trainlog-web
```
Uninstalling the integration disables the unit before removing only its user
configuration symlink:
```bash
systemctl --user disable --now trainlog-web.service
rm ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
```
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.

View file

@ -0,0 +1,35 @@
[Unit]
Description=Trainlog Web local user service
Documentation=https://github.com/labfytools/trainlog
After=default.target
# WHY: A persistent bind failure (notably an occupied 127.0.0.1:8080) must not
# become an aggressive restart loop in the user's session.
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Type=simple
# CONTRACT: The user-managed executable is the canonical manual and service
# entry point; the repository/build location must not leak into this unit.
ExecStart=%h/.local/bin/trainlog -w
# INVARIANT: Trainlog stays in the foreground, owns its ordered SIGTERM
# shutdown, and always keeps the frozen loopback address and port contract.
KillSignal=SIGTERM
TimeoutStopSec=20
Restart=on-failure
RestartSec=10
# These protections do not isolate the home directory: Trainlog must retain
# access to its SQLite data and configuration below ~/.local/share and ~/.config.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
[Install]
WantedBy=default.target

View file

@ -23,7 +23,11 @@ describe('shell Trainlog', () => {
expect(screen.getByRole('banner')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Aller au contenu' })).toHaveAttribute('href', '#main-content')
expect(screen.getByRole('navigation', { name: 'Navigation principale' })).toBeInTheDocument()
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('status-footer')
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)
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')

View file

@ -16,7 +16,6 @@ export function DashboardPage() {
<section className="page" aria-labelledby="page-title">
<div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
<p className="page-intro">Vos repères d’entraînement, sans extrapolation ni donnée inventée.</p>
</div>
<div className="tile-grid dashboard-preview">
{dashboardTiles.map(([title, eyebrow, message], index) => (

View file

@ -1,8 +1,12 @@
.app-shell {
width: 100%;
max-width: 100vw;
min-height: 100vh;
padding-bottom: 3.75rem;
overflow-x: hidden;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
background:
radial-gradient(circle at 92% 12%, rgb(180 190 254 / 7%), transparent 25rem),
var(--crust);
@ -12,6 +16,7 @@
.skip-link:focus { transform: translateY(0); }
.app-header {
min-width: 0;
position: sticky;
top: 0;
z-index: 10;
@ -25,8 +30,8 @@
backdrop-filter: blur(16px);
}
.brand { display: flex; align-items: center; gap: .7rem; text-decoration: none; font-weight: 800; letter-spacing: .13em; }
.brand-mark { width: 2rem; height: 2rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border-radius: .55rem; font: 700 .7rem var(--font-mono); letter-spacing: -.04em; }
.brand { display: flex; align-items: center; gap: .7rem; color: var(--text); text-decoration: none; font-size: 1.02rem; font-weight: 850; letter-spacing: .15em; text-shadow: 0 0 1.4rem rgb(180 190 254 / 24%); }
.brand-mark { width: 2.1rem; height: 2.1rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border: 1px solid rgb(255 255 255 / 28%); border-radius: .58rem; box-shadow: 0 0 .9rem rgb(180 190 254 / 18%); font: 750 .7rem var(--font-mono); letter-spacing: -.04em; }
.main-nav { min-width: 0; display: flex; align-items: center; gap: .25rem; overflow-x: auto; scrollbar-width: none; }
.main-nav::-webkit-scrollbar { display: none; }
.nav-link { position: relative; padding: .7rem .85rem; color: var(--subtext-0); text-decoration: none; font-size: .91rem; font-weight: 570; white-space: nowrap; transition: color 140ms ease, background 140ms ease; border-radius: .55rem; }
@ -34,7 +39,7 @@
.nav-link.is-active { color: var(--lavender); }
.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; }
.app-main { width: 100%; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
.page-heading { margin-bottom: clamp(2rem, 4vw, 3.5rem); display: flex; justify-content: space-between; align-items: end; gap: 2rem; }
.eyebrow { margin: 0 0 .55rem; color: var(--lavender); font: 700 .68rem/1.2 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; }
@ -59,7 +64,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.principle-panel p:last-child { line-height: 1.65; }
.principle-index { color: var(--surface-1); font: 700 4rem/1 var(--font-mono); letter-spacing: -.1em; }
.status-footer { position: sticky; bottom: 0; z-index: 10; min-height: 3.75rem; padding: .65rem var(--shell-gutter); display: grid; grid-template-columns: 1fr 1.25fr 1.25fr auto; align-items: center; gap: 1.5rem; background: rgb(24 24 37 / 96%); border-top: 1px solid rgb(69 71 90 / 62%); backdrop-filter: blur(16px); }
.status-footer { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; min-height: 3.75rem; padding: .65rem var(--shell-gutter); display: grid; grid-template-columns: 1fr 1.25fr 1.25fr auto; align-items: center; gap: 1.5rem; background: rgb(24 24 37 / 96%); border-top: 1px solid rgb(69 71 90 / 62%); backdrop-filter: blur(16px); }
.status-block { min-width: 0; display: flex; flex-direction: column; gap: .16rem; }
.status-label { color: var(--surface-1); font: 700 .58rem var(--font-mono); letter-spacing: .12em; }
.status-value { overflow: hidden; color: var(--subtext-0); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
@ -68,6 +73,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.backend-dot { width: .45rem; height: .45rem; background: currentColor; border-radius: 50%; }
@media (max-width: 70rem) {
.app-shell { padding-bottom: 5.4rem; }
.tile { grid-column: span 6; }
.tile-feature { grid-column: span 8; }
.principle-panel { grid-column: span 4; }
@ -76,6 +82,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
}
@media (max-width: 48rem) {
.app-shell { padding-bottom: 8rem; }
.app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; }
.brand { align-self: start; }
.main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); }
@ -84,12 +91,13 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }
.tile { min-height: 14rem; }
.principle-panel { min-height: 17rem; }
.status-footer { position: static; grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
.status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
.status-zone { grid-column: 1 / -1; }
.backend-state { grid-column: 1 / -1; justify-self: start; }
}
@media (max-width: 28rem) {
.app-shell { padding-bottom: 12.5rem; }
.status-footer { grid-template-columns: 1fr; }
.status-zone, .backend-state { grid-column: auto; }
h1 { font-size: 2.3rem; }