feat(web): add user service integration
This commit is contained in:
parent
188aba2df7
commit
6443221327
5 changed files with 102 additions and 8 deletions
48
README.md
48
README.md
|
|
@ -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.
|
||||
|
|
|
|||
35
systemd/trainlog-web.service
Normal file
35
systemd/trainlog-web.service
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue