diff --git a/apps/web/src/App.css b/apps/web/src/App.css deleted file mode 100644 index cfceaad..0000000 --- a/apps/web/src/App.css +++ /dev/null @@ -1,1117 +0,0 @@ -/* Grundlayout der Anwendung. */ - -.app-shell { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.app-shell__header { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--space-3); - padding: var(--space-4) var(--space-6); - border-bottom: var(--border-width) solid var(--color-border); - background-color: var(--color-surface-raised); -} - -.app-shell__brand { - font-size: var(--text-lg); - font-weight: 600; - letter-spacing: -0.01em; -} - -.app-shell__phase { - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.app-shell__main { - /* Die Breite ist begrenzt, damit Tabellen auf grossen Bildschirmen lesbar bleiben. */ - width: 100%; - max-width: 60rem; - margin: 0 auto; - padding: var(--space-8) var(--space-6); -} - -/* Auf schmalen Bildschirmen faellt der Innenabstand kleiner aus. */ -@media (max-width: 40rem) { - .app-shell__main { - padding: var(--space-6) var(--space-4); - } -} - -/* Kontobereich in der Kopfzeile. */ - -.app-shell__account { - /* Der Bereich rueckt an das rechte Ende der Kopfzeile. */ - margin-left: auto; - display: flex; - align-items: center; - gap: var(--space-3); -} - -.app-shell__username { - font-size: var(--text-sm); - font-weight: 500; -} - -.app-shell__warning { - font-size: var(--text-sm); - color: var(--color-status-warning); - border: var(--border-width) solid var(--color-status-warning); - border-radius: var(--radius); - padding: 0 var(--space-2); -} - -.app-shell__logout { - font: inherit; - font-size: var(--text-sm); - padding: var(--space-1) var(--space-3); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - background-color: var(--color-surface-page); - color: var(--color-text-primary); - cursor: pointer; -} - -.app-shell__logout:hover { - border-color: var(--color-text-secondary); -} - -/* --------------------------------------------------------------------------- - Grundlayout mit Seitenleiste - --------------------------------------------------------------------------- */ - -.app-shell__body { - display: flex; - flex: 1; - align-items: flex-start; -} - -/* Die Hauptflaeche ueberschreibt die schmale Breite aus dem Grundlayout: - Tabellen mit sieben Spalten brauchen den Platz. */ -.app-shell__body .app-shell__main { - max-width: none; - flex: 1; - min-width: 0; -} - -/* --------------------------------------------------------------------------- - Navigation - --------------------------------------------------------------------------- */ - -.navigation { - flex: 0 0 15rem; - padding: var(--space-6) var(--space-4); - border-right: var(--border-width) solid var(--color-border); - position: sticky; - top: 0; - align-self: stretch; -} - -.navigation__section + .navigation__section { - margin-top: var(--space-6); -} - -.navigation__section-title { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-secondary); - margin: 0 0 var(--space-2) var(--space-2); -} - -.navigation__list { - list-style: none; - margin: 0; - padding: 0; -} - -.navigation__entry { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - width: 100%; - padding: var(--space-2) var(--space-3); - border: none; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-primary); - font: inherit; - text-align: left; - cursor: pointer; -} - -.navigation__entry:hover { - background-color: var(--color-surface-raised); -} - -.navigation__entry--current { - background-color: var(--color-surface-raised); - font-weight: 600; -} - -/* Ein nicht verfuegbarer Bereich bleibt anklickbar: Der Klick fuehrt zur - Erklaerung, was fehlt. Ein gesperrter Knopf sagt nur „nein". */ -.navigation__entry--unavailable .navigation__entry-label { - color: var(--color-text-secondary); -} - -.navigation__entry-badge { - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -/* --------------------------------------------------------------------------- - Seiten - --------------------------------------------------------------------------- */ - -.page + .page { - margin-top: var(--space-8); -} - -.page__header { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--space-3); - margin-bottom: var(--space-5); -} - -.page__title { - font-size: var(--text-xl); - margin: 0; -} - -.page__meta, -.page__badge { - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.page__hint { - font-size: var(--text-sm); - color: var(--color-text-secondary); - max-width: 44rem; -} - -.page__state { - color: var(--color-text-secondary); - padding: var(--space-6) 0; -} - -.page__state--empty { - padding: var(--space-8) 0; - text-align: center; -} - -/* --------------------------------------------------------------------------- - Hinweise - --------------------------------------------------------------------------- */ - -.notice { - border: var(--border-width) solid var(--color-border); - border-left-width: 3px; - border-radius: var(--radius-sm); - padding: var(--space-4); - margin-bottom: var(--space-5); - max-width: 48rem; -} - -.notice--information { border-left-color: var(--color-status-info); } -.notice--warning { border-left-color: var(--color-status-warning); } -.notice--critical { border-left-color: var(--color-status-critical); } - -.notice__text { - margin: 0; -} - -.notice__meta { - margin: var(--space-2) 0 0; - font-size: var(--text-sm); - color: var(--color-text-secondary); - font-family: var(--font-mono, monospace); -} - -/* --------------------------------------------------------------------------- - Kennzahlen der Uebersicht - --------------------------------------------------------------------------- */ - -.widget-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); - gap: var(--space-4); -} - -.widget { - border: var(--border-width) solid var(--color-border); - border-top-width: 3px; - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - padding: var(--space-4); -} - -/* Die Statusfarbe steht am oberen Rand — sie ist der schnellste Weg, eine - Kachel mit Befund zu finden. */ -.widget--healthy { border-top-color: var(--color-status-healthy); } -.widget--warning { border-top-color: var(--color-status-warning); } -.widget--high { border-top-color: var(--color-status-high); } -.widget--critical { border-top-color: var(--color-status-critical); } -.widget--information { border-top-color: var(--color-status-info); } -.widget--unavailable { border-top-color: var(--color-border); } - -.widget__title { - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-secondary); - margin: 0 0 var(--space-2); -} - -.widget__value { - font-size: 1.75rem; - font-weight: 600; - margin: 0 0 var(--space-2); - line-height: 1.1; -} - -.widget__unit { - font-size: var(--text-sm); - font-weight: 400; - color: var(--color-text-secondary); -} - -.widget__value-missing, -.widget__unavailable { - font-size: var(--text-sm); - font-weight: 500; - color: var(--color-text-secondary); -} - -.widget__detail { - font-size: var(--text-sm); - color: var(--color-text-secondary); - margin: 0; -} - -.widget__breakdown { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - margin: var(--space-3) 0 0; - padding-top: var(--space-3); - border-top: var(--border-width) solid var(--color-border); - font-size: var(--text-xs); -} - -.widget__breakdown-item dt { - color: var(--color-text-secondary); -} - -.widget__breakdown-item dd { - margin: 0; - font-weight: 600; -} - -/* --------------------------------------------------------------------------- - Tabellen - --------------------------------------------------------------------------- */ - -/* Breite Tabellen scrollen in ihrem eigenen Rahmen; die Seite selbst nie. */ -.table-wrapper { - overflow-x: auto; - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); -} - -.data-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} - -.data-table th, -.data-table td { - padding: var(--space-3) var(--space-4); - text-align: left; - vertical-align: top; - border-bottom: var(--border-width) solid var(--color-border); -} - -.data-table th { - font-weight: 600; - color: var(--color-text-secondary); - white-space: nowrap; -} - -.data-table tbody tr:last-child td { - border-bottom: none; -} - -.data-table__number { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.data-table__code { - font-family: var(--font-mono, monospace); - font-size: var(--text-xs); -} - -.data-table__secondary { - display: block; - font-size: var(--text-xs); - color: var(--color-text-secondary); - word-break: break-all; -} - -.data-table__secondary--warning { - color: var(--color-status-warning); -} - -.data-table__unknown { - color: var(--color-text-secondary); - font-style: italic; -} - -/* Ein geloeschter Eintrag bleibt lesbar, aber erkennbar vergangen. */ -.data-table__row--deleted td { - opacity: 0.6; -} - -/* --------------------------------------------------------------------------- - Statusanzeigen - --------------------------------------------------------------------------- */ - -.badge { - display: inline-block; - padding: 2px var(--space-2); - border-radius: var(--radius-sm); - font-size: var(--text-xs); - font-weight: 600; - white-space: nowrap; - border: var(--border-width) solid currentColor; -} - -.badge--healthy { color: var(--color-status-healthy); } -.badge--warning { color: var(--color-status-warning); } -.badge--high { color: var(--color-status-high); } -.badge--critical { color: var(--color-status-critical); } -.badge--info { color: var(--color-status-info); } -.badge--neutral { color: var(--color-status-neutral); } - -/* --------------------------------------------------------------------------- - Filterleiste - --------------------------------------------------------------------------- */ - -.filter-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-4); - margin-bottom: var(--space-4); -} - -.filter-bar__field { - display: flex; - flex-direction: column; - gap: 2px; -} - -.filter-bar__label { - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -.filter-bar__select { - padding: var(--space-2) var(--space-3); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - color: var(--color-text-primary); - font: inherit; -} - -.filter-bar__checkbox { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); -} - -/* --------------------------------------------------------------------------- - Karten und Berechtigungen - --------------------------------------------------------------------------- */ - -.card-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: var(--space-4); -} - -.card { - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - padding: var(--space-4); -} - -.card__title { - font-size: var(--text-base); - margin: 0 0 var(--space-2); -} - -.card__text { - font-size: var(--text-sm); - color: var(--color-text-secondary); - margin: 0 0 var(--space-3); -} - -.permission-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.permission-list__item { - font-family: var(--font-mono, monospace); - font-size: var(--text-xs); - color: var(--color-text-secondary); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - padding: 1px var(--space-2); -} - -.button { - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-sm); - border: var(--border-width) solid var(--color-border); - background-color: var(--color-surface-raised); - color: var(--color-text-primary); - font: inherit; - cursor: pointer; -} - -.button--secondary:hover { - border-color: var(--color-text-secondary); -} - -/* --------------------------------------------------------------------------- - Schmale Bildschirme - --------------------------------------------------------------------------- */ - -/* Unter 55rem wandert die Navigation ueber den Inhalt: Eine Seitenleiste von - 15rem laesst auf einem Tablet quer zu wenig fuer eine Tabelle uebrig. */ -@media (max-width: 55rem) { - .app-shell__body { - flex-direction: column; - } - - .navigation { - flex: none; - width: 100%; - position: static; - border-right: none; - border-bottom: var(--border-width) solid var(--color-border); - display: flex; - flex-wrap: wrap; - gap: var(--space-4); - padding: var(--space-3) var(--space-4); - } - - .navigation__section + .navigation__section { - margin-top: 0; - } - - .navigation__list { - display: flex; - flex-wrap: wrap; - gap: var(--space-1, 4px); - } -} - -/* --------------------------------------------------------------------------- - Diagramme - --------------------------------------------------------------------------- */ - -.chart-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr)); - gap: var(--space-4); -} - -.chart-card { - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - padding: var(--space-4); -} - -.chart-card--unavailable { - opacity: 0.75; -} - -.chart-card__title { - font-size: var(--text-base); - margin: 0 0 var(--space-1, 4px); -} - -.chart-card__description, -.chart-card__note, -.chart-card__badge { - font-size: var(--text-xs); - color: var(--color-text-secondary); - margin: 0 0 var(--space-3); -} - -.chart-card__badge { - font-weight: 600; -} - -/* Der Hinweis zur Aussagekraft steht unter der Kurve und ist abgesetzt: Er - gehoert zur Lesart des Diagramms, nicht zu seiner Beschreibung. */ -.chart-card__note { - margin: var(--space-2) 0 0; - padding-top: var(--space-2); - border-top: var(--border-width) solid var(--color-border); -} - -.chart { - margin: 0; -} - -.chart__canvas { - width: 100%; - height: 13rem; - display: block; -} - -.chart__grid line { - stroke: var(--color-border); - stroke-width: 1; - vector-effect: non-scaling-stroke; -} - -.chart__grid text { - fill: var(--color-text-secondary); - font-size: 11px; -} - -/* Die Linie behaelt ihre Staerke, obwohl das SVG in der Breite gestreckt wird. - Ohne diese Angabe waeren waagerechte und senkrechte Linien verschieden dick. */ -.chart__line { - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; - vector-effect: non-scaling-stroke; -} - -.chart__empty { - font-size: var(--text-sm); - color: var(--color-text-secondary); - padding: var(--space-6) 0; - margin: 0; -} - -.chart__legend { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - margin-top: var(--space-2); - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -.chart__legend-item { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.chart__legend-swatch { - width: 10px; - height: 3px; - border-radius: 2px; -} - -/* --------------------------------------------------------------------------- - Meldungen - --------------------------------------------------------------------------- */ - -.summary-bar { - display: flex; - flex-wrap: wrap; - gap: var(--space-5); - padding: var(--space-4); - margin-bottom: var(--space-4); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); -} - -.summary-figure { - display: flex; - flex-direction: column; -} - -.summary-figure__value { - font-size: 1.5rem; - font-weight: 600; - line-height: 1.1; -} - -.summary-figure__label { - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -/* Farbe nur, wenn die Zahl ein Zustand ist: Null kritische Meldungen sind - keine gute Nachricht in Rot. */ -.summary-figure--critical .summary-figure__value { color: var(--color-status-critical); } -.summary-figure--high .summary-figure__value { color: var(--color-status-high); } - -.alert-list { - list-style: none; - margin: 0 0 var(--space-8); - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.alert-card { - border: var(--border-width) solid var(--color-border); - border-left-width: 3px; - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - padding: var(--space-4); -} - -.alert-card--critical { border-left-color: var(--color-status-critical); } -.alert-card--high { border-left-color: var(--color-status-high); } -.alert-card--warning { border-left-color: var(--color-status-warning); } -.alert-card--information { border-left-color: var(--color-status-info); } - -/* Eine erledigte Meldung bleibt lesbar, aber erkennbar vergangen. */ -.alert-card--resolved { - opacity: 0.6; - border-left-color: var(--color-border); -} - -.alert-card__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-2); -} - -.alert-card__title { - font-size: var(--text-base); - margin: 0; - flex: 1; - min-width: 12rem; -} - -.alert-card__count { - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -.alert-card__message { - margin: 0 0 var(--space-2); - max-width: 60rem; -} - -.alert-card__meta { - font-size: var(--text-xs); - color: var(--color-text-secondary); - margin: 0; -} - -.alert-card__actions { - display: flex; - gap: var(--space-2); - margin-top: var(--space-3); -} - -/* --------------------------------------------------------------------------- - Regelwerk - --------------------------------------------------------------------------- */ - -.rule-list__title { - font-size: var(--text-base); - margin: 0 0 var(--space-3); -} - -.rule-list__items { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: var(--space-3); -} - -.rule-list__item { - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius-sm); - padding: var(--space-3); -} - -.rule-list__item--unavailable { - opacity: 0.7; -} - -.rule-list__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - margin-bottom: var(--space-2); -} - -.rule-list__name { - font-weight: 600; - font-size: var(--text-sm); -} - -.rule-list__description { - font-size: var(--text-xs); - color: var(--color-text-secondary); - margin: 0; -} - -/* --------------------------------------------------------------------------- - Security Center - --------------------------------------------------------------------------- */ - -.score-card { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-6); - padding: var(--space-5); - margin-bottom: var(--space-5); - border: var(--border-width) solid var(--color-border); - border-left-width: 4px; - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); -} - -.score-card--healthy { border-left-color: var(--color-status-healthy); } -.score-card--warning { border-left-color: var(--color-status-warning); } -.score-card--high { border-left-color: var(--color-status-high); } -.score-card--critical { border-left-color: var(--color-status-critical); } - -.score-card__figure { - display: flex; - align-items: baseline; - gap: 2px; -} - -.score-card__value { - font-size: 3rem; - font-weight: 600; - line-height: 1; -} - -.score-card__unit { - font-size: var(--text-lg); - color: var(--color-text-secondary); -} - -.score-card__text { - flex: 1; - min-width: 16rem; -} - -.score-card__grade { - font-weight: 600; - margin: 0 0 var(--space-1, 4px); - text-transform: capitalize; -} - -.score-card__summary { - margin: 0; - color: var(--color-text-secondary); -} - -.score-card__unchecked { - margin: var(--space-2) 0 0; - font-size: var(--text-xs); - color: var(--color-text-secondary); -} - -.finding-list { - list-style: none; - margin: 0 0 var(--space-8); - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.finding { - border: var(--border-width) solid var(--color-border); - border-left-width: 3px; - border-radius: var(--radius-sm); - background-color: var(--color-surface-raised); - padding: var(--space-4); -} - -.finding--critical { border-left-color: var(--color-status-critical); } -.finding--high { border-left-color: var(--color-status-high); } -.finding--warning { border-left-color: var(--color-status-warning); } -.finding--information { border-left-color: var(--color-status-info); } - -.finding__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-2); -} - -.finding__title { - font-size: var(--text-base); - margin: 0; -} - -.finding__explanation { - margin: 0 0 var(--space-3); - max-width: 60rem; -} - -.finding__details { - margin: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.finding__detail { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - font-size: var(--text-sm); -} - -.finding__detail dt { - color: var(--color-text-secondary); - min-width: 8rem; -} - -.finding__detail dd { - margin: 0; - flex: 1; - min-width: 16rem; -} - -/* Die Empfehlung ist der Grund, warum der Befund angezeigt wird — sie steht - hervorgehoben und nicht als Fussnote. */ -.finding__detail--recommendation dd { - font-weight: 500; -} - -.area-overview__title { - font-size: var(--text-base); - margin: 0 0 var(--space-3); -} - -/* --- Berichte (Phase 17) --- */ - -.reports__catalog { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); - gap: var(--space-3); - margin-bottom: var(--space-5); -} - -.reports__entry { - display: flex; - flex-direction: column; - gap: var(--space-2); - padding: var(--space-4); - text-align: left; - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - cursor: pointer; - font: inherit; - color: inherit; -} - -.reports__entry:hover { - border-color: var(--color-text-secondary); -} - -/* Die Auswahl wird ueber die Randstaerke gezeigt, nicht ueber eine Farbe: - Farbe ist den Zustaenden vorbehalten (UI-Konvention). */ -.reports__entry--selected { - border-width: 2px; - border-color: var(--color-text-primary); - padding: calc(var(--space-4) - 1px); -} - -.reports__entry-title { - font-weight: 600; -} - -.reports__entry-description, -.reports__entry-period { - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.reports__panel { - padding: var(--space-4); - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.reports__panel-header { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-4); -} - -.reports__panel-title { - margin: 0; - font-size: 1.25rem; -} - -.reports__actions { - display: flex; - gap: var(--space-2); -} - -.reports__error { - padding: var(--space-3); - margin-bottom: var(--space-4); - border-left: 3px solid var(--color-status-critical); - background: var(--color-surface-page); -} - -.reports__request-id { - display: block; - margin-top: var(--space-1); - font-size: 0.8rem; - color: var(--color-text-secondary); -} - -.reports__meta { - margin: 0 0 var(--space-4); - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.reports__section { - margin-bottom: var(--space-5); -} - -.reports__section-title { - margin: 0 0 var(--space-2); - font-size: 1.05rem; -} - -.reports__section-description { - margin: 0 0 var(--space-3); - font-size: 0.9rem; - color: var(--color-text-secondary); -} - -.reports__metrics { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: var(--space-2) var(--space-5); - margin: 0 0 var(--space-4); -} - -.reports__metric { - display: flex; - gap: var(--space-3); - justify-content: space-between; - align-items: baseline; - padding-bottom: var(--space-1); - border-bottom: 1px solid var(--color-border); -} - -.reports__metric-label { - font-size: 0.9rem; -} - -.reports__metric-value { - margin: 0; - font-weight: 600; - text-align: right; -} - -/* Eine ungemessene Kennzahl wird zurueckgenommen dargestellt und nennt ihren - Grund. Sie als Null zu zeigen waere der eine Fehler, den ein Bericht nicht - machen darf. */ -.reports__metric-value--unknown { - font-weight: 400; - font-style: italic; - color: var(--color-text-secondary); -} - -.reports__metric-reason { - display: block; - margin-top: var(--space-1); - font-size: 0.78rem; - font-style: normal; - line-height: 1.4; -} - -.reports__table { - margin-bottom: var(--space-4); -} - -.reports__table-title { - margin: 0 0 var(--space-2); - font-size: 0.95rem; -} - -/* Breite Tabellen scrollen in sich selbst; die Seite bleibt ruhig. */ -.reports__table-scroll { - overflow-x: auto; -} - -.reports__empty, -.reports__truncation { - margin: var(--space-2) 0 0; - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.reports__notes { - padding-top: var(--space-3); - border-top: 1px solid var(--color-border); -} - -.reports__note-list { - margin: 0; - padding-left: var(--space-4); - font-size: 0.85rem; - line-height: 1.5; - color: var(--color-text-secondary); -} - -.reports__note-list li { - margin-bottom: var(--space-2); -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1cf1c07..d6ae84c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -17,6 +17,7 @@ import { getAccessToken, logout } from './features/auth/authApi'; import { LoginPage } from './features/auth/LoginPage'; import { AlertsPage } from './features/alerts/AlertsPage'; import { DashboardPage } from './features/dashboard/DashboardPage'; +import { SystemHealthPanel } from './features/health/SystemHealthPanel'; import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage'; import { RolesPage } from './features/administration/RolesPage'; import { SettingsPage } from './features/administration/SettingsPage'; @@ -40,12 +41,6 @@ import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages'; import { useCurrentPage } from './navigation/useCurrentPage'; import type { CurrentUser } from './types/auth'; import './styles/theme.css'; -// Die noch nicht umgebauten Seiten nutzen weiterhin das alte Stylesheet. Es -// hier stehen zu lassen ist unschoen und die einzige Alternative zu einer -// Oberflaeche, die auf halbem Weg unformatiert erscheint. Es faellt weg, -// sobald die letzte Seite umgebaut ist. -import './styles/tokens.css'; -import './App.css'; // Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls. setAccessTokenProvider(getAccessToken); @@ -142,7 +137,14 @@ function CurrentPageContent({ switch (pageDefinition.id) { case 'dashboard': - return ; + return ( + <> + +
+ +
+ + ); case 'jobs': return openObjectIdentifier ? ( diff --git a/apps/web/src/api/useMutation.ts b/apps/web/src/api/useMutation.ts index d6fb7f3..e4dd69a 100644 --- a/apps/web/src/api/useMutation.ts +++ b/apps/web/src/api/useMutation.ts @@ -137,45 +137,68 @@ export function useMutation( }; } +/** Bekannte Lagen samt Abhilfe. */ +const errorCodeExplanations: Readonly> = { + // Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des + // Anwenders — der Auftrag laeuft bereits. + JOB_ALREADY_RUNNING: + 'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.', + RESTORE_ALREADY_RUNNING: + 'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.', + VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.', + TARGET_NOT_EMPTY: + 'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.', + RESTORE_TARGET_FORBIDDEN: + 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.', + LAST_ADMINISTRATOR: + 'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.', + BUILTIN_ROLE_IMMUTABLE: + 'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.', + REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.', + REPOSITORY_LOCKED: + 'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".', + REPOSITORY_FULL: + 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.', + IMMUTABLE_UNTIL: + 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.', + LEGAL_HOLD: + 'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.', + SERVICE_UNAVAILABLE: + 'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.', + FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.', +}; + /** - * Uebersetzt einen Serverfehler in einen Satz, den ein Betreiber versteht. + * Liefert einen Satz, den ein Betreiber versteht. * - * Die Fehlercodes der API sind praezise und fuer Menschen unbrauchbar. Wo ein - * Code eine bekannte Lage beschreibt, steht hier die Erklaerung **samt Abhilfe** - * — der Rest faellt auf die Servermeldung zurueck, die ohnehin auf Deutsch - * verfasst ist. + * **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht, + * welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als + * jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server + * keine Meldung mitschickt. + * + * Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein + * `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine + * Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der + * Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber + * haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses + * einen Berichts. */ export function describeApiError(apiError: ApiError): string { - const knownExplanations: Readonly> = { - // Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des - // Anwenders — der Auftrag laeuft bereits. - JOB_ALREADY_RUNNING: - 'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.', - RESTORE_ALREADY_RUNNING: - 'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.', - VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.', - TARGET_NOT_EMPTY: - 'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.', - RESTORE_TARGET_FORBIDDEN: - 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.', - LAST_ADMINISTRATOR: - 'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.', - BUILTIN_ROLE_IMMUTABLE: - 'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.', - REPOSITORY_IN_USE: - 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.', - REPOSITORY_LOCKED: - 'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".', - REPOSITORY_FULL: - 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.', - IMMUTABLE_UNTIL: - 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.', - LEGAL_HOLD: - 'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.', - SERVICE_UNAVAILABLE: - 'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.', - FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.', - }; + const serverMessage = apiError.message.trim(); - return knownExplanations[apiError.code] ?? apiError.message; + if (serverMessage !== '') { + return serverMessage; + } + + return errorCodeExplanations[apiError.code] ?? 'Die Anfrage ist fehlgeschlagen.'; +} + +/** + * Liefert die allgemeine Erklaerung zu einem Fehlercode, sofern es eine gibt. + * + * Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage + * eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen. + */ +export function explainApiErrorCode(errorCode: string): string | undefined { + return errorCodeExplanations[errorCode]; } diff --git a/apps/web/src/components/PageState.tsx b/apps/web/src/components/PageState.tsx deleted file mode 100644 index dc8ca87..0000000 --- a/apps/web/src/components/PageState.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Gemeinsame Zustandsanzeigen der Seiten. - * - * Laden, Fehler und Leere sehen ueberall gleich aus — und vor allem: Leere wird - * ausdruecklich als Leere benannt. Eine Tabelle ohne Zeilen und ohne Hinweis - * laesst offen, ob es nichts gibt oder ob etwas schiefging. - */ - -import type { ApiError } from '../api/client'; - -/** Eigenschaften der Ladeanzeige. */ -interface LoadingStateProperties { - /** Was gerade geladen wird. */ - readonly what: string; -} - -/** Zeigt an, dass Daten geladen werden. */ -export function LoadingState({ what }: LoadingStateProperties): React.JSX.Element { - return ( -

- {what} werden geladen… -

- ); -} - -/** Eigenschaften der Fehleranzeige. */ -interface ErrorStateProperties { - /** Der aufgetretene Fehler. */ - readonly error: ApiError; - /** Laedt erneut. */ - readonly onRetry: () => void; -} - -/** Zeigt einen Fehler samt Request-ID. */ -export function ErrorState({ error, onRetry }: ErrorStateProperties): React.JSX.Element { - return ( -
-

{error.message}

- - {/* Die Request-ID gehoert sichtbar in die Oberflaeche: Mit ihr laesst sich - ein Vorfall im Serverlog eindeutig wiederfinden (PROMPT.md §50). */} - {error.requestId !== '' && ( -

- Fehlercode {error.code} · Vorgang {error.requestId} -

- )} - - -
- ); -} - -/** Eigenschaften der Leeranzeige. */ -interface EmptyStateProperties { - /** Der erklaerende Text. */ - readonly message: string; -} - -/** Zeigt an, dass es nichts anzuzeigen gibt. */ -export function EmptyState({ message }: EmptyStateProperties): React.JSX.Element { - return

{message}

; -} diff --git a/apps/web/src/components/StatusIndicator.css b/apps/web/src/components/StatusIndicator.css deleted file mode 100644 index 7a511d8..0000000 --- a/apps/web/src/components/StatusIndicator.css +++ /dev/null @@ -1,20 +0,0 @@ -/* Darstellung der Statusanzeige. */ - -.status-indicator { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.status-indicator__dot { - /* Feste Groesse, damit der Punkt in Tabellen nicht springt. */ - width: 0.625rem; - height: 0.625rem; - border-radius: 50%; - flex-shrink: 0; -} - -.status-indicator__label { - font-size: var(--text-sm); - color: var(--color-text-primary); -} diff --git a/apps/web/src/components/StatusIndicator.tsx b/apps/web/src/components/StatusIndicator.tsx deleted file mode 100644 index 8d68346..0000000 --- a/apps/web/src/components/StatusIndicator.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Statusanzeige des Syncova-Designsystems (PROMPT.md §105/§106). - * - * Der Zustand wird nicht allein ueber Farbe vermittelt, sondern immer zusaetzlich - * ueber Text. Farbe allein waere fuer farbfehlsichtige Anwender unzugaenglich - * (PROMPT.md §107). - */ - -import type { HealthStatus } from '../types/api'; -import './StatusIndicator.css'; - -/** Zuordnung von Zustand zu Anzeigetext und Farbvariable. */ -const STATUS_PRESENTATION: Record = { - healthy: { label: 'Fehlerfrei', colorVariable: 'var(--color-status-healthy)' }, - degraded: { label: 'Eingeschraenkt', colorVariable: 'var(--color-status-warning)' }, - warning: { label: 'Warnung', colorVariable: 'var(--color-status-warning)' }, - critical: { label: 'Kritisch', colorVariable: 'var(--color-status-critical)' }, - offline: { label: 'Nicht erreichbar', colorVariable: 'var(--color-status-high)' }, -}; - -/** Eigenschaften der Statusanzeige. */ -export interface StatusIndicatorProps { - /** Anzuzeigender Zustand. */ - status: HealthStatus; - /** Optionale abweichende Beschriftung. */ - label?: string; -} - -/** Zeigt einen Zustand als farbigen Punkt mit Beschriftung. */ -export function StatusIndicator({ status, label }: StatusIndicatorProps): React.JSX.Element { - const presentation = STATUS_PRESENTATION[status]; - - return ( - - {/* Der Punkt ist rein dekorativ; die Information steht im Text daneben. */} - - ); -} diff --git a/apps/web/src/components/layout/PageHeader.tsx b/apps/web/src/components/layout/PageHeader.tsx index 706f872..c58bfe7 100644 --- a/apps/web/src/components/layout/PageHeader.tsx +++ b/apps/web/src/components/layout/PageHeader.tsx @@ -16,7 +16,7 @@ export function PageHeader({ readonly title: ReactNode; readonly description?: ReactNode | undefined; readonly actions?: ReactNode | undefined; - readonly onBack?: () => void | undefined; + readonly onBack?: (() => void) | undefined; readonly backLabel?: string | undefined; readonly className?: string | undefined; }) { diff --git a/apps/web/src/components/ui/DataTable.tsx b/apps/web/src/components/ui/DataTable.tsx index 4e96c99..dc199c0 100644 --- a/apps/web/src/components/ui/DataTable.tsx +++ b/apps/web/src/components/ui/DataTable.tsx @@ -39,7 +39,7 @@ export function DataTable({ readonly columns: ReadonlyArray>; readonly rows: readonly RowType[]; readonly getRowKey: (row: RowType) => string; - readonly onRowClick?: (row: RowType) => void | undefined; + readonly onRowClick?: ((row: RowType) => void) | undefined; readonly isLoading?: boolean | undefined; readonly emptyTitle?: ReactNode | undefined; readonly emptyDescription?: ReactNode | undefined; diff --git a/apps/web/src/components/ui/Feedback.tsx b/apps/web/src/components/ui/Feedback.tsx index 4277d05..43dc166 100644 --- a/apps/web/src/components/ui/Feedback.tsx +++ b/apps/web/src/components/ui/Feedback.tsx @@ -32,7 +32,7 @@ export function ErrorState({ readonly title?: string | undefined; readonly message?: string | undefined; readonly requestId?: string | undefined; - readonly onRetry?: () => void | undefined; + readonly onRetry?: (() => void) | undefined; readonly className?: string | undefined; }) { return ( @@ -105,11 +105,21 @@ export function Callout({ title, children, className, + role = 'note', }: { readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined; readonly title?: ReactNode | undefined; readonly children?: ReactNode | undefined; readonly className?: string | undefined; + /** + * ARIA-Rolle. + * + * Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon + * beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung** + * erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an, + * statt ihn im Seiteninhalt untergehen zu lassen. + */ + readonly role?: 'note' | 'alert' | 'status' | undefined; }) { const toneClasses = { info: 'border-info/30 bg-info-bg text-info', @@ -121,7 +131,7 @@ export function Callout({ const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle; return ( -
+
diff --git a/apps/web/src/components/ui/StatusBadge.tsx b/apps/web/src/components/ui/StatusBadge.tsx index 36a90c4..4416731 100644 --- a/apps/web/src/components/ui/StatusBadge.tsx +++ b/apps/web/src/components/ui/StatusBadge.tsx @@ -85,8 +85,8 @@ const statusDefinitions: Readonly> = { none: { tone: 'critical', label: 'Kein Schutz' }, // --- Gesundheit --- - healthy: { tone: 'healthy', label: 'Gesund' }, - degraded: { tone: 'warning', label: 'Beeintraechtigt' }, + healthy: { tone: 'healthy', label: 'Fehlerfrei' }, + degraded: { tone: 'warning', label: 'Eingeschraenkt' }, critical: { tone: 'critical', label: 'Kritisch' }, unknown: { tone: 'neutral', label: 'Unbekannt' }, @@ -105,7 +105,7 @@ const statusDefinitions: Readonly> = { // --- Agenten --- online: { tone: 'healthy', label: 'Verbunden' }, - offline: { tone: 'critical', label: 'Nicht verbunden' }, + offline: { tone: 'critical', label: 'Nicht erreichbar' }, stale: { tone: 'warning', label: 'Veraltet' }, revoked: { tone: 'neutral', label: 'Gesperrt' }, diff --git a/apps/web/src/features/alerts/AlertsPage.tsx b/apps/web/src/features/alerts/AlertsPage.tsx index f52fdb0..b947712 100644 --- a/apps/web/src/features/alerts/AlertsPage.tsx +++ b/apps/web/src/features/alerts/AlertsPage.tsx @@ -1,31 +1,46 @@ /** * Meldungen. * - * Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der - * Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es - * wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf - * geachtet wird. + * Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung: + * **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss, + * was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine + * Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft". * - * Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist - * erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird. + * „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in + * der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus + * der Uebersicht, obwohl er weiterbesteht. */ import { useCallback, useState } from 'react'; -import { useApiResource } from '../../api/useApiResource'; -import { EmptyState, ErrorState, LoadingState } from '../../components/PageState'; +import { useApiResource } from '@/api/useApiResource'; +import { describeApiError, useMutation } from '@/api/useMutation'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { + Button, + Callout, + Card, + CardContent, + CardHeader, + CheckboxField, + EmptyState, + ErrorState, + LoadingState, + StatusBadge, + useToast, +} from '@/components/ui'; +import { cn, formatDateTime } from '@/lib/utils'; import { acknowledgeAlert, fetchAlertOverview, fetchAlerts, resolveAlert, } from './alertsApi'; -import type { Alert, AlertOverview, AlertSeverity } from './alertsApi'; +import type { Alert, AlertOverview } from './alertsApi'; -/** Zeigt die Meldungen. */ export function AlertsPage(): React.JSX.Element { + const toast = useToast(); const [onlyActive, setOnlyActive] = useState(true); const [actionCounter, setActionCounter] = useState(0); - const [actionError, setActionError] = useState(null); const loadAlerts = useCallback( (abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal), @@ -44,264 +59,250 @@ export function AlertsPage(): React.JSX.Element { const overviewResource = useApiResource(loadOverview, String(actionCounter)); - /** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */ - async function runAlertAction(action: () => Promise): Promise { - try { - await action(); - setActionError(null); - setActionCounter((previousCounter) => previousCounter + 1); - } catch (caughtError) { - setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler'); - } - } + const acknowledgeMutation = useMutation( + (alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''), + { + onSuccess: () => { + toast.showSuccess( + 'Zur Kenntnis genommen', + 'Die Meldung bleibt offen, bis ihre Ursache verschwindet.', + ); + setActionCounter((previousCounter) => previousCounter + 1); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }, + ); + + const resolveMutation = useMutation( + (alertIdentifier: string) => resolveAlert(alertIdentifier, 'Von Hand geschlossen.'), + { + onSuccess: () => { + toast.showSuccess('Meldung geschlossen'); + setActionCounter((previousCounter) => previousCounter + 1); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }, + ); return ( -
-
-

Meldungen

+ <> + - {overviewResource.data !== null && ( - - {overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '} - Regeln werden geprueft - - )} -
+ {overviewResource.data !== null ? ( + + ) : null} - {overviewResource.data !== null && } - -
-
+ {loadState === 'loaded' && data !== null && data.length > 0 ? ( +
    + {data.map((alert) => ( + void acknowledgeMutation.run(alert.id)} + onResolve={() => void resolveMutation.run(alert.id)} + /> + ))} +
+ ) : null} + + + {overviewResource.data !== null ? : null} + ); } -/** Eigenschaften der Zusammenfassung. */ -interface SummaryBarProperties { - /** Die Meldungslage samt Regelwerk. */ - readonly overview: AlertOverview; -} - /** Zeigt die Meldungslage in Zahlen. */ -function SummaryBar({ overview }: SummaryBarProperties): React.JSX.Element { +function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.JSX.Element { const { summary } = overview; + const figures = [ + { label: 'Kritisch', value: summary.critical_count, tone: 'critical' as const }, + { label: 'Ernst', value: summary.high_count, tone: 'high' as const }, + { label: 'Unbearbeitet', value: summary.open_count, tone: null }, + { label: 'Zur Kenntnis genommen', value: summary.acknowledged_count, tone: null }, + { label: 'Erledigt (24 h)', value: summary.resolved_last_day, tone: null }, + ]; + return ( -
- 0 ? 'critical' : undefined} - /> - 0 ? 'high' : undefined} - /> - - - +
+ {figures.map((figure) => ( + +

0 + ? figure.tone === 'critical' + ? 'text-critical' + : 'text-high' + : 'text-fg', + )} + > + {figure.value} +

+

{figure.label}

+
+ ))}
); } -/** Eigenschaften einer Kennzahl der Zusammenfassung. */ -interface SummaryFigureProperties { - /** Beschriftung. */ - readonly label: string; - /** Der Wert. */ - readonly value: number; - /** Statusfarbe, sofern der Wert eine ist. */ - readonly severity?: AlertSeverity | undefined; -} - -/** Zeigt eine einzelne Kennzahl. */ -function SummaryFigure({ label, value, severity }: SummaryFigureProperties): React.JSX.Element { - return ( -
- {value} - {label} -
- ); -} - -/** Eigenschaften einer Meldungskarte. */ -interface AlertCardProperties { - /** Die dargestellte Meldung. */ - readonly alert: Alert; - /** Nimmt die Meldung zur Kenntnis. */ - readonly onAcknowledge: () => void; - /** Schliesst die Meldung. */ - readonly onResolve: () => void; -} - /** Zeigt eine einzelne Meldung. */ -function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element { +function AlertRow({ + alert, + isBusy, + onAcknowledge, + onResolve, +}: { + readonly alert: Alert; + readonly isBusy: boolean; + readonly onAcknowledge: () => void; + readonly onResolve: () => void; +}): React.JSX.Element { const isResolved = alert.status === 'resolved'; return ( -
  • -
    - {alert.severity} +
  • +
    +
    +
    + +

    {alert.title}

    + {/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall, + zwanzigmal ein Zustand. */} + {alert.occurrence_count > 1 ? ( + + {alert.occurrence_count}× aufgetreten + + ) : null} +
    -

    {alert.title}

    +

    {alert.message}

    - {/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall, - zwanzigmal ein Zustand. */} - {alert.occurrence_count > 1 && ( - {alert.occurrence_count}× aufgetreten - )} -
    +

    + Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '} + {formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name} +

    -

    {alert.message}

    + {alert.status === 'acknowledged' ? ( +

    + Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die + Meldung bleibt offen, bis ihre Ursache verschwindet. +

    + ) : null} -

    - Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} · - Regel {alert.rule_name} -

    - - {alert.status === 'acknowledged' && ( -

    - Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt - offen, bis ihre Ursache verschwindet. -

    - )} - - {isResolved && ( -

    - Erledigt am {formatTimestamp(alert.resolved_at)} - {alert.resolution_note !== undefined && alert.resolution_note !== '' - ? ` — ${alert.resolution_note}` - : ''} -

    - )} - - {!isResolved && ( -
    - {alert.status === 'open' && ( - - )} - - + {isResolved ? ( +

    + Erledigt am {formatDateTime(alert.resolved_at)} + {alert.resolution_note !== undefined && alert.resolution_note !== '' + ? ` — ${alert.resolution_note}` + : ''} +

    + ) : null}
    - )} + + {!isResolved ? ( +
    + {alert.status === 'open' ? ( + + ) : null} + +
    + ) : null} +
  • ); } -/** Eigenschaften der Regelliste. */ -interface RuleListProperties { - /** Die Meldungslage samt Regelwerk. */ - readonly overview: AlertOverview; -} - /** * Zeigt, worauf geachtet wird. * - * Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in - * Ordnung" oder „es wird nichts geprueft". + * Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt + * den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten + * Regel ausdruecklich, dass sie nicht greift, und warum. */ -function RuleList({ overview }: RuleListProperties): React.JSX.Element { +function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element { return ( -
    -

    Worauf geachtet wird

    - -
      - {overview.rules.map((rule) => ( -
    • -
      - {rule.title} - - {rule.available ? ( - {rule.severity} - ) : ( - wird nicht geprueft + + + +
        + {overview.rules.map((rule) => ( +
      • + > +
        + {rule.title} + {rule.available ? ( + + ) : ( + + )} +
        +

        + {rule.available ? rule.description : rule.unavailable_reason} +

        +
      • + ))} +
      -

      - {rule.available ? rule.description : rule.unavailable_reason} -

      -
    • - ))} -
    -
    + {overview.available_rule_count < overview.rules.length ? ( + + {overview.rules.length - overview.available_rule_count} Regeln werden + nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie + schweigen, weil ihnen die Datengrundlage fehlt. + + ) : null} + + ); } - -/** Bildet einen Schweregrad auf eine Statusfarbe ab. */ -function severityClassOf(severity: AlertSeverity): string { - switch (severity) { - case 'critical': - return 'critical'; - case 'high': - return 'high'; - case 'warning': - return 'warning'; - default: - return 'info'; - } -} - -/** Schreibt einen Zeitstempel in deutscher Schreibweise. */ -function formatTimestamp(isoTimestamp: string | undefined): string { - if (isoTimestamp === undefined) { - return '—'; - } - - return new Date(isoTimestamp).toLocaleString('de-DE', { - dateStyle: 'medium', - timeStyle: 'short', - }); -} diff --git a/apps/web/src/features/auth/LoginPage.css b/apps/web/src/features/auth/LoginPage.css deleted file mode 100644 index b88e3d6..0000000 --- a/apps/web/src/features/auth/LoginPage.css +++ /dev/null @@ -1,102 +0,0 @@ -/* Darstellung der Anmeldemaske. */ - -.login-page { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-4); -} - -.login-card { - width: 100%; - max-width: 22rem; - background-color: var(--color-surface-raised); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-8) var(--space-6); -} - -.login-card__title { - margin: 0 0 var(--space-1); - font-size: var(--text-xl); - font-weight: 600; - letter-spacing: -0.02em; -} - -.login-card__subtitle { - margin: 0 0 var(--space-6); - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.login-form { - display: flex; - flex-direction: column; -} - -.login-form__label { - font-size: var(--text-sm); - font-weight: 500; - margin-bottom: var(--space-1); -} - -.login-form__input { - font: inherit; - padding: var(--space-2) var(--space-3); - margin-bottom: var(--space-4); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - background-color: var(--color-surface-page); - color: var(--color-text-primary); -} - -/* Der Einmalcode wird in Ziffernbreite dargestellt, damit er gut lesbar ist. */ -.login-form__input--code { - font-family: var(--font-mono); - font-size: var(--text-lg); - letter-spacing: 0.15em; - text-align: center; -} - -.login-form__hint { - margin: 0 0 var(--space-4); - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.login-form__error { - /* Der farbige Rand betont den Fehler, ohne die Flaeche einzufaerben. */ - border-left: 3px solid var(--color-status-critical); - padding-left: var(--space-3); - margin: 0 0 var(--space-4); - font-size: var(--text-sm); -} - -.login-form__button { - font: inherit; - font-weight: 500; - padding: var(--space-3); - border: var(--border-width) solid var(--color-text-primary); - border-radius: var(--radius); - background-color: var(--color-text-primary); - color: var(--color-surface-raised); - cursor: pointer; -} - -.login-form__button:disabled { - opacity: 0.6; - cursor: default; -} - -.login-form__secondary { - font: inherit; - font-size: var(--text-sm); - margin-top: var(--space-3); - padding: var(--space-2); - border: none; - background: none; - color: var(--color-text-secondary); - cursor: pointer; - text-decoration: underline; -} diff --git a/apps/web/src/features/auth/LoginPage.tsx b/apps/web/src/features/auth/LoginPage.tsx index 237c37b..006e050 100644 --- a/apps/web/src/features/auth/LoginPage.tsx +++ b/apps/web/src/features/auth/LoginPage.tsx @@ -9,7 +9,6 @@ import { useState } from 'react'; import { ApiError } from '../../api/client'; import { login, verifyMFA } from './authApi'; import type { CurrentUser } from '../../types/auth'; -import './LoginPage.css'; /** Eigenschaften der Anmeldemaske. */ export interface LoginPageProps { @@ -106,20 +105,20 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen } return ( -
    -
    -

    Syncova

    +
    +
    +

    Syncova

    {loginStep === 'credentials' ? ( -
    -

    Bitte anmelden

    + +

    Bitte anmelden

    -
    + } + /> -
    - - - Version {systemHealth.version} · Umgebung {systemHealth.environment} - -
    - - {componentEntries.length === 0 ? ( -

    Es sind keine Komponenten registriert.

    + {componentRows.length === 0 ? ( + +

    Es sind keine Komponenten registriert.

    +
    ) : ( - - - - - - - - - - - {componentEntries.map(([componentName, componentHealth]) => ( - - - - - - ))} - -
    Zustand der einzelnen Komponenten
    KomponenteZustandAntwortzeit
    {componentName} - - {componentHealth.message && ( - {componentHealth.message} - )} - {componentHealth.latency_ms.toFixed(1)} ms
    + component.name} + /> )} - + ); } diff --git a/apps/web/src/features/jobs/BackupWizard.css b/apps/web/src/features/jobs/BackupWizard.css deleted file mode 100644 index cc8e3b3..0000000 --- a/apps/web/src/features/jobs/BackupWizard.css +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Darstellung des Backup-Assistenten. - * - * Semantische Farben stehen ausschliesslich fuer Zustand (PROMPT.md §28): - * Rot fuer Fehler, Gelb fuer nicht verfuegbare Faehigkeiten, Blau fuer - * Hinweise. Alles Uebrige bleibt neutral - Farbe als Schmuck macht die - * Statusfarben bedeutungslos. - */ - -.wizard { - display: grid; - grid-template-columns: 14rem 1fr; - gap: var(--space-6); - align-items: start; -} - -/* Auf schmalen Fenstern liegt die Schrittleiste ueber dem Inhalt statt daneben. */ -@media (max-width: 48rem) { - .wizard { - grid-template-columns: 1fr; - } - - .wizard__steps { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - } -} - -.wizard__steps { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.wizard__step { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-2) var(--space-3); - border: var(--border-width) solid transparent; - border-radius: var(--radius); - background: none; - color: var(--color-text-secondary); - font-size: var(--text-sm); - text-align: left; - cursor: pointer; -} - -/* Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine - Prüfung überspringen, die der Assistent gerade führen soll. */ -.wizard__step:disabled { - cursor: default; - opacity: 0.5; -} - -.wizard__step--current { - border-color: var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text-primary); - font-weight: 600; -} - -.wizard__step--done { - color: var(--color-status-healthy); -} - -.wizard__step-number { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - flex-shrink: 0; - border-radius: 50%; - border: var(--border-width) solid currentColor; - font-size: 0.75rem; -} - -.wizard__panel { - padding: var(--space-6); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface-raised); -} - -.wizard__heading { - margin: 0 0 var(--space-6); - font-size: var(--text-lg); - color: var(--color-text-primary); -} - -.wizard__fields { - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -.wizard__field { - display: flex; - flex-direction: column; - gap: var(--space-1); - border: none; - margin: 0; - padding: 0; -} - -.wizard__label { - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.wizard__input { - padding: var(--space-2) var(--space-3); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface-page); - color: var(--color-text-primary); - font-family: var(--font-sans); - font-size: var(--text-base); -} - -.wizard__input--area { - resize: vertical; -} - -.wizard__input--mono { - font-family: var(--font-mono); -} - -.wizard__hint { - font-size: var(--text-sm); - color: var(--color-text-secondary); - margin: 0; -} - -.wizard__preview { - padding: var(--space-3); - border-left: 3px solid var(--color-status-info); - background: var(--color-surface-page); - color: var(--color-text-primary); - font-size: var(--text-sm); - margin: 0; -} - -.wizard__source { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding: var(--space-4); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - margin: 0; -} - -.wizard__checkbox-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); -} - -.wizard__checkbox, -.wizard__radio { - display: flex; - align-items: flex-start; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--color-text-primary); - cursor: pointer; -} - -.wizard__radio { - padding: var(--space-3); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); -} - -.wizard__radio-body { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.wizard__radio-title { - font-weight: 600; -} - -.wizard__radio-detail { - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.wizard__radio-warning { - font-size: var(--text-sm); - color: var(--color-status-warning); -} - -/* Ein nicht umgesetzter Schritt wird als solcher gekennzeichnet, statt Werte - abzufragen, die niemand auswertet (PROMPT.md §138). */ -.wizard__unavailable { - padding: var(--space-4); - border-left: 3px solid var(--color-status-warning); - background: var(--color-surface-page); -} - -.wizard__unavailable-badge { - margin: 0 0 var(--space-2); - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-status-warning); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.wizard__unavailable-text { - margin: 0; - color: var(--color-text-primary); -} - -.wizard__notice { - padding: var(--space-3); - border-left: 3px solid var(--color-status-healthy); - background: var(--color-surface-page); - font-size: var(--text-sm); - color: var(--color-text-primary); -} - -.wizard__problems { - margin: var(--space-4) 0 0; - padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6); - border-left: 3px solid var(--color-status-critical); - background: var(--color-surface-page); - color: var(--color-status-critical); - font-size: var(--text-sm); -} - -.wizard__error { - margin: 0; - padding: var(--space-3); - border-left: 3px solid var(--color-status-critical); - background: var(--color-surface-page); - color: var(--color-status-critical); - font-size: var(--text-sm); -} - -.wizard__summary { - display: grid; - grid-template-columns: 12rem 1fr; - gap: var(--space-2) var(--space-4); - margin: 0; -} - -.wizard__summary dt { - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.wizard__summary dd { - margin: 0; - color: var(--color-text-primary); -} - -/* Was nicht eingerichtet ist, wird in der Uebersicht als solches benannt. - Eine leere Zeile liesse offen, ob nichts eingestellt oder nichts moeglich ist. */ -.wizard__summary-unavailable { - color: var(--color-status-warning); - font-size: var(--text-sm); -} - -.wizard__summary-list { - margin: 0; - padding-left: var(--space-4); -} - -.wizard__summary-detail { - color: var(--color-text-secondary); - font-size: var(--text-sm); -} - -.wizard__actions { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-3); - margin-top: var(--space-8); - padding-top: var(--space-4); - border-top: var(--border-width) solid var(--color-border); -} - -.wizard__navigation { - display: flex; - gap: var(--space-2); -} - -.wizard__button { - padding: var(--space-2) var(--space-4); - border: var(--border-width) solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface-page); - color: var(--color-text-primary); - font-size: var(--text-base); - cursor: pointer; -} - -.wizard__button--primary { - border-color: var(--color-text-primary); - background: var(--color-text-primary); - color: var(--color-surface-raised); -} - -.wizard__button--small { - align-self: flex-start; - font-size: var(--text-sm); -} - -.wizard__button:disabled { - cursor: default; - opacity: 0.6; -} diff --git a/apps/web/src/features/jobs/BackupWizard.tsx b/apps/web/src/features/jobs/BackupWizard.tsx index 1aeea71..d11343f 100644 --- a/apps/web/src/features/jobs/BackupWizard.tsx +++ b/apps/web/src/features/jobs/BackupWizard.tsx @@ -23,7 +23,6 @@ import { WIZARD_STEPS, } from './wizardModel'; import type { DraftSource, JobDraft, WizardStep } from './wizardModel'; -import './BackupWizard.css'; /** Eigenschaften des Assistenten. */ export interface BackupWizardProps { @@ -132,7 +131,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea return (
    -
      +
        {WIZARD_STEPS.map((wizardStep, stepIndex) => (
      1. ))}
      -
      -

      +
      +

      Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}

      {renderStepContent()} {showProblems && stepValidation.problems.length > 0 && ( -
        +
          {stepValidation.problems.map((problemText) => (
        • {problemText}
        • ))}
        )} -
        - -
        +
        {currentStepIndex > 0 && ( - )} {currentStep !== 'create' ? ( - ) : (