M19: harden performance and recovery
This commit is contained in:
@@ -2459,3 +2459,24 @@ evidence yet."
|
||||
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
|
||||
- Exact next action: split the frontend bundle, harden mobile layout and operational
|
||||
backup/deployment controls, then run clean full acceptance and redeploy.
|
||||
|
||||
## M19 — performance and recoverable operations (2026-08-10)
|
||||
|
||||
- Route-level React lazy loading reduced the initial production JavaScript chunk from
|
||||
about **572 kB to 212 kB**; every operational page now ships as a separate bounded
|
||||
chunk and the previous Vite large-chunk warning is gone. Both npm audits report zero
|
||||
vulnerabilities.
|
||||
- Reflowed the five dashboard readiness metrics into a readable 3+2 mobile grid instead
|
||||
of an overflowing horizontal strip at 390 px.
|
||||
- Compose now gates dependants on database-backed `/health/ready`. The optional bundled
|
||||
n8n fallback is pinned to `n8nio/n8n:2.33.7`, matching the central server n8n version;
|
||||
Unraid still starts no second n8n instance.
|
||||
- Added guarded Unraid PostgreSQL backup/restore scripts. Backups use custom format and
|
||||
are structurally verified; restore requires an explicit confirmation, makes a safety
|
||||
backup, stops API writes, recreates only the configured database and checks Alembic.
|
||||
A disposable dump/restore drill recovered all **50 vehicles** into a second database.
|
||||
- Evidence: merged Compose configurations and shell syntax pass; frontend production
|
||||
build and audits pass; full isolated PostgreSQL suite **225 passed, 1 warning**; ruff
|
||||
and mypy are clean.
|
||||
- Exact next action: commit and deploy this milestone, run complete Playwright and visual
|
||||
acceptance against Unraid, refresh final evidence, push and verify the live hash.
|
||||
|
||||
@@ -38,13 +38,14 @@ rollback).
|
||||
The PoC implements:
|
||||
|
||||
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
|
||||
- vehicle and booking views with working search, filters and pagination;
|
||||
- vehicle and booking workspaces with planning windows, location filters, service/next-
|
||||
booking context, operational ordering and pagination;
|
||||
- server-backed session lifecycle (refresh-safe, central 401 handling);
|
||||
- a role matrix enforced server-side and mirrored in the UI (see
|
||||
`docs/12-security-and-audit.md`);
|
||||
- vehicle return capture → authoritative server-evaluated review → commit → result;
|
||||
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
|
||||
manual scan action;
|
||||
- five deterministic data-quality checks with SLA deadlines, assignment, overdue/bulk
|
||||
queue controls, bounded resolution flows and a manual scan action;
|
||||
- human review and customer merge;
|
||||
- audit trail with human-readable before/after evidence and safe entity links;
|
||||
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
|
||||
@@ -55,6 +56,7 @@ The PoC implements:
|
||||
heartbeat evidence and crash-recoverable outbox delivery leases;
|
||||
- four read-only MCP tools through ITWorx MCP Hub;
|
||||
- deterministic demo reset and five-minute showcase.
|
||||
- audited operational user creation, role/name editing, password reset and activation.
|
||||
|
||||
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent.
|
||||
|
||||
@@ -118,7 +120,7 @@ required for the automation demo, and the full operational runbook.
|
||||
Endpoints:
|
||||
|
||||
- Web: `http://localhost:1228`
|
||||
- API health: `http://localhost:8128/health`
|
||||
- API readiness: `http://localhost:8128/health/ready` (liveness: `/health/live`)
|
||||
- n8n: `http://localhost:5678`
|
||||
|
||||
All defaults are configurable via `.env` (see `.env.example`).
|
||||
|
||||
@@ -354,10 +354,21 @@ def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
|
||||
|
||||
|
||||
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||
targets = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "vehicle_status_conflict", "status": "open"},
|
||||
).json()
|
||||
target = None
|
||||
preview = None
|
||||
for candidate in targets:
|
||||
candidate_preview = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation"
|
||||
).json()
|
||||
if candidate_preview["safe_to_apply"]:
|
||||
target = candidate
|
||||
preview = candidate_preview
|
||||
break
|
||||
assert target is not None and preview is not None
|
||||
assert preview["safe_to_apply"] is True
|
||||
assert preview["manual_review_required"] is False
|
||||
|
||||
|
||||
+3
-2
@@ -47,7 +47,7 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -65,7 +65,8 @@ services:
|
||||
networks: [mobilityops]
|
||||
|
||||
n8n:
|
||||
image: n8nio/n8n:latest
|
||||
# Explicit fallback only; production reuses the server's existing central n8n.
|
||||
image: n8nio/n8n:2.33.7
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
GENERIC_TIMEZONE: ${TZ:-Europe/Brussels}
|
||||
|
||||
@@ -52,3 +52,24 @@ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
|
||||
docker logs --tail=200 n8n
|
||||
```
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Create and structurally verify a timestamped PostgreSQL custom-format backup:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/backup-postgres.sh
|
||||
```
|
||||
|
||||
Copy backups off the server according to the host backup policy. A restore is deliberately
|
||||
guarded and creates an additional safety backup before replacing the database:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/restore-postgres.sh \
|
||||
backups/postgres/mobilityops-YYYYMMDDTHHMMSSZ.dump \
|
||||
RESTORE-MOBILITYOPS
|
||||
```
|
||||
|
||||
The restore stops the API, recreates only the configured MobilityOps database, restarts
|
||||
API/web and verifies the active Alembic revision. Test restores in a disposable environment
|
||||
before using a production backup for incident recovery.
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
project="${COMPOSE_PROJECT_NAME:-mobilityops}"
|
||||
destination="${1:-backups/postgres}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
backup_file="${destination}/mobilityops-${timestamp}.dump"
|
||||
temporary_file="${backup_file}.partial"
|
||||
compose_files="-f compose.yaml -f compose.unraid.yaml"
|
||||
|
||||
mkdir -p "$destination"
|
||||
trap 'rm -f "$temporary_file"' EXIT INT TERM
|
||||
|
||||
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
|
||||
'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
||||
> "$temporary_file"
|
||||
|
||||
docker compose -p "$project" $compose_files exec -T db pg_restore --list \
|
||||
< "$temporary_file" > /dev/null
|
||||
mv "$temporary_file" "$backup_file"
|
||||
trap - EXIT INT TERM
|
||||
printf '%s\n' "$backup_file"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
backup_file="${1:-}"
|
||||
confirmation="${2:-}"
|
||||
project="${COMPOSE_PROJECT_NAME:-mobilityops}"
|
||||
compose_files="-f compose.yaml -f compose.unraid.yaml"
|
||||
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
echo "Backup file not found: $backup_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$confirmation" != "RESTORE-MOBILITYOPS" ]; then
|
||||
echo "Refusing destructive restore; pass RESTORE-MOBILITYOPS as the second argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -p "$project" $compose_files exec -T db pg_restore --list \
|
||||
< "$backup_file" > /dev/null
|
||||
safety_backup="$(./deploy/unraid/backup-postgres.sh)"
|
||||
echo "Pre-restore safety backup: $safety_backup"
|
||||
|
||||
restart_api() {
|
||||
docker compose -p "$project" $compose_files up -d api web > /dev/null
|
||||
}
|
||||
trap restart_api EXIT INT TERM
|
||||
docker compose -p "$project" $compose_files stop api > /dev/null
|
||||
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
|
||||
'dropdb --force -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
|
||||
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
|
||||
'pg_restore --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
< "$backup_file"
|
||||
restart_api
|
||||
trap - EXIT INT TERM
|
||||
docker compose -p "$project" $compose_files exec -T api alembic current
|
||||
echo "Restore completed from $backup_file"
|
||||
+2
-2
@@ -24,7 +24,7 @@ docker compose exec api python -m app.cli seed --reset
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8128/health # {"status":"ok",...}
|
||||
curl http://localhost:8128/health/ready # database-backed readiness
|
||||
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
|
||||
make test # isolated test project/database, all tests pass
|
||||
docker compose run --rm api ruff check . # clean
|
||||
@@ -52,7 +52,7 @@ invalidates its next request.
|
||||
|
||||
## n8n automation (one-time per environment)
|
||||
|
||||
The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any
|
||||
The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any
|
||||
workflow — including webhook registration — works reliably; `N8N_BASIC_AUTH_ACTIVE` no
|
||||
longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
||||
|
||||
|
||||
+35
-30
@@ -1,24 +1,29 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Vehicles } from "./pages/Vehicles";
|
||||
import { VehicleDetail } from "./pages/VehicleDetail";
|
||||
import { Bookings } from "./pages/Bookings";
|
||||
import { BookingDetail } from "./pages/BookingDetail";
|
||||
import { BookingCreate } from "./pages/BookingCreate";
|
||||
import { DataQuality } from "./pages/DataQuality";
|
||||
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
||||
import { Automation } from "./pages/Automation";
|
||||
import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
import { Scenarios } from "./pages/Scenarios";
|
||||
import { Users } from "./pages/Users";
|
||||
const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
|
||||
const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles })));
|
||||
const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail })));
|
||||
const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings })));
|
||||
const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail })));
|
||||
const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate })));
|
||||
const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality })));
|
||||
const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail })));
|
||||
const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation })));
|
||||
const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge })));
|
||||
const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit })));
|
||||
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
|
||||
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
|
||||
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
||||
|
||||
function deferredPage(element: ReactNode) {
|
||||
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -26,7 +31,7 @@ export function App() {
|
||||
<AuthProvider>
|
||||
<DemoGuideProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/login" element={deferredPage(<Login />)} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
@@ -34,20 +39,20 @@ export function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/new" element={<BookingCreate />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
<Route path="/dashboard" element={deferredPage(<Dashboard />)} />
|
||||
<Route path="/vehicles" element={deferredPage(<Vehicles />)} />
|
||||
<Route path="/vehicles/:publicRef" element={deferredPage(<VehicleDetail />)} />
|
||||
<Route path="/bookings" element={deferredPage(<Bookings />)} />
|
||||
<Route path="/bookings/new" element={deferredPage(<BookingCreate />)} />
|
||||
<Route path="/bookings/:publicRef" element={deferredPage(<BookingDetail />)} />
|
||||
<Route path="/data-quality" element={deferredPage(<DataQuality />)} />
|
||||
<Route path="/data-quality/:publicRef" element={deferredPage(<DataQualityIssueDetail />)} />
|
||||
<Route path="/automation" element={deferredPage(<Automation />)} />
|
||||
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
|
||||
<Route path="/audit" element={deferredPage(<Audit />)} />
|
||||
<Route path="/users" element={deferredPage(<Users />)} />
|
||||
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
||||
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -419,6 +419,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
|
||||
|
||||
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
|
||||
.route-loading { min-height: 40vh; display: grid; place-items: center; }
|
||||
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
|
||||
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
|
||||
.api-error-notice strong { display: block; font-size: .78rem; }
|
||||
@@ -512,7 +513,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
|
||||
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }.metric-cell { min-width: 0; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; white-space: normal; overflow-wrap: anywhere; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
|
||||
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
|
||||
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
|
||||
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
|
||||
|
||||
Reference in New Issue
Block a user