@@ -205,9 +305,12 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork
nameFor={(id) => nodesById.get(id)?.name ?? id}
onSelect={onSelect}
/>
- {recentTruncated && (
-
- {t('operations:timeline.recentTruncated')}
+ {densityCellsByLane.size > 0 && (
+
+ {t(densityCapped ? 'operations:timeline.densityCapped' : 'operations:timeline.density', {
+ runs: densitySummary.runs,
+ failed: densitySummary.failed,
+ })}
@@ -277,6 +380,27 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork
data-testid="ops-history-gap"
/>
)}
+ {/* Density: what the window holds where individual bars ran out. One slice per
+ (lane, bucket), stacked by outcome so a slice with a single failure among twenty
+ successes cannot render as either all-green or all-red. */}
+ {lanes.flatMap((lane, i) =>
+ (densityCellsByLane.get(lane.workflowId) ?? []).map((cell) => (
+
+ )),
+ )}
{/* Gridlines */}
{ticks.map((tick) => (
diff --git a/src/nodepilot-ui/src/i18n/locales/de/ai.json b/src/nodepilot-ui/src/i18n/locales/de/ai.json
index 379cac4a..e32b7066 100644
--- a/src/nodepilot-ui/src/i18n/locales/de/ai.json
+++ b/src/nodepilot-ui/src/i18n/locales/de/ai.json
@@ -120,14 +120,24 @@
"disabledTitle": "KI-Chat ist deaktiviert",
"disabledBody": "Ein Administrator kann ihn in den Einstellungen unter „AI-Wissen (Chat)“ aktivieren (setzt Llm:Enabled voraus).",
"examples": [
- "Wie richte ich einen Webhook-Trigger ein?",
- "Welche Workflows sind in den letzten 24 Stunden fehlgeschlagen?",
- "Was macht der Workflow ‚Nightly Backup‘ und welche Maschinen nutzt er?",
- "Wie richte ich E-Mail-Alerts bei fehlgeschlagenen Läufen ein?",
- "Welche Workflows laufen nach einem Zeitplan?",
- "Welche Maschinen sind aktuell nicht erreichbar?",
+ "Zeig mir die letzten 10 fehlgeschlagenen Läufe mit Fehlermeldung.",
+ "An welchem Schritt ist der letzte fehlgeschlagene Lauf gescheitert?",
+ "Welche Workflows sind in den letzten 7 Tagen am häufigsten fehlgeschlagen?",
+ "Welche Läufe hängen seit über einer Stunde im Status „Running“?",
+ "Welche Maschinen waren beim letzten Verbindungstest nicht erreichbar?",
+ "Was steht in den nächsten 24 Stunden auf dem Zeitplan?",
+ "Wer hat in den letzten 24 Stunden was geändert?",
+ "Wie bekomme ich eine E-Mail, wenn ein Workflow fehlschlägt?"
+ ],
+ "examplesLite": [
+ "Was steht in den nächsten 24 Stunden auf dem Zeitplan?",
+ "Wie richte ich einen Webhook-Trigger sicher ein?",
+ "Wie bekomme ich eine E-Mail, wenn ein Workflow fehlschlägt?",
"Wie starte ich einen fehlgeschlagenen Lauf erneut?",
- "Wie funktioniert das System-Backup?"
+ "Wie gebe ich Daten von einem Schritt an den nächsten weiter?",
+ "Wozu dient der Bearbeitungs-Lock und wie gebe ich einen Workflow wieder frei?",
+ "Wie sichere ich die komplette NodePilot-Konfiguration und spiele sie zurück?",
+ "Was darf die Rolle ‚Operator‘ — und was nicht?"
]
},
"aria": {
diff --git a/src/nodepilot-ui/src/i18n/locales/de/operations.json b/src/nodepilot-ui/src/i18n/locales/de/operations.json
index 88585350..7d43e24e 100644
--- a/src/nodepilot-ui/src/i18n/locales/de/operations.json
+++ b/src/nodepilot-ui/src/i18n/locales/de/operations.json
@@ -13,7 +13,11 @@
"idleHint": "Sobald ein Workflow startet, wächst hier live sein Balken.",
"nextStart": "Nächster Start {{time}} — {{name}}",
"historyGap": "Keine beendeten Läufe vor {{time}} geliefert",
- "recentTruncated": "Zu viele beendete Läufe in diesem Fenster — ältere werden nicht angezeigt."
+ "density": "{{runs}} beendete Läufe in diesem Fenster, {{failed}} fehlgeschlagen — ältere sind als Dichte zusammengefasst. Für einzelne Balken ein kürzeres Fenster wählen.",
+ "densityCapped": "Mehr als {{runs}} beendete Läufe in diesem Fenster — die Dichte beruht auf den neuesten {{runs}}. Für einzelne Balken ein kürzeres Fenster wählen.",
+ "densityCell": "{{from}}–{{to}} · {{runs}} Läufe",
+ "densityCellFailed": "{{count}} fehlgeschlagen",
+ "densityCellCancelled": "{{count}} abgebrochen"
},
"window": {
"label": "Zeitfenster",
diff --git a/src/nodepilot-ui/src/i18n/locales/en/ai.json b/src/nodepilot-ui/src/i18n/locales/en/ai.json
index 0a7f4877..33ea9f60 100644
--- a/src/nodepilot-ui/src/i18n/locales/en/ai.json
+++ b/src/nodepilot-ui/src/i18n/locales/en/ai.json
@@ -120,14 +120,24 @@
"disabledTitle": "AI Chat is disabled",
"disabledBody": "An administrator can enable it in Settings under \"AI Knowledge (Chat)\" (requires Llm:Enabled).",
"examples": [
- "How do I set up a webhook trigger?",
- "Which workflows failed in the last 24 hours?",
- "What does the 'Nightly Backup' workflow do and which machines does it use?",
- "How do I set up email alerts for failed runs?",
- "Which workflows run on a schedule?",
- "Which machines are currently unreachable?",
+ "Show me the last 10 failed runs with their error message.",
+ "Which step broke in the most recent failed run?",
+ "Which workflows failed most often in the last 7 days?",
+ "Which runs have been stuck in \"Running\" for more than an hour?",
+ "Which machines were unreachable at the last connectivity check?",
+ "What is scheduled to run in the next 24 hours?",
+ "Who changed what in the last 24 hours?",
+ "How do I get an email when a workflow fails?"
+ ],
+ "examplesLite": [
+ "What is scheduled to run in the next 24 hours?",
+ "How do I set up a webhook trigger securely?",
+ "How do I get an email when a workflow fails?",
"How do I retry a failed run?",
- "How does the system-configuration backup work?"
+ "How do I pass data from one step to the next?",
+ "What is the edit lock for, and how do I release a workflow?",
+ "How do I back up the whole NodePilot configuration and restore it?",
+ "What may the Operator role do — and what not?"
]
},
"aria": {
diff --git a/src/nodepilot-ui/src/i18n/locales/en/operations.json b/src/nodepilot-ui/src/i18n/locales/en/operations.json
index c5202866..7bd2fe27 100644
--- a/src/nodepilot-ui/src/i18n/locales/en/operations.json
+++ b/src/nodepilot-ui/src/i18n/locales/en/operations.json
@@ -13,7 +13,11 @@
"idleHint": "As soon as a workflow starts, its bar grows here live.",
"nextStart": "Next start {{time}} — {{name}}",
"historyGap": "No finished runs returned before {{time}}",
- "recentTruncated": "Too many finished runs in this window — older ones are not shown."
+ "density": "{{runs}} finished runs in this window, {{failed}} failed — older ones are aggregated as density. Pick a shorter window for individual bars.",
+ "densityCapped": "More than {{runs}} finished runs in this window — the density is based on the newest {{runs}}. Pick a shorter window for individual bars.",
+ "densityCell": "{{from}}–{{to}} · {{runs}} runs",
+ "densityCellFailed": "{{count}} failed",
+ "densityCellCancelled": "{{count}} cancelled"
},
"window": {
"label": "Window",
diff --git a/src/nodepilot-ui/src/index.css b/src/nodepilot-ui/src/index.css
index 4171037c..ca1e2e1b 100644
--- a/src/nodepilot-ui/src/index.css
+++ b/src/nodepilot-ui/src/index.css
@@ -1606,10 +1606,22 @@ html.dark .np-shell .np-card.np-folder-card {
/* Table column-header tint. A hair above surface-low (midway toward the card body
surface-container) so the header is a touch lighter than the plain surface-low bar
but still sits just under the body — distinct without the harsh near-black. Token-
- based so it adapts to light + dark. Tune here once for every list table. */
+ based so it adapts to light + dark. Tune here once for every list table
+ (Workflows, Executions, Machines, Globals, Users, Maintenance, Alerting, Custom
+ Activities, the DB viewer — they all share this one class). */
.np-col-header {
background-color: color-mix(in srgb, var(--color-surface-low), var(--color-surface-container));
}
+/* Light needs a bigger step than dark. The surface ramp is compressed at the top —
+ the card is pure white and the page ground sits ~3% below it — so the dark-side
+ value lands only ~4% under the card body and the header stops reading as its own
+ band. Moving one step down the ramp (container → high) roughly doubles that to
+ ~8%, which separates the header without turning it into a filled toolbar. Still
+ token-based, so each light skin keeps its own hue: cool grey on `light`, cream on
+ `light-grey`, cool blue-grey on `light-sparkasse`. */
+html:not(.dark) .np-col-header {
+ background-color: color-mix(in srgb, var(--color-surface-container), var(--color-surface-high));
+}
/* Hero card — same glass, plus an accent halo so the signature element pops. */
.np-card-hero { border-radius: 1.5rem; /* 24px */ }
@@ -2565,6 +2577,17 @@ html:not(.dark) .np-ops-bar--running {
border-right: 1px dashed color-mix(in srgb, var(--color-outline) 60%, transparent);
}
+/* Density slice: what one time bucket of a lane held once individual bars ran out. Drawn before
+ the gridlines and the bars, so anything with a drill-down wins the pointer; density only ever
+ occupies the stretch left of the seam, where no bar exists to compete with. It does keep
+ pointer events — the hover title is the only way to read the counts behind a slice. Inline
+ styles carry the outcome stack and the count-driven opacity. */
+.np-ops-density {
+ position: absolute;
+ cursor: default;
+ border-radius: 2px;
+}
+
/* --- Stuck strip: overdue runs lifted out of the timeline ------------------- */
.np-ops-stuck {
display: flex;
@@ -3183,21 +3206,27 @@ html.dark[data-skin="dark-nebula"] .np-shell aside a.np-nav[aria-current="page"]
──────────────────────────────────────────────────────────────────────────── */
html.dark[data-skin="dark"] .np-shell,
html.dark[data-skin="dark"] .np-designer {
- --np-az-rim-top: rgba(255, 255, 255, 0.10);
- --np-az-rim-side: rgba(255, 255, 255, 0.045);
- --np-az-rim-bottom: rgba(0, 0, 0, 0.55);
+ /* Depth intensity is calibrated as ONE set: the lit top rim, the dark bottom rim,
+ the face gradient and the shadow ramp all read as the same light. Change them
+ together or plates stop matching buttons. The current level is one deliberate
+ step below the original build — the bevel was reading as embossed rather than
+ as a plate lying on the page. Keep the ratios, scale the alphas. */
+ --np-az-rim-top: rgba(255, 255, 255, 0.075);
+ --np-az-rim-side: rgba(255, 255, 255, 0.034);
+ --np-az-rim-bottom: rgba(0, 0, 0, 0.40);
--np-az-face: linear-gradient(180deg,
- rgba(255, 255, 255, 0.065) 0%,
- rgba(255, 255, 255, 0.018) 42%,
- rgba(0, 0, 0, 0.14) 100%);
- --np-az-sheen: inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
+ rgba(255, 255, 255, 0.048) 0%,
+ rgba(255, 255, 255, 0.013) 42%,
+ rgba(0, 0, 0, 0.10) 100%);
+ --np-az-sheen: inset 0 1px 0 0 rgba(255, 255, 255, 0.045);
/* Every step opens with a hard contact line — that is what makes a plate read
as resting on the page instead of floating above it, and it is the one depth
- cue that survives on a cheap panel at 100% zoom. */
- --np-az-shadow-sm: 0 1px 0 0 rgba(0,0,0,.60), 0 2px 4px rgba(0,0,0,.45), 0 6px 16px -6px rgba(0,0,0,.70);
- --np-az-shadow-md: 0 1px 0 0 rgba(0,0,0,.65), 0 4px 8px -2px rgba(0,0,0,.50), 0 16px 36px -12px rgba(0,0,0,.78);
- --np-az-shadow-lg: 0 1px 0 0 rgba(0,0,0,.70), 0 8px 16px -6px rgba(0,0,0,.55), 0 30px 64px -20px rgba(0,0,0,.82);
- --np-az-shadow-xl: 0 1px 0 0 rgba(0,0,0,.75), 0 12px 24px -8px rgba(0,0,0,.60), 0 44px 88px -28px rgba(0,0,0,.85);
+ cue that survives on a cheap panel at 100% zoom. Geometry is unchanged from
+ the original ramp; only the alphas came down, so the cue survives the softening. */
+ --np-az-shadow-sm: 0 1px 0 0 rgba(0,0,0,.44), 0 2px 4px rgba(0,0,0,.32), 0 6px 16px -6px rgba(0,0,0,.50);
+ --np-az-shadow-md: 0 1px 0 0 rgba(0,0,0,.48), 0 4px 8px -2px rgba(0,0,0,.36), 0 16px 36px -12px rgba(0,0,0,.56);
+ --np-az-shadow-lg: 0 1px 0 0 rgba(0,0,0,.52), 0 8px 16px -6px rgba(0,0,0,.40), 0 30px 64px -20px rgba(0,0,0,.60);
+ --np-az-shadow-xl: 0 1px 0 0 rgba(0,0,0,.56), 0 12px 24px -8px rgba(0,0,0,.44), 0 44px 88px -28px rgba(0,0,0,.62);
--np-az-btn-top: #3372e0;
--np-az-btn-bot: #2467d9;
}
@@ -3268,7 +3297,9 @@ html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn:hover:not(:disa
html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn:active:not(:disabled) {
border-top-color: var(--np-az-rim-bottom);
border-bottom-color: var(--np-az-rim-side);
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.55);
+ /* Scaled with the raised state above — a press that stays deep against a softened
+ plate reads as a bigger travel than the button actually has. */
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.42);
}
html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn-ghost {
border-color: transparent;
@@ -3283,17 +3314,17 @@ html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn-ghost {
html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn-primary,
html.dark[data-skin="dark"].np-accent-remap .np-shell .bg-blue-600 {
border-color: transparent;
- border-bottom-color: rgba(0, 0, 0, 0.45);
+ border-bottom-color: rgba(0, 0, 0, 0.33);
background-color: var(--np-az-btn-bot);
background-image: linear-gradient(180deg, var(--np-az-btn-top) 0%, var(--np-az-btn-bot) 100%);
color: #ffffff;
- box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.28), var(--np-az-shadow-sm);
+ box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.22), var(--np-az-shadow-sm);
}
@media (hover: hover) and (pointer: fine) {
html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .np-btn-primary:hover:not(:disabled),
html.dark[data-skin="dark"].np-accent-remap .np-shell .bg-blue-600:hover {
background-image: linear-gradient(180deg, var(--np-az-btn-bot) 0%, var(--np-accent-hover) 100%);
- box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.32),
+ box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.25),
0 8px 26px -8px color-mix(in srgb, var(--np-accent) 60%, transparent);
}
}
@@ -3306,12 +3337,12 @@ html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .input-field {
border-right-color: var(--color-outline-variant);
border-bottom-color: var(--np-az-rim-side);
background-color: var(--color-surface-lowest);
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.45);
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.34);
}
html.dark[data-skin="dark"] :is(.np-shell, .np-designer) .input-field:focus {
background-color: var(--color-surface);
border-color: color-mix(in srgb, var(--np-accent-text) 55%, transparent);
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.45),
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.34),
0 0 0 3px color-mix(in srgb, var(--np-accent) 20%, transparent);
}
@@ -3344,3 +3375,162 @@ html.dark[data-skin="dark"] {
--np-minimap-mask-atelier: rgba(11, 12, 15, 0.64);
--np-node-bg: rgba(22, 24, 29, 0.94);
}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ DEFAULT LIGHT SKIN "LUMEN" — the depth layer, mirroring Azur.
+
+ Same lighting model as `[data-skin="dark"]` above, same five ingredients in the
+ same order (rim / face / sheen / shadow ramp / inverted inputs), so a plate, a
+ button and a modal read as one material in both bases. Private namespace
+ `--np-lt-*`; the selector list is a 1:1 copy of the Azur one, which is the point
+ — anything added to one belongs in the other.
+
+ ONE deliberate divergence, forced by the physics: a light plate is already the
+ brightest thing on the page (`--color-surface-lowest` #ffffff on a #f8f9fb
+ ground), so there is no headroom for a LIT top rim. The lift therefore comes
+ from the shadow, and the bevel is carried entirely downward — the border darkens
+ from top to bottom instead of flipping from light to dark. Everything that still
+ has headroom (secondary buttons and inputs on the grey surface steps) does get
+ the white rim and the sheen, which is why both are kept in the token set rather
+ than dropped.
+
+ Alphas are much lower than Azur's for the same *perceived* depth: on white,
+ ink at .17 already reads as a hard edge, where on #1a1c20 it disappears. The
+ ink itself is the cool near-black 23,26,31 the `--np-elev-*` light ramp already
+ uses, so designer menus and dashboard plates cast the same colour of shadow.
+
+ Scoped to `[data-skin="light"]` only. `light-grey` and `light-sparkasse` have
+ their own token blocks further up and are deliberately untouched — the bank skin
+ in particular tunes its own button plates.
+ ──────────────────────────────────────────────────────────────────────────── */
+html:not(.dark)[data-skin="light"] .np-shell,
+html:not(.dark)[data-skin="light"] .np-designer {
+ /* Bevel: monotonically darker top → bottom. See the divergence note above. */
+ --np-lt-rim-top: rgba(23, 26, 31, 0.05);
+ --np-lt-rim-side: rgba(23, 26, 31, 0.10);
+ --np-lt-rim-bottom: rgba(23, 26, 31, 0.17);
+ --np-lt-face: linear-gradient(180deg,
+ rgba(255, 255, 255, 0.90) 0%,
+ rgba(255, 255, 255, 0) 45%,
+ rgba(23, 26, 31, 0.030) 100%);
+ --np-lt-sheen: inset 0 1px 0 0 rgba(255, 255, 255, 0.90);
+ /* Same geometry as the Azur ramp — including the 0-blur contact line that makes
+ a plate rest on the page rather than hover over it. Only the alphas differ. */
+ --np-lt-shadow-sm: 0 1px 0 0 rgba(23,26,31,.05), 0 2px 4px rgba(23,26,31,.05), 0 6px 16px -6px rgba(23,26,31,.10);
+ --np-lt-shadow-md: 0 1px 0 0 rgba(23,26,31,.06), 0 4px 8px -2px rgba(23,26,31,.06), 0 16px 36px -12px rgba(23,26,31,.13);
+ --np-lt-shadow-lg: 0 1px 0 0 rgba(23,26,31,.07), 0 8px 16px -6px rgba(23,26,31,.07), 0 30px 64px -20px rgba(23,26,31,.16);
+ --np-lt-shadow-xl: 0 1px 0 0 rgba(23,26,31,.08), 0 12px 24px -8px rgba(23,26,31,.09), 0 44px 88px -28px rgba(23,26,31,.20);
+}
+
+/* Elevation ramp. `.shadow-2xl` is included for the same reason as in Azur —
+ ModalShell, the drawer and the designer overlays use it. */
+html:not(.dark)[data-skin="light"] .np-shell .shadow { box-shadow: var(--np-lt-shadow-sm); }
+html:not(.dark)[data-skin="light"] .np-shell .shadow-sm { box-shadow: var(--np-lt-shadow-sm); }
+html:not(.dark)[data-skin="light"] .np-shell .shadow-md { box-shadow: var(--np-lt-shadow-md); }
+html:not(.dark)[data-skin="light"] .np-shell .shadow-lg { box-shadow: var(--np-lt-shadow-lg); }
+html:not(.dark)[data-skin="light"] .np-shell .shadow-xl { box-shadow: var(--np-lt-shadow-xl); }
+html:not(.dark)[data-skin="light"] .np-shell .shadow-2xl { box-shadow: var(--np-lt-shadow-xl); }
+
+/* ── Cards ─────────────────────────────────────────────────────────────────── */
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .np-card {
+ border: 1px solid transparent;
+ border-top-color: var(--np-lt-rim-top);
+ border-left-color: var(--np-lt-rim-side);
+ border-right-color: var(--np-lt-rim-side);
+ border-bottom-color: var(--np-lt-rim-bottom);
+ background-color: var(--color-surface-lowest);
+ background-image: var(--np-lt-face);
+ box-shadow: var(--np-lt-shadow-lg);
+}
+/* Shared-folders box: evenly lit, like its Azur counterpart — the face gradient's
+ foot reads heavy down a tall resizable panel. */
+html:not(.dark)[data-skin="light"] .np-shell .np-card.np-folder-card {
+ background-image: linear-gradient(180deg, rgba(255,255,255,.9) 0%, rgba(255,255,255,0) 70%, transparent 100%);
+}
+/* Hero card: the accent arrives as light falling from the top right, never as a
+ surface tint — same construction as Azur, weaker because it lands on white. */
+html:not(.dark)[data-skin="light"] .np-shell .np-card-hero {
+ border-top-color: color-mix(in srgb, var(--color-primary) 22%, var(--np-lt-rim-top));
+ border-left-color: color-mix(in srgb, var(--color-primary) 16%, var(--np-lt-rim-side));
+ border-right-color: color-mix(in srgb, var(--color-primary) 16%, var(--np-lt-rim-side));
+ background-image:
+ radial-gradient(120% 100% at 100% 0%, color-mix(in srgb, var(--color-primary) 7%, transparent) 0%, transparent 55%),
+ var(--np-lt-face);
+ box-shadow:
+ var(--np-lt-shadow-lg),
+ 0 26px 60px -34px color-mix(in srgb, var(--color-primary) 45%, transparent);
+}
+
+/* ── List rows: an inset accent edge instead of a lift ──────────────────────
+ No transform and no layout shift, so a 500-row audit-log page stays smooth. */
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .np-row:hover {
+ background-color: color-mix(in srgb, var(--color-on-surface) 5%, transparent);
+ box-shadow: inset 3px 0 0 0 var(--color-primary);
+}
+
+/* ── Buttons: raised plates that sink when pressed ───────────────────────────
+ The `:not()` list keeps the semantic variants out: selected/attention/danger/
+ success carry their own tinted fill AND their own border colour, and a bevel at
+ this specificity would silently overwrite both. Ghost is excluded for the same
+ reason it is in Azur — it is not a plate. Note there is deliberately no
+ `background-color` here: the base rule already sets it, and declaring it would
+ flatten `.np-btn-secondary`'s grey step into plain white. */
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer)
+ .np-btn:not(.np-btn-primary, .np-btn-ghost, .np-btn-selected, .np-btn-attention, .np-btn-danger, .np-btn-success) {
+ border-color: transparent;
+ border-top-color: var(--np-lt-rim-top);
+ border-left-color: var(--np-lt-rim-side);
+ border-right-color: var(--np-lt-rim-side);
+ border-bottom-color: var(--np-lt-rim-bottom);
+ background-image: var(--np-lt-face);
+ box-shadow: var(--np-lt-sheen), var(--np-lt-shadow-sm);
+}
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer)
+ .np-btn:not(.np-btn-primary, .np-btn-ghost, .np-btn-selected, .np-btn-attention, .np-btn-danger, .np-btn-success):hover:not(:disabled) {
+ border-top-color: color-mix(in srgb, var(--color-primary) 25%, var(--np-lt-rim-top));
+}
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer)
+ .np-btn:not(.np-btn-ghost):active:not(:disabled) {
+ border-top-color: var(--np-lt-rim-bottom);
+ border-bottom-color: var(--np-lt-rim-top);
+ box-shadow: inset 0 2px 4px rgba(23, 26, 31, 0.14);
+}
+/* Primary: a two-stop gradient so the fill has a lit top edge instead of reading
+ as a flat swatch. Both stops sit at or below the token primary (#004ac6), so the
+ white label keeps its contrast over the whole height. */
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .np-btn-primary {
+ border-color: transparent;
+ border-bottom-color: rgba(0, 0, 0, 0.22);
+ background-color: var(--color-primary);
+ background-image: linear-gradient(180deg,
+ color-mix(in srgb, var(--color-primary) 88%, #ffffff) 0%,
+ var(--color-primary) 100%);
+ color: var(--color-on-primary);
+ box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.22), var(--np-lt-shadow-sm);
+}
+@media (hover: hover) and (pointer: fine) {
+ html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .np-btn-primary:hover:not(:disabled) {
+ background-image: linear-gradient(180deg,
+ var(--color-primary) 0%,
+ color-mix(in srgb, var(--color-primary) 88%, #000000) 100%);
+ box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.18),
+ 0 8px 26px -8px color-mix(in srgb, var(--color-primary) 45%, transparent);
+ }
+}
+
+/* ── Inputs: recessed, so they read as the opposite of a button ────────────── */
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .input-field {
+ border-color: transparent;
+ border-top-color: var(--np-lt-rim-bottom);
+ border-left-color: var(--np-lt-rim-side);
+ border-right-color: var(--np-lt-rim-side);
+ border-bottom-color: var(--np-lt-rim-top);
+ background-color: var(--color-surface-high);
+ box-shadow: inset 0 2px 4px rgba(23, 26, 31, 0.10);
+}
+html:not(.dark)[data-skin="light"] :is(.np-shell, .np-designer) .input-field:focus {
+ background-color: var(--color-surface-low);
+ border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
+ box-shadow: inset 0 2px 4px rgba(23, 26, 31, 0.10),
+ 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent);
+}
diff --git a/src/nodepilot-ui/src/lib/opsTimeline.ts b/src/nodepilot-ui/src/lib/opsTimeline.ts
index f2c684fe..3af689a1 100644
--- a/src/nodepilot-ui/src/lib/opsTimeline.ts
+++ b/src/nodepilot-ui/src/lib/opsTimeline.ts
@@ -1,4 +1,4 @@
-import type { OpsNode, OpsRecentExecution, OpsRunningExecution } from '../types/api';
+import type { OpsDensityBucket, OpsNode, OpsRecentExecution, OpsRunningExecution } from '../types/api';
import type { LocalSettled } from '../stores/operationsStore';
// Pure geometry + lane allocation for the Mission-Control live timeline. Everything here is
@@ -187,6 +187,7 @@ const ACTIVE_STATUSES = new Set(['Running', 'Pending', 'Paused']);
export function assignLanes(
bars: TimelineBarInput[],
nodesById: Map
,
+ densityWorkflowIds: ReadonlySet = new Set(),
): { lanes: TimelineLane[]; placed: Omit[] } {
const byWorkflow = new Map();
for (const b of bars) {
@@ -194,6 +195,12 @@ export function assignLanes(
if (list) list.push(b);
else byWorkflow.set(b.workflowId, [b]);
}
+ // A workflow can have density but no bar: it ran inside the window, yet every one of its runs
+ // fell past the raw cap. Without a lane of its own its density strip would have nowhere to
+ // draw and the workflow would read as idle — the exact misreading density exists to prevent.
+ for (const workflowId of densityWorkflowIds) {
+ if (!byWorkflow.has(workflowId)) byWorkflow.set(workflowId, []);
+ }
const wfByExec = new Map(bars.map((b) => [b.executionId, b.workflowId]));
@@ -278,7 +285,9 @@ export function assignLanes(
workflowId: lane.workflowId,
name: node?.name ?? lane.workflowId,
folderPath: node?.folderPath ?? '/',
- subRowCount: rowEnds.length,
+ // Floored at 1: a density-only lane has no bars and would otherwise be zero rows tall,
+ // leaving its label and its density strip nowhere to render.
+ subRowCount: Math.max(rowEnds.length, 1),
hasActive: lane.hasActive,
depth,
});
@@ -287,6 +296,61 @@ export function assignLanes(
return { lanes, placed };
}
+/** One rendered density slice: pixel geometry plus the counts behind it. */
+export interface DensityCell {
+ bucketIndex: number;
+ /** Time range this slice covers, already clipped to what is actually drawn. */
+ fromMs: number;
+ toMs: number;
+ leftPx: number;
+ widthPx: number;
+ total: number;
+ failed: number;
+ cancelled: number;
+}
+
+/**
+ * Turns one workflow's density buckets into drawable cells.
+ *
+ * Only the stretch LEFT of `seamMs` is emitted: that is where the snapshot ran out of individual
+ * runs, and it is the only place an aggregate adds anything. Right of the seam every run is
+ * already a bar, and drawing density under them would count the same runs twice on screen. The
+ * server nevertheless buckets the whole window (so the totals stay honest); the clipping is a
+ * rendering decision made here.
+ */
+export function buildDensityCells(
+ buckets: readonly OpsDensityBucket[],
+ recentSinceMs: number,
+ bucketSeconds: number,
+ seamMs: number,
+ w: TimelineWindow,
+): DensityCell[] {
+ if (bucketSeconds <= 0 || w.trackWidthPx <= 0) return [];
+ const bucketMs = bucketSeconds * 1000;
+ const cells: DensityCell[] = [];
+ for (const b of buckets) {
+ if (b.total <= 0) continue;
+ const from = Math.max(recentSinceMs + b.bucketIndex * bucketMs, w.startMs);
+ const to = Math.min(recentSinceMs + (b.bucketIndex + 1) * bucketMs, seamMs);
+ if (to <= from) continue;
+ const leftPx = timeToX(from, w);
+ // Floored at 1 px: a slice that rounds away to nothing would read as a gap in the history,
+ // which is the opposite of what it says.
+ const widthPx = Math.max(timeToX(to, w) - leftPx, 1);
+ cells.push({
+ bucketIndex: b.bucketIndex,
+ fromMs: from,
+ toMs: to,
+ leftPx,
+ widthPx,
+ total: b.total,
+ failed: b.failed,
+ cancelled: b.cancelled,
+ });
+ }
+ return cells;
+}
+
/** Computes final pixel geometry for a bar within the window. */
export function placeBar(
bar: Omit,
diff --git a/src/nodepilot-ui/src/pages/AiChatPage.tsx b/src/nodepilot-ui/src/pages/AiChatPage.tsx
index deaf3763..958648d1 100644
--- a/src/nodepilot-ui/src/pages/AiChatPage.tsx
+++ b/src/nodepilot-ui/src/pages/AiChatPage.tsx
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
- Add, BareMetalServer, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code, DataBase, Document,
- Download, Edit, Email, FlowModeler, Renew, Reset, Save, Send, Time, Tools, TrashCan, WarningAlt,
+ Add, BareMetalServer, ChartColumn, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code,
+ DataBase, DataShare, Debug, Document, Download, Edit, Email, Events, FlowModeler, InProgress,
+ Locked, Renew, Reset, Save, Send, Time, Tools, TrashCan, UserRole, WarningAlt,
} from '@carbon/icons-react';
import {
askStream, getKnowledgeCapabilities,
@@ -253,10 +254,15 @@ export function AiChatPage() {
downloadTextFile(`nodepilot-ai-chat-${slug}-${date}.md`, md);
}, [messages, threads, threadId, t]);
- const examples = useMemo(
- () => (t('ai:knowledge.examples', { returnObjects: true }) as string[]) ?? [],
- [t],
- );
+ // Starter prompts follow the enabled sources: the ops set leans on the DB/text2sql tools, which
+ // are off by default AND privileged-only — offering "show me the last 10 failed runs" to someone
+ // without that source just produces "source not available". Hold both back until `caps` resolves,
+ // otherwise the lite set flashes for a beat before swapping.
+ const examples = useMemo(() => {
+ if (!caps) return [];
+ const key = caps.db ? 'ai:knowledge.examples' : 'ai:knowledge.examplesLite';
+ return (t(key, { returnObjects: true }) as string[]) ?? [];
+ }, [t, caps]);
const lastIndex = messages.length - 1;
// Disabled state: capabilities loaded and the chat is off (Llm or AiKnowledge master off).
@@ -316,7 +322,12 @@ export function AiChatPage() {
{messages.length === 0 ? (
-
+
) : (
messages.map((m, i) => (
void;
t: (k: string) => string;
}>) {
@@ -425,7 +448,7 @@ function EmptyState({
{examples.length > 0 && (
{examples.map((ex, i) => {
- const Icon = EXAMPLE_ICONS[i] ?? Chat;
+ const Icon = icons[i] ?? Chat;
return (
)}
diff --git a/src/nodepilot-ui/src/types/api.ts b/src/nodepilot-ui/src/types/api.ts
index b9675310..7b214d5f 100644
--- a/src/nodepilot-ui/src/types/api.ts
+++ b/src/nodepilot-ui/src/types/api.ts
@@ -401,7 +401,10 @@ export interface OpsRunningExecution {
activeStepCount: number | null;
}
-/** Terminal execution completed within the recent window (30 min, newest 200). */
+/**
+ * Terminal execution completed within the requested window, newest first and capped. Runs past
+ * the cap are not dropped — they come back aggregated in `OpsDensityLane`.
+ */
export interface OpsRecentExecution {
executionId: string;
workflowId: string;
@@ -429,8 +432,35 @@ export interface OpsSnapshotMeta {
* stretch that was not lost.
*/
oldestReturnedCompletedAt: string | null;
- /** More settled runs existed in the window than the cap returns. */
+ /** More settled runs existed in the window than the cap returns — i.e. `density` is populated. */
recentTruncated: boolean;
+ /**
+ * Width of one density bucket. Bucket `i` spans
+ * `[recentSinceUtc + i·s, recentSinceUtc + (i+1)·s)`. Zero when no density was computed.
+ */
+ densityBucketSeconds: number;
+ /**
+ * The density scan hit its own row cap: the aggregate describes the newest N settled runs of
+ * the window rather than all of them. Distinct from `recentTruncated` — that one means "not
+ * every run is a bar", this one means "not every run is even counted".
+ */
+ densityCapped: boolean;
+}
+
+/** One time slice of one workflow's settled runs. Outcome is split, never reduced to a worst status. */
+export interface OpsDensityBucket {
+ /** Slice offset from `meta.recentSinceUtc`, in `meta.densityBucketSeconds` steps. */
+ bucketIndex: number;
+ total: number;
+ failed: number;
+ cancelled: number;
+}
+
+/** Bucketed settled-run counts for one workflow. Only present when `recent` was capped. */
+export interface OpsDensityLane {
+ workflowId: string;
+ /** Only buckets with at least one run, ascending by index. */
+ buckets: OpsDensityBucket[];
}
export interface OperationsGraph {
@@ -438,6 +468,11 @@ export interface OperationsGraph {
edges: OpsEdge[];
running: OpsRunningExecution[];
recent: OpsRecentExecution[];
+ /**
+ * Per-workflow run density over the window — populated only when `recent` was capped, so the
+ * stretch the bars cannot reach is drawn as an aggregate instead of an empty band.
+ */
+ density: OpsDensityLane[];
meta: OpsSnapshotMeta;
}
diff --git a/tests/NodePilot.Api.Tests/Controllers/OperationsControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/OperationsControllerTests.cs
index 5c3fd4d1..31aabe2d 100644
--- a/tests/NodePilot.Api.Tests/Controllers/OperationsControllerTests.cs
+++ b/tests/NodePilot.Api.Tests/Controllers/OperationsControllerTests.cs
@@ -498,19 +498,182 @@ public async Task GetGraph_MoreSettledRunsThanTheCap_FlagsTruncationAndCapsTheLi
var wf = Guid.NewGuid();
db.Workflows.Add(Wf(wf, "W", "{}"));
var now = DateTime.UtcNow;
- for (var i = 0; i < 505; i++)
+ for (var i = 0; i < 1005; i++)
db.WorkflowExecutions.Add(Settled(wf, now.AddSeconds(-i)));
await db.SaveChangesAsync();
var graph = await GetGraph(NewController(db));
- graph.Recent.Should().HaveCount(500);
+ graph.Recent.Should().HaveCount(1000);
graph.Meta.RecentTruncated.Should().BeTrue();
// The honest left edge sits INSIDE the window, right of what the caller asked for.
graph.Meta.OldestReturnedCompletedAt.Should().NotBeNull();
graph.Meta.OldestReturnedCompletedAt!.Value.Should().BeAfter(graph.Meta.RecentSinceUtc);
}
+ // ---- Density: the window covered where individual bars run out ---------------------------
+ //
+ // The defect these guard: at 1 h / 4 h a busy system blew past the raw cap after ~30 minutes,
+ // so every wider window showed the same newest half hour and an empty band for the rest. The
+ // window selector was decoration. Bars still cap — thousands of them cannot be re-sent every
+ // poll and re-positioned every tick — but what they cannot reach now comes back counted.
+
+ private static async Task SeedBusyWindow(
+ Guid wf, int count, TimeSpan spacing, params (int Index, ExecutionStatus Status)[] outcomes)
+ {
+ var db = TestDbFactory.Create();
+ db.Workflows.Add(Wf(wf, "W", "{}"));
+ var now = DateTime.UtcNow;
+ var overrides = outcomes.ToDictionary(o => o.Index, o => o.Status);
+ for (var i = 0; i < count; i++)
+ {
+ var run = Settled(wf, now - spacing * i);
+ if (overrides.TryGetValue(i, out var status)) run.Status = status;
+ db.WorkflowExecutions.Add(run);
+ }
+ await db.SaveChangesAsync();
+ return db;
+ }
+
+ [Fact]
+ public async Task GetGraph_UntruncatedWindow_ShipsNoDensityAtAll()
+ {
+ // A quiet system must pay nothing for this: no second query, no extra payload.
+ var db = TestDbFactory.Create();
+ var wf = Guid.NewGuid();
+ db.Workflows.Add(Wf(wf, "W", "{}"));
+ db.WorkflowExecutions.Add(Settled(wf, DateTime.UtcNow.AddMinutes(-5)));
+ await db.SaveChangesAsync();
+
+ var graph = await GetGraph(NewController(db));
+
+ graph.Density.Should().BeEmpty();
+ graph.Meta.DensityBucketSeconds.Should().Be(0);
+ graph.Meta.DensityCapped.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task GetGraph_TruncatedWindow_CountsEveryRunIncludingTheOnesPastTheBarCap()
+ {
+ // 1200 runs, 1000 of them bars — the density must account for all 1200, otherwise the
+ // console cannot answer "how much ran in this window?" at all.
+ var wf = Guid.NewGuid();
+ // 500 ms apart, so all 1200 comfortably fit the 20-minute window even if the request
+ // lands a moment after the seed — a 1 s spacing would push the oldest rows out of it.
+ var db = await SeedBusyWindow(wf, 1200, TimeSpan.FromMilliseconds(500),
+ (3, ExecutionStatus.Failed), (7, ExecutionStatus.Failed), (11, ExecutionStatus.Cancelled));
+
+ var graph = await GetGraph(NewController(db));
+
+ var lane = graph.Density.Should().ContainSingle().Subject;
+ lane.WorkflowId.Should().Be(wf);
+ lane.Buckets.Sum(b => b.Total).Should().Be(1200);
+ lane.Buckets.Sum(b => b.Failed).Should().Be(2);
+ lane.Buckets.Sum(b => b.Cancelled).Should().Be(1);
+ graph.Meta.DensityCapped.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(20, 25)]
+ [InlineData(60, 75)]
+ [InlineData(240, 300)]
+ public async Task GetGraph_DensityBucketWidth_ScalesWithTheWindow(int windowMinutes, int expectedSeconds)
+ {
+ // Fixed bucket COUNT, not fixed bucket width: that is what makes a wider window cost the
+ // console nothing extra, however many runs sit behind it.
+ var db = await SeedBusyWindow(Guid.NewGuid(), 1010, TimeSpan.FromSeconds(1));
+
+ var graph = await GetGraph(NewController(db), windowMinutes);
+
+ graph.Meta.DensityBucketSeconds.Should().Be(expectedSeconds);
+ graph.Density.Should().ContainSingle().Which.Buckets.Should().HaveCountLessThanOrEqualTo(48);
+ }
+
+ [Fact]
+ public async Task GetGraph_DensityBuckets_AreAscendingAndAnchoredOnRecentSince()
+ {
+ // Bucket 0 starts at RecentSinceUtc — the console turns an index straight back into a
+ // time range, so an off-by-one anchor would slide the whole history sideways.
+ var db = await SeedBusyWindow(Guid.NewGuid(), 1010, TimeSpan.FromSeconds(1));
+
+ var graph = await GetGraph(NewController(db), 240);
+
+ var buckets = graph.Density.Should().ContainSingle().Subject.Buckets;
+ buckets.Select(b => b.BucketIndex).Should().BeInAscendingOrder();
+ buckets.Should().OnlyContain(b => b.Total > 0);
+ // Everything was seeded within the last ~17 min of a 4 h window → the newest 5-min slices.
+ var lastIndex = (int)((DateTime.UtcNow - graph.Meta.RecentSinceUtc).TotalSeconds / graph.Meta.DensityBucketSeconds);
+ buckets.Should().OnlyContain(b => b.BucketIndex <= lastIndex && b.BucketIndex >= lastIndex - 5);
+ }
+
+ [Fact]
+ public async Task GetGraph_Density_IsFolderScopedLikeEverythingElse()
+ {
+ var db = TestDbFactory.Create();
+ SeedFolders(db);
+ var visible = Guid.NewGuid();
+ var hidden = Guid.NewGuid();
+ db.Workflows.AddRange(
+ Wf(visible, "Visible", "{}", folderId: FolderA),
+ Wf(hidden, "Hidden", "{}", folderId: FolderB));
+ // The visible folder alone has to blow past the cap: RBAC scoping runs BEFORE the cap, so
+ // the hidden folder's runs can neither trigger the truncation nor inflate the counts.
+ var now = DateTime.UtcNow;
+ for (var i = 0; i < 1100; i++)
+ {
+ db.WorkflowExecutions.Add(Settled(visible, now.AddMilliseconds(-500 * i)));
+ db.WorkflowExecutions.Add(Settled(hidden, now.AddMilliseconds(-500 * i)));
+ }
+ await db.SaveChangesAsync();
+
+ var scoped = new ScopedAuthz(new AccessibleFolderSet { IsUnrestricted = false, FolderIds = [FolderA] });
+ var graph = await GetGraph(NewController(db, scoped, role: "Operator"));
+
+ graph.Meta.RecentTruncated.Should().BeTrue();
+ var lane = graph.Density.Should().ContainSingle().Subject;
+ lane.WorkflowId.Should().Be(visible);
+ lane.Buckets.Sum(b => b.Total).Should().Be(1100); // the hidden folder's 1100 never counted
+ }
+
+ [Fact]
+ public async Task GetGraph_Density_SeparatesLanesPerWorkflow()
+ {
+ var db = TestDbFactory.Create();
+ var a = Guid.NewGuid();
+ var b = Guid.NewGuid();
+ db.Workflows.AddRange(Wf(a, "A", "{}"), Wf(b, "B", "{}"));
+ var now = DateTime.UtcNow;
+ for (var i = 0; i < 600; i++) db.WorkflowExecutions.Add(Settled(a, now.AddSeconds(-i)));
+ for (var i = 0; i < 500; i++) db.WorkflowExecutions.Add(Settled(b, now.AddSeconds(-i)));
+ await db.SaveChangesAsync();
+
+ var graph = await GetGraph(NewController(db));
+
+ graph.Density.Should().HaveCount(2);
+ graph.Density.Single(l => l.WorkflowId == a).Buckets.Sum(x => x.Total).Should().Be(600);
+ graph.Density.Single(l => l.WorkflowId == b).Buckets.Sum(x => x.Total).Should().Be(500);
+ }
+
+ [Fact]
+ public async Task GetGraph_Density_ExcludesActiveRunsJustLikeRecent()
+ {
+ // A Running row has no CompletedAt; counting it would inflate "what finished here".
+ var db = TestDbFactory.Create();
+ var wf = Guid.NewGuid();
+ db.Workflows.Add(Wf(wf, "W", "{}"));
+ var now = DateTime.UtcNow;
+ for (var i = 0; i < 1010; i++) db.WorkflowExecutions.Add(Settled(wf, now.AddSeconds(-i)));
+ db.WorkflowExecutions.Add(new WorkflowExecution
+ {
+ Id = Guid.NewGuid(), WorkflowId = wf, Status = ExecutionStatus.Running, StartedAt = now.AddMinutes(-2),
+ });
+ await db.SaveChangesAsync();
+
+ var graph = await GetGraph(NewController(db));
+
+ graph.Density.Should().ContainSingle().Which.Buckets.Sum(b => b.Total).Should().Be(1010);
+ }
+
// ---- Step activity on live runs ----------------------------------------------------------
private static StepExecution StepRow(Guid execId, string stepId, ExecutionStatus status,
diff --git a/tests/NodePilot.Cli.Tests/OpsObservabilityDbCommandTests.cs b/tests/NodePilot.Cli.Tests/OpsObservabilityDbCommandTests.cs
index 62826bac..ef405eec 100644
--- a/tests/NodePilot.Cli.Tests/OpsObservabilityDbCommandTests.cs
+++ b/tests/NodePilot.Cli.Tests/OpsObservabilityDbCommandTests.cs
@@ -43,11 +43,13 @@ public void OperationsGraph_Table_RendersNodesAndEdges()
},
running = Array.Empty