Sankey-Visualisierung mit Vega in Kibana 6.2
In Fortsetzung unserer Serie über die Erstellung benutzerdefinierter Vega-Graphen in Kibana ist das heutige Thema ein einfacher zweistufiger Sankey-Graph zur Darstellung von Mustern im Netzwerkverkehr. (Beim letzten Mal haben wir benutzerdefinierte Vega-Visualisierungen in Kibana besprochen.) Ein Sankey-Diagramm ist eine Art Ablaufdiagramm, bei dem die Breite der Linien proportional zur Flussmenge dargestellt wird. Jeder Eintrag in den Beispieldaten enthält einen Herkunfts- und einen Zielländercode. Der Graph verfügt über zwei Modi: all-to-all (Standard); zudem können Nutzer entweder das Herkunfts- oder das Zielland auswählen und nur den zugehörigen Netzwerkverkehr anzeigen.


Voraussetzungen
- Elasticsearch 6.2 oder neuer herunterladen
bin/elasticsearchentpacken und ausführen- Kibana 6.2 oder neuer herunterladen*(gleiche Version wie Elasticsearch)*
- Entpacken und führen Sie
bin/kibanaaus - Verwenden Sie das makelogs-Dienstprogramm, um Beispieldaten zu generieren. Installieren Sie es mit
npm install -g makelogsund führen Sie es mitmakelogsaus. Möglicherweise möchten Sie einen größeren Datensatz mit einem-c 100k-Parameter generieren. Dies setzt voraus, dass Sie bereits NPM installiert haben. Führen Sie dies nicht auf einem Produktions-Cluster aus! - Navigieren Sie zu http://localhost:5601/
- Klicken Sie auf den Tab Management (letztes Symbol links), erstellen Sie ein neues Indexmuster: Geben Sie
logstash-*für das Indexmuster ein, klicken Sie auf Next step, wählen Sie@timestampfür den Zeitfilter und klicken Sie auf Create index pattern. - Testen Sie, ob alles funktioniert, indem Sie den Tab Discover verwenden, den Zeitfilter in der oberen rechten Ecke auf die letzte Stunde einstellen und Observe die zufällig generierten Daten.
Daten
Für dieses Beispiel verwende ich geo.src (als erster Stack) und geo.dest (als zweiter Stack) Felder aus den zufälligen Daten, die mit dem makelogs-Dienstprogramm generiert wurden. Die Felder stellen die Quelle und das Ziel des Datenverkehrs dar. Diese Abfrage würde mithilfe der Elasticsearch-Composite-Aggregation nach beiden Feldern aggregieren, Zählen der Anzahl der Dokumente für jede Kombination und Rückgabe der ersten 10.
GET logstash-*/_search
{
"size":0,
"aggs": {
"table": {
"composite": {
"size": 10,
"sources": [
{"stk1": {"terms": {"Feld": "geo.src"}}},
{"stk2": {"terms": {"Feld": "geo.dest"}}}
]
}
}
}
}
Versuchen Sie, diese Abfrage im Tab Dev Tools auszuführen – Sie sollten in etwa Folgendes sehen:
{
// ... einige Metainformationen überspringen ...
"aggregations": {
"table": {
"buckets": [
{
"key": {
"stk1": "AE",
"stk2": "AR"
},
"doc_count": 1
},
{
"key": {
"stk1": "AE",
"stk2": "BD"
},
"doc_count": 2
},
// ... 8 weitere Objekte
]
}
}
Visualisierungsstrategie
Man kann sich den Sankey-Graph so vorstellen, dass die Nodes in Stacks organisiert sind und Kanten die Nodes verbinden. Für diesen Graph haben wir nur zwei Stacks: die Quelle und das Ziel. Die beiden Stacks werden als vertikale Balkendiagramme dargestellt, eines auf jeder Seite des Bildschirms. Jeder Stack enthält Länder, die übereinander gestapelt sind. Jedes Land besteht aus Nodes – Teilen des Quelllandes, die mit den Nodes im Zielland verbunden sind.

Wir benötigen mehrere Datentabellen, um das obige Graph-Diagramm zu zeichnen:
- nodes Diese Tabelle wird nicht direkt zum Zeichnen verwendet, sondern dient als Datenquelle für die anderen Tabellen. Wir können Knoten im obigen Bild als Blackboxen visualisieren – wir haben hier 12 davon. Jeder Node besteht aus der Dokumentenanzahl, die er repräsentiert, der Stack-ID, dem Ländercode und der Position innerhalb des Stacks (
y0bisy1). Beachten Sie, dass diese Tabelle keine „Bildschirm“-Koordinaten enthält, sondern nur die Quelldaten. Sogar die Stack-Position wird in Form von Dokumentenanzahlen ausgedrückt. Der erste Node würde0..n1-1umfassen, der zweiten1..n2-1usw. Da wir zudem zwei Stacks haben, gäbe es jeweils zwei der ersten, zweiten, ... Knoten, einen für jeden Stack. - Kanten Eine Liste von Liniendiagrammen (in diesem Fall 6), die jeweils einen Node auf der linken und der rechten Seite verbinden. Das Liniendiagramm benötigt ein Paar (x, y)-Koordinaten, eine Liniendicke (strikeWidth) und eine Linienfarbe (dieselbe wie beim Node). Diese Tabelle wird generiert, indem alle
stack=="stk1"-Zeilen aus der Node-Tabelle übernommen und der entsprechende Node für den Ziel-Stack nachgeschlagen wird. Eine Linkpath-Transformation generiert die SVG-Pfad-Zeichenfolge, die die gekrümmte Linie beschreibt. - groups Jede Gruppe kombiniert alle Nodes für dasselbe Land für denselben Stack. In diesem Graph haben wir 6 Gruppen, 3 auf jeder Seite. Jede Gruppe muss eine Stack-ID (stk1 oder stk2), den Start- und End-Y-Wert (y0 und y1, ähnlich wie in der Node-Tabelle) sowie den Ländercode enthalten. Diese Tabelle wird ebenfalls aus der Nodes-Tabelle generiert, gruppiert nach Stack-ID und Ländercode, und ähnlich wie die Nodes gestapelt. Die Y-Werte richten sich nach der Nodes-Tabelle, da wir beide Tabellen nach denselben Werten sortieren.
Zusätzlich zu den Tabellen verwende ich drei Skalen, um vom „Datenkoordinatenraum“ in den „Bildschirmkoordinatenraum“ zu projizieren. Die „x“-Skala bestimmt die Position der Stacks stk1 und stk2 auf dem Bildschirm. Die „y“-Skala ordnet die Diagrammhöhe der Gesamthöhe aller Nodes im höchsten Stack zu (in unserem Fall haben beide Stacks die gleiche Höhe). Zuletzt weist das „color“-Skalieren jedem Land eine Farbe zu.
Der letzte Schritt besteht darin, Datentabellen mithilfe von drei Markierungen in Visualisierungen umzuwandeln: die „path“-Markierung für Kanten, die „rect“-Markierung für die Stacks und die Textmarkierung für Länderbezeichnungen.
Interaktionen
An dieser Stelle sehen Sie den erwarteten Graph, aber oft möchten Sie vielleicht definieren, wie Nutzer mit dem Graph interagieren können. Dies geschieht mit Signalen – dynamischen Variablen, die ihren Wert basierend auf Ereignissen ändern und in verschiedenen Ausdrücken verwendet werden können.
Wenn ein Nutzer im Quellstapel auf eine Ländergruppe klickt, muss der Graph alle Daten ausblenden, die nicht aus dem angeklickten Land stammen. Ebenso sollte es funktionieren, wenn auf die Zielseite geklickt wird. Dies erfolgt durch die Definition eines groupSelector-Signals. Wenn auf groupMark geklickt wird, wird das Signal auf den Ländercode als Quelle oder Ziel gesetzt. Die Nodes-Tabelle wird automatisch basierend auf dem Signalwert gefiltert und der Graph wird so neu gezeichnet, als ob keine anderen Daten existieren. Um zurückzugehen, zeige ich ein rechteckiges Feld in der Mitte mit einer Textmarkierung. Durch Klicken auf das Feld wird das Signal zurückgesetzt, wodurch die Nodes-Tabelle auf die vollständige Liste wiederhergestellt wird.
Eine weitere Interaktion ist „mouseover“ bei Ländern in den Stacks. Beim groupMark-Mouseover-Ereignis wird das groupHover-Signal ähnlich wie der groupSelector gesetzt. Das Signal wird zur Steuerung der Transparenz der Kantenlinien verwendet.
Debugging und Erkundung
Das Verständnis von Vega-Code kann manchmal eine Herausforderung sein. Glücklicherweise können Sie jederzeit mithilfe von Browser-Debugging-Tools auf den internen Status von Vega zugreifen. Verwenden Sie in Firefox und Chrome Control+Shift+I (Windows, Linux) oder Command+Option+I (Mac). Verwenden Sie die Konsole, um den Tabelleninhalt anzuzeigen, z. B. console.table(VEGA_DEBUG.view.Daten('edges')) oder den Status eines Signals mit VEGA_DEBUG.view.signal('height'). Sie können sogar interne Werte zum Skalieren mit VEGA_DEBUG.view._runtime.scales.y anzeigen.
Vega-Code
Erläuterungen zu den einzelnen Zeilen finden Sie in den Code-Kommentaren oder in der Vega-Dokumentation. Beachten Sie, dass dieser Code HJSON verwendet, eine besser lesbare Form von JSON.
{
$schema: https://vega.github.io/schema/vega/v3.0.json
Daten: [
{
// ES basierend auf dem aktuell ausgewählten Zeitbereich und der Filterzeichenfolge abfragen
name: rawData
URL: {
%context%: wahr
%timefield%: @timestamp
index: logstash-*
body: {
size: 0
aggs: {
Tabelle: {
zusammengesetzt: {
Größe: 10000
Quellen: [
{
stk1: {
Begriffe: {field: "geo.src"}
}
}
{
stk2: {
Begriffe: {field: "geo.dest"}
}
}
]
}
}
}
}
}
// Übernehmen Sie aus dem Ergebnis nur die Daten, an denen wir interessiert sind
Format: {property: "aggregations.table.buckets"}
// Konvertieren von key.stk1 -> stk1 für einfacheren Zugriff weiter unten
transform: [
{type: "formula", expr: "datum.key.stk1", as: "stk1"}
{type: "formula", expr: "datum.key.stk2", as: "stk2"}
{type: "formula", expr: "datum.doc_count", as: "size"}
]
}
{
Name: Nodes
Quelle: rawData
transform: [
// wenn ein Land ausgewählt ist, nicht zugehörige Daten herausfiltern
{
Typ: Filter
expr: !groupSelector || groupSelector.stk1 == datum.stk1 || groupSelector.stk2 == datum.stk2
}
// Neuen Schlüssel für spätere Lookups festlegen – identifiziert jeden Node
{type: "formula", expr: "datum.stk1+datum.stk2", as: "key"}
// erstellen Sie anstelle jeder Tabellenzeile zwei neue Zeilen,
// einer für die Quelle (stack=stk1) und einer für den Ziel-Node (stack=stk2).
// Der in den Feldern stk1 und stk2 gespeicherte Ländercode wird in das Feld grpId eingefügt.
{
Typ: Faltung
Felder: ["stk1", "stk2"]
as: ["stack", "grpId"]
}
// Erstellen Sie einen Sortierschlüssel, der für stk1- und stk2-Stacks unterschiedlich ist.
// Der Leerzeichen-Separator stellt in einigen Sonderfällen die korrekte Sortierreihenfolge sicher.
{
Typ: Formel
expr: datum.stack == 'stk1' ? datum.stk1+' '+datum.stk2 : datum.stk2+' '+datum.stk1
als: sortField
}
// Berechnen Sie die y0- und y1-Positionen für das Stapeln von Nodes übereinander,
// unabhängig für jeden Stack und unter Sicherstellung der korrekten Reihenfolge,
// alphabetisch von oben (auf der y-Achse umgekehrt)
{
Typ: Stack
groupby: ["stack"]
Sortierung: {field: "sortField", order: "descending"}
Feld: Größe
}
// vertikalen Mittelpunkt für jeden Node berechnen, der zum Zeichnen von Kanten verwendet wird
{type: "formula", expr: "(datum.y0+datum.y1)/2", as: "yc"}
]
}
{
name: groups
Quelle: Knoten
transform: [
// alle Nodes in Ländergruppen zusammenfassen und die Anzahl der Dokumente summieren
{
type: aggregate
groupby: ["stack", "grpId"]
Felder: ["size"]
ops: [„sum“]
as: ["total"]
}
// die Stacking-Werte y0,y1 neu berechnen
{
Typ: Stack
groupby: ["stack"]
Sortierung: {field: "grpId", order: "descending"}
Feld: total
}
// y0- und y1-Werte auf Bildschirmkoordinaten projizieren
// dies hier einmal ausführen, anstatt es mehrmals in den Marks zu tun
{type: "formula", expr: "scale('y', datum.y0)", as: "scaledY0"}
{type: "formula", expr: "scale('y', datum.y1)", as: "scaledY1"}
// Boolesches Flag, ob das Label rechts vom Stack platziert werden soll
{type: "formula", expr: "datum.stack == 'stk1'", as: "rightLabel"}
// Berechnung des Traffic-Prozentsatzes für dieses Land unter Verwendung der „y“-Skalierung
// Domain-Obergrenze, die den gesamten Datenverkehr darstellt
{
Typ: Formel
expr: datum.total/domain('y')[1]
als: Prozentsatz
}
]
}
{
// Dies ist eine temporäre Nachschlagetabelle mit allen 'stk2'-Stack-Nodes
name: destinationNodes
Quelle: Knoten
transform: [
{type: "filter", expr: "datum.stack == 'stk2'"}
]
}
{
Name: Edges
Quelle: Knoten
transform: [
// wir möchten nur Knoten aus dem linken Stack
{type: "filter", expr: "datum.stack == 'stk1'"}
// entsprechenden Node aus dem rechten Stack finden, als "target" beibehalten
{
type: lookup
von: destinationNodes
Schlüssel: Schlüssel
Felder: ["key"]
as: ["target"]
}
// SVG-Linkpfad zwischen stk1- und stk2-Stacks für das Node-Paar berechnen
{
Typ: linkpath
Ausrichtung: horizontal
shape: diagonal
sourceY: {expr: "scale('y', datum.yc)"}
sourceX: {expr: "scale('x', 'stk1') + bandwidth('x')"}
targetY: {expr: "scale('y', datum.target.yc)"}
targetX: {expr: "scale('x', 'stk2')"}
}
// Ein kleiner Trick zur Berechnung der Liniendicke.
// Der Wert muss derselbe sein wie die Höhe des Nodes, aber skalierend
// Die Größe zur Bildschirmhöhe ergibt einen umgekehrten Wert, da die Y-Achse des Bildschirms
// Koordinate verläuft von oben nach unten, während Y=0 des Graphen
// befindet sich unten. Durch Subtraktion der skalierten Dokumentenanzahl von der Bildschirmhöhe
// (was die „untere“ Grenze der „y“-Skalieren ist) liefert uns den richtigen Wert
{
Typ: Formel
expr: range('y')[0]-skalieren('y', datum.size)
als: strokeWidth
}
// Tooltip benötigt den prozentualen Anteil des einzelnen Links am gesamten Traffic
{
Typ: Formel
expr: datum.size/domain('y')[1]
als: Prozentsatz
}
]
}
]
skalieren: [
{
// berechnet die horizontale Stapelpositionierung
Name: x
Typ: Band
Bereich: Breite
domain: ["stk1", "stk2"]
paddingOuter: 0,05
paddingInner: 0,95
}
{
// diese Skalierung reicht bis zum höchsten y1-Wert aller Knoten
name: y
Typ: linear
Bereich: Höhe
Domain: {data: "nodes", field: "y1"}
}
{
// Verwenden Sie rawData, um sicherzustellen, dass die Farben beim Klicken gleich bleiben.
Name: Farbe
Typ: Ordinalzahl
Bereich: Kategorie
Domain: {data: "rawData", fields: ["stk1", "stk2"]}
}
{
// dieses Skalieren wird verwendet, um interne IDs (stk1, stk2) Maps als Stack-Namen zuzuordnen
name: stackNames
Typ: Ordinalzahl
range: ["Source", "Destination"]
domain: ["stk1", "stk2"]
}
]
axes: [
{
// Die X-Achse sollte eine benutzerdefinierte Beschriftungsformatierung verwenden, um die korrekten Stack-Namen auszugeben
Ausrichtung: unten
skalieren: x
codieren: {
Labels: {
Update: {
Text: {scale: "stackNames", field: "value"}
}
}
}
}
{orient: "left", scale: "y"}
]
marks: [
{
// Verbindungsliniendiagramm zwischen Stacks zeichnen
Typ: Pfad
name: edgeMark
von: {data: "edges"}
// dies verhindert einige Probleme bei der automatischen Größenanpassung mit großer strokeWidth für Pfade
clip: true
codieren: {
Update: {
// Standardmäßig die Farbe des linken Node verwenden, außer bei der Anzeige von Traffic
// aus nur einem Land; in diesem Fall die Zielfarbe verwenden.
stroke: [
{
test: groupSelector && groupSelector.stack=='stk1'
skalieren: Farbe
Feld: stk2
}
{scale: "color", field: "stk1"}
]
strokeWidth: {field: "strokeWidth"}
Pfad: {field: "path"}
// wenn der gesamte Datenverkehr angezeigt wird und der Mauszeiger über ein Land bewegt wird,
// markieren Sie den Datenverkehr aus diesem Land.
strokeOpacity: {
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 0,9 : 0,3
}
// Sicherstellen, dass die bei Hover ausgewählten Kanten oben angezeigt werden
zindex: {
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 1 : 0
}
// Format-Zeichenfolge für Tooltip
Tooltip: {
signal: datum.stk1 + ' → ' + datum.stk2 + ' ' + format(datum.size, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'
}
}
// Einfache Mouseover-Hervorhebung einer einzelnen Linie
hover: {
strokeOpacity: {value: 1}
}
}
}
{
// Stack-Gruppen (Länder) zeichnen
type: rect
name: groupMark
von: {data: "groups"}
codieren: {
eingeben: {
fill: {scale: "color", field: "grpId"}
Breite: {scale: "x", band: 1}
}
Update: {
x: {scale: "x", field: "stack"}
y: {field: "scaledY0"}
y2: {field: "scaledY1"}
fillOpacity: {value: 0.6}
Tooltip: {
signal: datum.grpId + ' ' + format(datum.total, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'
}
}
hover: {
fillOpacity: {value: 1}
}
}
}
{
// Ländercode-Labels auf der Innenseite des Stacks zeichnen
Typ: Text
von: {data: "groups"}
// Ereignisse für die Beschriftungen nicht verarbeiten – andernfalls ist das Mouseover für das Liniendiagramm unsauber
interaktiv: falsch
codieren: {
Update: {
// je nachdem, um welchen Stack es sich handelt, positionieren Sie x mit etwas Padding
x: {
signal: skalieren('x', datum.stack) + (datum.rightLabel ? bandwidth('x') + 8 : -8)
}
// Mitte der Gruppe
yc: {signal: "(datum.scaledY0 + datum.scaledY1)/2"}
ausrichten: {signal: "datum.rightLabel ? 'left' : 'right'"}
Ausgangswert: {value: "middle"}
Schriftgewicht: {value: "bold"}
// Text-Label nur anzeigen, wenn die Höhe der Gruppe ausreichend groß ist
Text: {signal: "abs(datum.scaledY0-datum.scaledY1) > 13 ? datum.grpId : ''"}
}
}
}
{
// Erstellen Sie eine „Alle anzeigen“-Schaltfläche. Wird nur angezeigt, wenn ein Land ausgewählt ist.
Typ: Gruppe
Daten: [
// Wir müssen dafür sorgen, dass die Schaltfläche nur angezeigt wird, wenn das groupSelector-Signal true ist.
// Jede Markierung wird so oft gezeichnet, wie Elemente in den zugrunde liegenden Daten vorhanden sind.
// Das bedeutet, dass die Liste der Werte nicht gezeichnet wird, wenn sie leer ist.
// Hier erstelle ich eine Datenquelle mit einem leeren Objekt und filtere diese Liste
// basierend auf dem Signalwert. Dies kann nur in einer Gruppe erfolgen.
{
name: dataForShowAll
values: [{}]
transform: [{type: "filter", expr: "groupSelector"}]
}
]
// Schaltflächengröße und -positionierung festlegen
codieren: {
eingeben: {
xc: {signal: "width/2"}
y: {value: 30}
Breite: {value: 80}
height: {value: 30}
}
}
marks: [
{
// Diese Gruppe wird als Schaltfläche mit abgerundeten Ecken angezeigt.
Typ: Gruppe
// Der Markierungsname ermöglicht die Signalerfassung
Name: groupReset
// Zeigt die Schaltfläche nur an, wenn dataForShowAll Werte enthält.
von: {data: "dataForShowAll"}
codieren: {
eingeben: {
Eckenradius: {value: 6}
fill: {value: "#f5f5f5"}
stroke: {value: "#c1c1c1"}
strokeWidth: {value: 2}
// Größe der übergeordneten Gruppe verwenden
height: {
Feld: {group: "height"}
}
width: {
Feld: {group: "width"}
}
}
Update: {
// Gruppen sind standardmäßig transparent
Opazität: {value: 1}
}
hover: {
Opazität: {value: 0.7}
}
}
marks: [
{
Typ: Text
// Wenn „true“, wird das Klicken auf die Schaltfläche verhindert, wenn sie sich über Text befindet.
interaktiv: falsch
codieren: {
eingeben: {
// Text in der Klammergruppe zentrieren
xc: {
Feld: {group: "width"}
mult: 0,5
}
yc: {
Feld: {group: "height"}
mult: 0,5
Offset: 2
}
ausrichten: {value: "center"}
Ausgangswert: {value: "middle"}
Schriftgewicht: {value: "bold"}
Text: {value: "Show All"}
}
}
}
]
}
]
}
]
Signale: [
{
// wird verwendet, um Datenverkehr in dasselbe/aus demselben Land hervorzuheben
name: groupHover
Wert: {}
ein: [
{
events: @groupMark:mouseover
update: „{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}“
}
{events: "mouseout", update: "{}"}
]
}
// wird verwendet, um nur die Daten zu filtern, die sich auf das ausgewählte Land beziehen
{
name: groupSelector
Wert: falsch
ein: [
{
// Durch Klicken auf groupMark wird dieses Signal auf die Filterwerte gesetzt
Events: @groupMark:click!
update: „{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}“
}
{
// Durch Klicken auf die Schaltfläche „show all“ oder durch Doppelklick an einer beliebigen Stelle wird dies zurückgesetzt
events: [
{type: "click", markname: "groupReset"}
{type: "dblclick"}
]
update: "false"
}
]
}
]
}
Hervorhebungskomponenten
Um Komponenten der Stacks hervorzuheben und das obige Erklärungsbild zu generieren, ersetzen Sie den „url“-Abschnitt durch diese hartcodierten Werte und fügen Sie nach allen anderen Markierungen eine zusätzliche Markierung hinzu.
Werte: {
Aggregationen: { table: { Buckets: [
{key: {stk1:"aa", stk2:"cc"}, doc_count: 7}
{key: {stk1:"aa", stk2:"bb"}, doc_count: 4}
{key: {stk1:"bb", stk2:"aa"}, doc_count: 8}
{key: {stk1:"bb", stk2:"bb"}, doc_count: 6}
{key: {stk1:"bb", stk2:"cc"}, doc_count: 3}
{key: {stk1:"cc", stk2:"aa"}, doc_count: 9}
]}}}
{
type: rect
von: {data: "nodes"}
codieren: {
eingeben: {
stroke: {value: "#000"}
strokeWidth: {value: 2}
Breite: {scale: "x", band: 1}
x: {scale: "x", field: "stack"}
y: {field: "y0", scale: "y"}
y2: {field: "y1", scale: "y"}
}
}
}
Nützliche Links:
- Kibana Vega-Dokumentation
- Vega-Dokumentation
- Vega-Beispiele
- Vega-Lite-Dokumentation
- Vega-Lite-Beispiele
Fragen und Kommentare
Um diesen Blogbeitrag zu diskutieren, nutzen Sie bitte unser Forum