mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
Merge pull request #16 from Joulenap/release-0.4.4-ux-a11y
release: 0.4.4 — UX + accessibility remediation
This commit is contained in:
@@ -13,7 +13,7 @@ body:
|
||||
attributes:
|
||||
label: Joulenap version
|
||||
description: Shown in the UI footer.
|
||||
placeholder: "0.4.3"
|
||||
placeholder: "0.4.4"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -33,6 +33,9 @@ npm-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
frontend/dist/
|
||||
|
||||
# Playwright MCP run artifacts (screenshots/snapshots from local UI verification)
|
||||
.playwright-mcp/
|
||||
frontend/.vite/
|
||||
|
||||
# --- Local planning notes (NEVER commit) ---
|
||||
|
||||
+30
-1
@@ -7,6 +7,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Accessible confirmation dialog** — the dialog shown before every backup, GC, power-off, and
|
||||
reset is now fully keyboard- and screen-reader-accessible: it identifies itself as a dialog,
|
||||
keeps focus inside while open, closes on Escape, and returns focus to the button that opened it.
|
||||
- **Self-hosted fonts** — the interface fonts (IBM Plex) are now bundled with the app instead of
|
||||
being fetched from Google Fonts, so the UI makes no third-party request on load and renders
|
||||
correctly fully offline or air-gapped.
|
||||
- **Sign-in screens** — the login and first-account screens are now proper forms with correct
|
||||
autocomplete hints, so password managers fill and save credentials reliably; the button shows
|
||||
progress while signing in.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Header status label** — the header now reads "GC running" or "Verify running" during those
|
||||
jobs, instead of always saying "Backup running".
|
||||
- **Selective backup with no guests** — choosing Selective mode with no guests selected is now
|
||||
blocked with an explanation, instead of silently saving a schedule that wakes the PBS and backs
|
||||
up nothing.
|
||||
- **Setup wizard error visibility** — an error on a lower wizard step now scrolls into view (and is
|
||||
announced to screen readers) instead of appearing off-screen, and "Detect MAC" now tells you when
|
||||
auto-detection found nothing instead of doing nothing.
|
||||
- **Empty guest list** — the guest panel now shows a "No guests found" message when a node has no
|
||||
guests, instead of a blank area.
|
||||
|
||||
## [0.4.3]
|
||||
|
||||
### Added
|
||||
@@ -217,7 +244,9 @@ Backup Server, all from a web UI.
|
||||
- Config-driven via `config.yaml` (pydantic-validated); secrets stay in `config.yaml` and are
|
||||
redacted from API responses.
|
||||
|
||||
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.4.2...HEAD
|
||||
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.4.4...HEAD
|
||||
[0.4.4]: https://github.com/Joulenap/joulenap/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/Joulenap/joulenap/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/Joulenap/joulenap/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/Joulenap/joulenap/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/Joulenap/joulenap/compare/v0.3.1...v0.4.0
|
||||
|
||||
@@ -50,7 +50,7 @@ Joulenap **owns the schedule** itself (internal scheduler), so nothing on the Pr
|
||||
|
||||
## Status
|
||||
|
||||
**v0.4.3.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
|
||||
**v0.4.4.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
|
||||
notifications + setup wizard, packaged as a Docker image — with transport hardening (PBS TLS
|
||||
pinning + SSH host-key verification) and auth hardening (login rate-limit, session hardening).
|
||||
Includes a read-only [dashboard integration](docs/INTEGRATIONS.md) (Homepage/Homarr/Dashy/Glance),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Joulenap — web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."""
|
||||
|
||||
__version__ = "0.4.3"
|
||||
__version__ = "0.4.4"
|
||||
|
||||
@@ -33,6 +33,20 @@ def latest_cycle_run(session: Session) -> Run | None:
|
||||
).first()
|
||||
|
||||
|
||||
def running_run(session: Session) -> Run | None:
|
||||
"""The currently in-progress run of any kind (backup cycle / GC / verify), or None.
|
||||
|
||||
Used to label the header pill with what is actually running instead of assuming
|
||||
every job is a backup. Normally at most one row is RUNNING (the single-run lock),
|
||||
and startup sweeps any orphan, so an ordered LIMIT 1 is exact in practice."""
|
||||
return session.scalars(
|
||||
select(Run)
|
||||
.where(Run.status == RunStatus.RUNNING)
|
||||
.order_by(Run.started_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
|
||||
def latest_finished_cycle_run(session: Session) -> Run | None:
|
||||
"""Most recent backup cycle that has finished (any terminal status), ignoring an
|
||||
in-progress RUNNING cycle — so a mid-backup dashboard shows the previous result."""
|
||||
|
||||
@@ -41,6 +41,7 @@ class StatusResponse(BaseModel):
|
||||
schedule: str
|
||||
next_run: datetime | None
|
||||
job_running: bool
|
||||
running_kind: str | None = None # "cycle" | "gc" | "verify" while a run is in flight
|
||||
pbs_online: bool
|
||||
last_run: RunSummary | None
|
||||
datastore: DatastoreInfo | None = None
|
||||
@@ -56,6 +57,7 @@ def get_status(
|
||||
) -> StatusResponse:
|
||||
config = store.config
|
||||
last = _probe.latest_cycle_run(session)
|
||||
running = _probe.running_run(session)
|
||||
pbs_online, live_ds, nl = _probe.probe_pbs(config, job_service.deps.build_pbs)
|
||||
ds = _probe.resolve_datastore(config.pbs.datastore, live_ds)
|
||||
|
||||
@@ -69,6 +71,7 @@ def get_status(
|
||||
schedule=config.backup.schedule,
|
||||
next_run=scheduler.next_run_time,
|
||||
job_running=job_service.is_running,
|
||||
running_kind=running.kind if running else None,
|
||||
pbs_online=pbs_online,
|
||||
last_run=RunSummary.of(last) if last else None,
|
||||
datastore=datastore,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "joulenap"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
description = "Self-hosted web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -76,11 +76,24 @@ def test_status_shape(app_ctx):
|
||||
body = client.get("/api/status").json()
|
||||
assert body["scheduler_enabled"] is True # example config enables backups
|
||||
assert body["job_running"] is False
|
||||
assert body["running_kind"] is None # nothing in flight
|
||||
assert body["pbs_online"] is False
|
||||
assert body["last_run"] is None
|
||||
assert "next_run" in body and "schedule" in body
|
||||
|
||||
|
||||
def test_status_running_kind_reflects_in_progress_run(app_ctx):
|
||||
"""A RUNNING run surfaces its kind so the header pill can label GC/verify
|
||||
correctly instead of always saying 'Backup running' (UX-6)."""
|
||||
client, _app = app_ctx
|
||||
with session_scope() as s:
|
||||
s.add(Run(kind=RunKind.GC, trigger=RunTrigger.MANUAL, status=RunStatus.RUNNING,
|
||||
started_at=datetime.now(UTC)))
|
||||
|
||||
body = client.get("/api/status").json()
|
||||
assert body["running_kind"] == "gc"
|
||||
|
||||
|
||||
# --- config ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "joulenap-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface StatusResponse {
|
||||
schedule: string
|
||||
next_run: string | null
|
||||
job_running: boolean
|
||||
running_kind?: 'cycle' | 'gc' | 'verify' | null
|
||||
pbs_online: boolean
|
||||
last_run: RunSummary | null
|
||||
datastore: DatastoreInfo | null
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { c } from '../theme'
|
||||
import { Toggle } from './Toggle'
|
||||
@@ -15,6 +16,57 @@ export interface ConfirmState {
|
||||
|
||||
export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null; onCancel: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const titleId = useId()
|
||||
const msgId = useId()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
// Read onCancel through a ref so the effect (keyed only on open/closed) never captures a stale
|
||||
// closure and never re-runs when Dashboard rebuilds `state` on a keep-PBS-on toggle flip.
|
||||
const onCancelRef = useRef(onCancel)
|
||||
onCancelRef.current = onCancel
|
||||
|
||||
const open = state !== null
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
// Focus the non-destructive Cancel button so a stray Enter/Space can't fire a danger action.
|
||||
cancelRef.current?.focus()
|
||||
|
||||
const focusables = () =>
|
||||
dialogRef.current
|
||||
? Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !el.hasAttribute('disabled'))
|
||||
: []
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancelRef.current()
|
||||
} else if (e.key === 'Tab') {
|
||||
const f = focusables()
|
||||
if (!f.length) return
|
||||
const first = f[0]
|
||||
const last = f[f.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
// Return focus to whatever opened the dialog (e.g. the "Run backup now" button).
|
||||
previouslyFocused?.focus?.()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!state) return null
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +84,11 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={msgId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 430,
|
||||
@@ -60,9 +117,9 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
|
||||
>
|
||||
{state.icon}
|
||||
</div>
|
||||
<span style={{ fontSize: 17, fontWeight: 700 }}>{state.title}</span>
|
||||
<span id={titleId} style={{ fontSize: 17, fontWeight: 700 }}>{state.title}</span>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 14, lineHeight: 1.55, color: '#a8b0ba' }}>
|
||||
<p id={msgId} style={{ margin: '0 0 20px', fontSize: 14, lineHeight: 1.55, color: '#a8b0ba' }}>
|
||||
{state.message}
|
||||
</p>
|
||||
{state.toggle && (
|
||||
@@ -83,6 +140,7 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
|
||||
@@ -21,6 +21,7 @@ export function Dropdown({ value, options, onChange, width = '100%', mono: useMo
|
||||
return (
|
||||
<div style={{ position: 'relative', width }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -63,6 +64,7 @@ export function Dropdown({ value, options, onChange, width = '100%', mono: useMo
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(o.value)
|
||||
setOpen(false)
|
||||
|
||||
@@ -109,6 +109,7 @@ const STATUS: StatusResponse = {
|
||||
schedule: '30 2 * * 1,3,5',
|
||||
next_run: '2026-07-10T02:30:00Z',
|
||||
job_running: false,
|
||||
running_kind: null,
|
||||
pbs_online: true,
|
||||
last_run: {
|
||||
id: 42,
|
||||
@@ -218,7 +219,7 @@ const WIZARD_SSH_TRUST: { trusted: boolean } = { trusted: true }
|
||||
const WIZARD_RESET: { ok: boolean } = { ok: true }
|
||||
|
||||
const ROUTES: Record<string, unknown> = {
|
||||
'GET /health': { status: 'ok', version: '0.4.3-stub' },
|
||||
'GET /health': { status: 'ok', version: '0.4.4-stub' },
|
||||
'GET /auth/status': AUTH_STATUS,
|
||||
'GET /auth/me': ME,
|
||||
'GET /status': STATUS,
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"off": "Off",
|
||||
"running": "Backup running",
|
||||
"gcRunning": "GC running",
|
||||
"verifyRunning": "Verify running",
|
||||
"idle": "idle",
|
||||
"timerDisabled": "timer disabled"
|
||||
},
|
||||
@@ -94,9 +95,11 @@
|
||||
"guests": "Guests",
|
||||
"refresh": "Refresh",
|
||||
"guestsError": "Couldn't refresh the guest list — showing the last known guests.",
|
||||
"noGuests": "No guests found on this node.",
|
||||
"general": "General backup",
|
||||
"selective": "Selective backup",
|
||||
"selectedCount": "{{n}} sel.",
|
||||
"noGuestsSelected": "Select at least one guest, or switch to General backup — Selective mode with no guests backs up nothing.",
|
||||
"excludeMode": "Exclude mode (config.yaml)",
|
||||
"excludeNote": "Exclude mode is set in config.yaml — every guest is backed up except the ones marked ✕. Edit config.yaml to change the list.",
|
||||
"excludedCount": "{{n}} excl.",
|
||||
@@ -344,7 +347,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"pbsUnreachable": "PBS is not reachable at that host and port. Check it is powered on and the address is correct, then try again.",
|
||||
"pbsTokenMissing": "A PBS API token is required. Enter the token ID and secret before continuing."
|
||||
"pbsTokenMissing": "A PBS API token is required. Enter the token ID and secret before continuing.",
|
||||
"macNotDetected": "Couldn't auto-detect the MAC address — enter it manually."
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"off": "Spento",
|
||||
"running": "Backup in corso",
|
||||
"gcRunning": "GC in corso",
|
||||
"verifyRunning": "Verifica in corso",
|
||||
"idle": "inattivo",
|
||||
"timerDisabled": "timer disattivato"
|
||||
},
|
||||
@@ -94,9 +95,11 @@
|
||||
"guests": "Guest",
|
||||
"refresh": "Aggiorna",
|
||||
"guestsError": "Impossibile aggiornare l'elenco dei guest — mostro gli ultimi guest noti.",
|
||||
"noGuests": "Nessun guest trovato su questo nodo.",
|
||||
"general": "Backup generale",
|
||||
"selective": "Backup selettivo",
|
||||
"selectedCount": "{{n}} sel.",
|
||||
"noGuestsSelected": "Seleziona almeno un guest, oppure passa a Backup generale — la modalità Selettiva senza guest non salva nulla.",
|
||||
"excludeMode": "Modalità esclusione (config.yaml)",
|
||||
"excludeNote": "La modalità esclusione è impostata in config.yaml — vengono salvati tutti i guest tranne quelli contrassegnati con ✕. Modifica config.yaml per cambiare l'elenco.",
|
||||
"excludedCount": "{{n}} escl.",
|
||||
@@ -344,7 +347,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"pbsUnreachable": "Il PBS non è raggiungibile a quell'host e porta. Verifica che sia acceso e che l'indirizzo sia corretto, poi riprova.",
|
||||
"pbsTokenMissing": "È richiesto un token API del PBS. Inserisci l'ID del token e il segreto prima di continuare."
|
||||
"pbsTokenMissing": "È richiesto un token API del PBS. Inserisci l'ID del token e il segreto prima di continuare.",
|
||||
"macNotDetected": "Impossibile rilevare automaticamente l'indirizzo MAC — inseriscilo manualmente."
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
|
||||
+52
-1
@@ -1,4 +1,55 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
|
||||
/* Self-hosted IBM Plex (latin subset, covers EN + IT) — no third-party request, works offline
|
||||
on an air-gapped LAN (FE-A5). Files under assets/fonts/ are SIL OFL 1.1 (see OFL.txt).
|
||||
Vite fingerprints + bundles them into dist/assets via the relative url()s below. */
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-sans-latin-600-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-sans-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-mono-latin-500-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./assets/fonts/ibm-plex-mono-latin-600-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useConfig } from '../config/ConfigContext'
|
||||
import { useRegisterDirty } from '../shell/UnsavedGuard'
|
||||
import { useTaskLog } from '../hooks/useTaskLog'
|
||||
import { buildCron, isAdvancedSchedule, parseCron } from '../utils/cron'
|
||||
import { guestsSelectionError } from '../utils/guests'
|
||||
import { ActivityLog } from './dashboard/ActivityLog'
|
||||
import { GuestsPanel } from './dashboard/GuestsPanel'
|
||||
import { ManualPanel } from './dashboard/ManualPanel'
|
||||
@@ -170,6 +171,14 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
|
||||
}
|
||||
|
||||
const apply = async () => {
|
||||
// Block Selective mode with no guests: it would save a schedule that wakes the PBS
|
||||
// and aborts every run without backing anything up (UX-8). Cleared on the next
|
||||
// guest toggle / mode change (patch + toggleGuest reset err).
|
||||
const guestErr = guestsSelectionError(draft.guestsMode, draft.selected.length)
|
||||
if (guestErr) {
|
||||
setErr(t(guestErr))
|
||||
return
|
||||
}
|
||||
const next: Config = structuredClone(config)
|
||||
next.backup.enabled = enabled
|
||||
next.backup.schedule = isAdvancedSchedule({
|
||||
|
||||
+102
-96
@@ -45,8 +45,9 @@ export function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -99,116 +100,121 @@ export function Login() {
|
||||
{register ? t('auth.registerSubtitle') : t('auth.signInSubtitle')}
|
||||
</span>
|
||||
|
||||
{expired && !register && !error && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(232,131,15,.1)',
|
||||
border: '1px solid rgba(232,131,15,.4)',
|
||||
borderRadius: 7,
|
||||
padding: '9px 12px',
|
||||
marginBottom: 14,
|
||||
fontSize: 12,
|
||||
color: c.textMid,
|
||||
}}
|
||||
>
|
||||
⚠ {t('auth.sessionExpired')}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={onSubmit} style={{ display: 'contents' }}>
|
||||
{expired && !register && !error && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(232,131,15,.1)',
|
||||
border: '1px solid rgba(232,131,15,.4)',
|
||||
borderRadius: 7,
|
||||
padding: '9px 12px',
|
||||
marginBottom: 14,
|
||||
fontSize: 12,
|
||||
color: c.textMid,
|
||||
}}
|
||||
>
|
||||
⚠ {t('auth.sessionExpired')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.username')}</span>
|
||||
<input
|
||||
value={user}
|
||||
onChange={(e) => {
|
||||
setUser(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="off"
|
||||
placeholder="admin"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => {
|
||||
setPass(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
placeholder="••••••••"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{register && (
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.confirmPassword')}</span>
|
||||
<span style={labelStyle}>{t('auth.username')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={pass2}
|
||||
value={user}
|
||||
onChange={(e) => {
|
||||
setPass2(e.target.value)
|
||||
setUser(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="username"
|
||||
placeholder="admin"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => {
|
||||
setPass(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
autoComplete={register ? 'new-password' : 'current-password'}
|
||||
placeholder="••••••••"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{register && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.timezone')}</span>
|
||||
<Dropdown value={tz} options={tzOptions} onChange={setTz} mono />
|
||||
<span
|
||||
{register && (
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.confirmPassword')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={pass2}
|
||||
onChange={(e) => {
|
||||
setPass2(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{register && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<span style={labelStyle}>{t('auth.timezone')}</span>
|
||||
<Dropdown value={tz} options={tzOptions} onChange={setTz} mono />
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
color: c.textDim,
|
||||
lineHeight: 1.5,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{t('auth.timezoneHint')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
color: c.textDim,
|
||||
lineHeight: 1.5,
|
||||
marginTop: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(229,103,91,.12)',
|
||||
border: '1px solid #5e3330',
|
||||
borderRadius: 7,
|
||||
padding: '9px 12px',
|
||||
marginBottom: 14,
|
||||
fontSize: 12,
|
||||
color: c.red,
|
||||
}}
|
||||
>
|
||||
{t('auth.timezoneHint')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(229,103,91,.12)',
|
||||
border: '1px solid #5e3330',
|
||||
borderRadius: 7,
|
||||
padding: '9px 12px',
|
||||
marginBottom: 14,
|
||||
fontSize: 12,
|
||||
color: c.red,
|
||||
}}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
style={{ ...primaryBtn, width: '100%', padding: 12, marginTop: 6, fontSize: 14 }}
|
||||
>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
style={{ ...primaryBtn, width: '100%', padding: 12, marginTop: 6, fontSize: 14 }}
|
||||
>
|
||||
{register ? t('auth.registerButton') : t('auth.signInButton')}
|
||||
</button>
|
||||
{busy
|
||||
? t('common.loading')
|
||||
: register
|
||||
? t('auth.registerButton')
|
||||
: t('auth.signInButton')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -114,6 +114,14 @@ export function GuestsPanel({ guests, mode, onModeChange, selected, onToggleGues
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 250, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{guests.length === 0 && !refreshing && !error && (
|
||||
// Genuinely-zero guests (a successful empty response) — distinct from the error state
|
||||
// above, which keeps the last known list (FE-M8). Gated on !refreshing to avoid a flash
|
||||
// during the initial load (UX-7).
|
||||
<div style={{ padding: '18px', textAlign: 'center', fontSize: 12, color: c.textMuted, lineHeight: 1.5 }}>
|
||||
{t('dashboard.noGuests')}
|
||||
</div>
|
||||
)}
|
||||
{guests.map((g) => {
|
||||
const isCt = g.type === 'lxc'
|
||||
const on = selected.has(g.vmid)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, ApiError } from '../../api/client'
|
||||
import { api } from '../../api/client'
|
||||
import type { Config, NetInterface } from '../../api/types'
|
||||
import { ConfirmModal, type ConfirmState } from '../../components/ConfirmModal'
|
||||
import { Dropdown } from '../../components/Dropdown'
|
||||
@@ -234,6 +234,12 @@ export function SetupWizard() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ifaces, setIfaces] = useState<NetInterface[]>([])
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
// Scroll the top-anchored error banner into view whenever a step fails: a check on a lower
|
||||
// card (SSH/Install) would otherwise update a banner off-screen -> "clicked, nothing happened"
|
||||
// (UX-1). The nonce forces the scroll even when the same message re-fires (setError(null) then
|
||||
// the identical string batches to no net change, so keying on `error` alone wouldn't re-run).
|
||||
const errorRef = useRef<HTMLDivElement>(null)
|
||||
const [errorNonce, setErrorNonce] = useState(0)
|
||||
|
||||
// If the saved config is already set up, show the wizard as completed on (re)mount rather
|
||||
// than restarting from card 1 — but never clobber an in-progress session's own state.
|
||||
@@ -263,6 +269,11 @@ export function SetupWizard() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) errorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [errorNonce])
|
||||
|
||||
const patch = (p: Partial<Wiz>) => setW((s) => ({ ...s, ...p }))
|
||||
const rapido = w.mode === 'rapido'
|
||||
const doneCount = w.status.filter((s) => s === 'done').length
|
||||
@@ -279,7 +290,10 @@ export function SetupWizard() {
|
||||
} catch (e) {
|
||||
// Surface the backend's message (e.g. PVE auth failure, unreachable host) so a
|
||||
// failed step isn't silently a no-op; the connection-status dots stay red too.
|
||||
setError(e instanceof ApiError ? e.message : String(e))
|
||||
// Use `.message` for any Error (incl. our own localized throws like macNotDetected /
|
||||
// pbsUnreachable) so the raw "Error: " prefix from String(e) never leaks to the banner.
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setErrorNonce((n) => n + 1)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
@@ -397,7 +411,11 @@ export function SetupWizard() {
|
||||
const detectMac = () =>
|
||||
run('mac', async () => {
|
||||
const r = await api.wizardDetectMac(w.pbsHost)
|
||||
if (r.mac) patch({ wolMac: r.mac })
|
||||
// A 200 with no MAC is a *failed* detection, not an error the run() wrapper would catch —
|
||||
// surface a hint (routed through the error banner, now scroll-visible via UX-1) instead of
|
||||
// a silent no-op, so the user knows to type it in (UX-3).
|
||||
if (!r.mac) throw new Error(t('settings.setup.errors.macNotDetected'))
|
||||
patch({ wolMac: r.mac })
|
||||
})
|
||||
|
||||
// A MAC is required to wake the PBS; without it the WoL step must not complete (FE-H4).
|
||||
@@ -762,6 +780,8 @@ export function SetupWizard() {
|
||||
|
||||
{error && (
|
||||
<div
|
||||
ref={errorRef}
|
||||
role="alert"
|
||||
style={{
|
||||
background: 'rgba(229,103,91,.1)',
|
||||
border: '1px solid rgba(229,103,91,.32)',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { StatusResponse } from '../api/types'
|
||||
import { useClock } from '../hooks/useClock'
|
||||
import { c, mono } from '../theme'
|
||||
import { fmtClock } from '../utils/format'
|
||||
import { runningLabelKey } from '../utils/status'
|
||||
|
||||
interface HeaderProps {
|
||||
host: string
|
||||
@@ -13,7 +14,8 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
function pill(status: StatusResponse | null, t: (k: string) => string) {
|
||||
if (status?.job_running) return { label: t('status.running'), color: c.blue, busy: true, sub: '' }
|
||||
if (status?.job_running)
|
||||
return { label: t(runningLabelKey(status.running_kind)), color: c.blue, busy: true, sub: '' }
|
||||
if (status?.pbs_online) {
|
||||
return {
|
||||
label: t('status.on'),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { guestsSelectionError } from './guests.ts'
|
||||
|
||||
test('guestsSelectionError blocks Selective mode with no guests', () => {
|
||||
assert.equal(guestsSelectionError('selective', 0), 'dashboard.noGuestsSelected')
|
||||
})
|
||||
|
||||
test('guestsSelectionError allows Selective mode with at least one guest', () => {
|
||||
assert.equal(guestsSelectionError('selective', 1), null)
|
||||
assert.equal(guestsSelectionError('selective', 3), null)
|
||||
})
|
||||
|
||||
test('guestsSelectionError never blocks General or Exclude mode (empty is valid)', () => {
|
||||
assert.equal(guestsSelectionError('general', 0), null)
|
||||
assert.equal(guestsSelectionError('exclude', 0), null)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* i18n key for a blocking validation error on the guest selection, or null if valid.
|
||||
*
|
||||
* Only Selective (`include`) mode with an empty list is invalid: it saves a schedule
|
||||
* that wakes the PBS and aborts every run ("No guests selected") without ever backing
|
||||
* anything up. `general` (all) and `exclude` (all-except, read-only) are always valid —
|
||||
* an empty exclude list means "back up everything".
|
||||
*/
|
||||
export function guestsSelectionError(
|
||||
mode: 'general' | 'selective' | 'exclude',
|
||||
selectedCount: number,
|
||||
): string | null {
|
||||
if (mode === 'selective' && selectedCount === 0) return 'dashboard.noGuestsSelected'
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { runningLabelKey } from './status.ts'
|
||||
|
||||
test('runningLabelKey maps each run kind to its own label', () => {
|
||||
assert.equal(runningLabelKey('cycle'), 'status.running')
|
||||
assert.equal(runningLabelKey('gc'), 'status.gcRunning')
|
||||
assert.equal(runningLabelKey('verify'), 'status.verifyRunning')
|
||||
})
|
||||
|
||||
test('runningLabelKey falls back to the backup label for null/unknown', () => {
|
||||
assert.equal(runningLabelKey(null), 'status.running')
|
||||
assert.equal(runningLabelKey(undefined), 'status.running')
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { StatusResponse } from '../api/types'
|
||||
|
||||
/**
|
||||
* i18n key for the header pill label while a run is in flight.
|
||||
*
|
||||
* The backend only sends `running_kind` once a RUNNING row exists, so an unknown
|
||||
* or absent kind (including the brief gap between the lock being taken and the run
|
||||
* row being created) falls back to the generic "Backup running" label.
|
||||
*/
|
||||
export function runningLabelKey(kind: StatusResponse['running_kind']): string {
|
||||
switch (kind) {
|
||||
case 'gc':
|
||||
return 'status.gcRunning'
|
||||
case 'verify':
|
||||
return 'status.verifyRunning'
|
||||
default:
|
||||
return 'status.running'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user