Design: Serverpuls-Projekttabelle (Sortierkoepfe und Zeilen als Buttons, Deltas als Farbe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab4a300e70
commit
fa1ce2c4cb
2 files changed
+82
-61
No files matched your search
@@ -1,22 +1,26 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
|
||||
import { isRowActivation } from '../../lib/rowClick'
|
||||
import Sparkline from './Sparkline'
|
||||
|
||||
const COLS = [
|
||||
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
|
||||
{ key: 'sizeBytes', label: 'Größe', get: (r) => r.sizeBytes },
|
||||
{ key: 'delta7d', label: 'Δ 7 T', get: (r) => r.delta7d },
|
||||
{ key: 'delta30d', label: 'Δ 30 T', get: (r) => r.delta30d },
|
||||
{ key: 'cpuPct', label: 'CPU', get: (r) => r.cpuPct },
|
||||
{ key: 'memBytes', label: 'RAM', get: (r) => r.memBytes },
|
||||
]
|
||||
|
||||
// Das Vorzeichen steckt in d.text, die Richtung färbt den Text (rot = gestiegen, grün = gesunken)
|
||||
function Delta({ pct }) {
|
||||
const d = formatDelta(pct)
|
||||
const arrow = d.dir === 'up' ? '▲ ' : d.dir === 'down' ? '▼ ' : ''
|
||||
return <span className={`sp-delta sp-delta-${d.dir}`}>{arrow}{d.text}</span>
|
||||
return <span className={`sp-delta sp-delta-${d.dir}`}>{d.text}</span>
|
||||
}
|
||||
|
||||
// num: Zahlenspalte (rechts, tabellarische Ziffern), hide: unter 860 px ausgeblendet (Kernspalten bleiben)
|
||||
const COLS = [
|
||||
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
|
||||
{ key: 'sizeBytes', label: 'Größe', num: true, get: (r) => r.sizeBytes, cell: (r) => formatBytes(r.sizeBytes) },
|
||||
{ key: 'delta7d', label: 'Δ 7 T', num: true, get: (r) => r.delta7d, cell: (r) => <Delta pct={r.delta7d} /> },
|
||||
{ key: 'delta30d', label: 'Δ 30 T', num: true, hide: true, get: (r) => r.delta30d, cell: (r) => <Delta pct={r.delta30d} /> },
|
||||
{ key: 'cpuPct', label: 'CPU', num: true, hide: true, get: (r) => r.cpuPct, cell: (r) => (r.cpuPct === null ? '–' : formatPct(r.cpuPct)) },
|
||||
{ key: 'memBytes', label: 'RAM', num: true, get: (r) => r.memBytes, cell: (r) => formatBytes(r.memBytes) },
|
||||
]
|
||||
|
||||
const cellClass = (c) => [c.num && 'right num', c.hide && 'hide-sm'].filter(Boolean).join(' ') || undefined
|
||||
|
||||
export default function AppsTable({ rows, selectedId, onSelect }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
|
||||
@@ -39,52 +43,71 @@ export default function AppsTable({ rows, selectedId, onSelect }) {
|
||||
const toggle = (key) =>
|
||||
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }))
|
||||
|
||||
// Der Knopf in der ersten Zelle trägt die Tastatur, der Zeilenklick die Maus: isRowActivation filtert Klicks auf Knöpfe aus,
|
||||
// so löst jede Aktivierung onSelect genau einmal aus
|
||||
const pick = (id) => onSelect(selectedId === id ? null : id)
|
||||
|
||||
return (
|
||||
<div className="sp-apps" data-testid="sp-apps">
|
||||
<input
|
||||
className="sp-search"
|
||||
data-testid="sp-apps-search"
|
||||
type="search"
|
||||
placeholder="Projekt suchen …"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="sp-table-wrap">
|
||||
<table className="sp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLS.map((c) => (
|
||||
<th key={c.key} onClick={() => toggle(c.key)} className="sp-sortable" data-sort={c.key}>
|
||||
{c.label}{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>30 Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<div data-testid="sp-apps">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="input search"
|
||||
data-testid="sp-apps-search"
|
||||
type="search"
|
||||
placeholder="Projekt suchen …"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLS.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cellClass(c)}
|
||||
data-sort={c.key}
|
||||
aria-sort={sort.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
>
|
||||
<button type="button" className="sp-sort" onClick={() => toggle(c.key)}>
|
||||
{c.label}
|
||||
<span aria-hidden="true">{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="hide-sm">30 Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => {
|
||||
const selected = selectedId === r.id
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`row${selected ? ' sp-row-active' : ''}`}
|
||||
data-testid="sp-app-row"
|
||||
data-app-id={r.id}
|
||||
className={`sp-row ${selectedId === r.id ? 'sp-row-active' : ''}`}
|
||||
onClick={() => onSelect(selectedId === r.id ? null : r.id)}
|
||||
onClick={(e) => {
|
||||
if (isRowActivation(e)) pick(r.id)
|
||||
}}
|
||||
>
|
||||
<td className="sp-name">{r.name}</td>
|
||||
<td>{formatBytes(r.sizeBytes)}</td>
|
||||
<td><Delta pct={r.delta7d} /></td>
|
||||
<td><Delta pct={r.delta30d} /></td>
|
||||
<td>{r.cpuPct === null ? '–' : formatPct(r.cpuPct)}</td>
|
||||
<td>{formatBytes(r.memBytes)}</td>
|
||||
<td><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
||||
<td className="sp-name">
|
||||
<button type="button" className="link" aria-pressed={selected} onClick={() => pick(r.id)}>
|
||||
{r.name}
|
||||
</button>
|
||||
</td>
|
||||
{COLS.slice(1).map((c) => (
|
||||
<td key={c.key} className={cellClass(c)}>{c.cell(r)}</td>
|
||||
))}
|
||||
<td className="hide-sm"><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Serverpuls (P9): Kopf, Warnungen, Kacheln, Umschalter, Panels. Die Tabellenregeln weiter unten gehoeren Task 5. */
|
||||
/* Serverpuls (P9): Kopf, Warnungen, Kacheln, Umschalter, Panels, Projekttabelle */
|
||||
.sp-page .msg { margin: 0 0 16px; }
|
||||
|
||||
.sp-head-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
@@ -26,16 +26,14 @@
|
||||
.sp-chart .u-legend { font-size: 13px; color: var(--text-2); }
|
||||
.sp-nodata { padding: 40px 0; text-align: center; color: var(--text-2); }
|
||||
|
||||
.sp-search { width: 100%; max-width: 320px; margin-bottom: var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-sm); padding: 8px 12px; font: inherit; }
|
||||
.sp-table-wrap { overflow-x: auto; }
|
||||
.sp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.sp-table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.sp-sortable { cursor: pointer; user-select: none; }
|
||||
.sp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.sp-row { cursor: pointer; }
|
||||
.sp-row:hover, .sp-row-active { background: var(--surface-2); }
|
||||
.sp-name { font-weight: 600; }
|
||||
.sp-empty { text-align: center; color: var(--text-muted); }
|
||||
.sp-sort { display: block; width: 100%; text-align: inherit; white-space: nowrap; }
|
||||
.sp-page .table td.num { white-space: nowrap; }
|
||||
.sp-name { overflow-wrap: anywhere; }
|
||||
.sp-name .link { text-align: left; overflow-wrap: anywhere; }
|
||||
.sp-page .table tr.sp-row-active td { background: var(--bg-subtle); }
|
||||
.sp-page .table tr.sp-row-active td:first-child { box-shadow: -12px 0 0 var(--bg-subtle); }
|
||||
.sp-page .table tr.sp-row-active td:last-child { box-shadow: 12px 0 0 var(--bg-subtle); }
|
||||
.sp-empty { text-align: center; color: var(--text-2); }
|
||||
.sp-delta { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.sp-detail { margin-top: 28px; }
|
||||
|
||||
Reference in new issue
Block a user