Eigentlich wollte ich für diese Ausgabe ja eine Terminal-UI schreiben, die anhand von Widgets mir wichtige Daten aus Systemstatus und Weltgeschehen anzeigt. Doch, Schockschwerenot, mit der August-Kolumne der Reihe "Tooltipps" im Linux-Magazin ([3]) fand ich heraus, dass es bereits ein (auch noch in Go geschriebenes!) Open-Source-Tool namens wtf
gibt ([2]), das das alles bereits seit langem beherrscht, und sich außerdem noch einfach mit neuen Widgets erweitern lässt. Bitteschön, dann springen wir halt als Trittbrettfahrer auf diesen Zug auf!
Damit wtf
(oder wtfutil
wie das Programm ursprünglich hieß) auf dem Heimcomputer seine Kacheln mit den verschiedenen Widgets in ein Terminalfenster malt wie in der Demo in Abbildung 1, muss der Admin erstens das kompilierte Go-Programm wtfutil
als wtf
in ein Bin-Verzeichnis verpflanzen und eine Yaml-Datei mit den einzelnen Wtf-Modulen in den verschiedenen Kacheln konfigurieren. Anschließend zaubert der Aufruf von wtf
von der Kommandozeile die frisch mit Inhalt gefüllten Kacheln ins gerade offene Terminal-Fenster.
Abbildung 1: Eine voll konfigurierte Installation des Terminal-Dashboards (Quelle: [2]) |
Die Installation auf verschiedensten Betriebssystemen steht auf [2] beschrieben, aber letztendlich genügt auf Linux ein git clone
des Repositories, und ein im entstehenden Unterverzeichnis ausgeführtes make build
, damit der Go-Compiler erst alle abhängigen Go-Libraries von Github einholt und anschließend die ganze Enchilada in ein Binary unter bin/wtfutil
verpackt (Abbildung 2). Wer übrigens denkt, go build
wäre eine gute Idee, wird kurz vor dem Ende der Kompilationsphase eines Besseren belehrt, denn Go würde das Ergebnis in einer Datei namens wtf
ablegen, doch unter diesem Namen steht im Repository bereits ein Verzeichnis. Das Makefile stellt hingegen sicher, dass das erzeugte Binary wtfutil
heißt und ohne Kollisionen im Verzeichnis bin
landet.
Abbildung 2: "make build" zieht halb Github als Source-Code heran. |
Nun kommt wtf
bereits mit einem prall gefüllten Werkzeuggürtel an vordefinierten Widgets daher, die es bei Bedarf nur noch zu aktivieren gilt. Zum Beispiel gefiel mir das Widget ipinfo
recht gut, denn oft kommt es vor, dass sich auf meinem Rechner durch allerlei VPN-Spielereien die offizielle IP-Adresse ändert, und es ist oft hilfreich, zu wissen, in welchem Land mich genutzte Internet-Dienste gerade wähnen. Die Yaml-Konfiguration in Listing 1 pflanzt das ipinfo
-Modul aufs Armaturenbrett in Abbildung 3. Der Yaml-Code aktiviert das wft
-intern definierte Modul ipinfo
unter der Modul-Sektion mods
, und zwar in der Anzeige links oben, also mit einem Row- und Column-Index von jeweils 0 und einer Höhe und Breite von jeweils 1. Größe und Position der Kacheln in wtf
bestimmen sich aus der globalen Kachelbreite und -höhe in der grid
-Sektion, gemessen in Terminal-Zeichen, und die Lage einer Kachel mit den von 0 ab laufenden Indexnummern für Zeile und Spalte. Wurde das Terminal anfangs in vier Spalten und zwei Zeilen unterteilt, adressierte top=0 left=0 nach dem Schema die Kachel links oben und top=1 left=3 die Kachel links unten. Kacheln können sich entsprechend ihrer Einstellungen für width
und height
nicht nur über eine Kachelspalte bzw. -zeile erstrecken, sondern auch mehr Platz belegen.
01 wtf: 02 colors: 03 background: black 04 border: 05 focusable: darkslateblue 06 focused: orange 07 normal: gray 08 grid: 09 columns: [32, 32, 32] 10 rows: [10, 10, 10] 11 refreshInterval: 1 12 mods: 13 ipinfo: 14 colors: 15 name: "lightblue" 16 value: "white" 17 enabled: true 18 position: 19 top: 0 20 left: 0 21 height: 1 22 width: 1 23 refreshInterval: 150
Abbildung 3: Das Standardmodul "ipinfo" zeigt die Geo-Location der aktuellen WAN-IP an. |
Abbildung 3 zeigt das Terminal nach dem Aufruf von wtf
mit der Konfigurationsdatei in Listing 1 in ~/.config/wtf/config.yml
. Die linke obere Kachel zeigt wie verlangt die gegenwärtig allokierte ipv4
-Adresse, sowie deren Geo-Location in meiner Wahlheimat San Francisco. Ein schönes, nützliches Standard-Widget, aber nun wird es Zeit, wtf
mit unseren eigenen Kreationen zu erweitern, auf zu neuen Schandtaten!
Abbildung 4: Von der Kommandozeile aus aufgerufen pflastert p0d das Terminal mit Ausgaben voll. |
Das nächste Widget misst die Geschwindigkeit, mit der mein Internetprovider die Daten über die Hausleitung rein- und rausschaufelt. Die verfügbare Bandbreite exakt in beiden Richtungen in MBit pro Sekunde zu messen, das hat sich das Tool p0d
([4]) auf die Fahnen geschrieben. Es lässt sich gemäß Readme von Github herunterladen, klonen und mit Go compilieren. Nach dem go install
-Kommando aus dem Readme liegt ein Binary p0d
unter dem lokalen Go-Pfad in ~/go/bin/p0d
, das der Admin für später in einen ausführbaren Pfad überführt. Von der Kommandozeile aus aufgerufen malt das Tool das Terminal mit wild fortschreitenden Zählern voll (Abbildung 4). Das Wrapper-Skript in Ruby aus Listing 2 ruft p0d
auf, fängt aber die Ausgabe ab und konzentriert sich nur auf die von p0d
wegen der Option -O
angelegte Json-Datei mit einigen Eckdaten zur Bandbreitenmessung.
01 #!/usr/bin/ruby 02 03 require 'open3' 04 require 'tempfile' 05 require 'json' 06 07 out = Tempfile.new('p0d') 08 09 stdin, stdout, stderr, wait_thr = 10 Open3.popen3("p0d", "-d", "3", "-O", out.path, "https://netflix.com") 11 stdin.close 12 13 if wait_thr.value.exitstatus != 0 14 puts stderr.read 15 exit 16 end 17 18 out.rewind 19 data = JSON.parse(out.read) 20 printf("Internet Speed:\n"); 21 os = data[0]["OS"] 22 printf("Download: %d mbits/sec\n", os['InetDlSpeedMBits'].to_i); 23 printf("Upload: %d mbits/sec\n", os['InetUlSpeedMBits'].to_i);
Die kürzeste erlaubte Laufzeit von p0d
scheint bei 3 Sekunden zu liegen (ohne Begrenzung sind es 10), also legt Zeile 10 in Listing 2 im dritten Parameter zu Rubys externem Kommandoausführer popen3()
aus dem Paket Open3
den Wert 3 fest. Die Ausgabe der Json-Eckdaten landet in der vorher in Zeile 7 angelegten Temp-Datei. Nach einer Fehlerprüfung spult das Ruby-Skript dann in Zeile 18 die Temp-Datei an den Anfang zurück und der Json-Parser analysiert in Zeile 19 die Daten mit parse()
. Im ersten Unter-Array (Index 0) unter dem Schlüssel "OS"
stehen dort die beiden gesuchten Mbit-Werte für Up- und Download-Geschwindigkeit, und zwar unter den Schlüsseln InetUlSpeedMBits
und InetDlSpeedMBits
, als Fließkommazahlen mit unsinnig vielen Nachkommastellen. Rubys String-Zu-Integer-Konverter to_i()
rundet sinngebend zur nächsten Ganzzahl.
01 p0d: 02 args: [""] 03 cmd: "p0d-runner" 04 colors: 05 name: "lightblue" 06 value: "white" 07 enabled: true 08 position: 09 top: 1 10 left: 0 11 height: 1 12 width: 1 13 refreshInterval: 600 14 type: "cmdrunner"
Abbildung 5: Mit dem Internet-Geschwindigkeitsmesser sind's schon zwei Widgets. |
Listing 3 fügt das Tool als Widget zur Wtf-Konfiguration in dessen config.yml
-Datei hinzu. Da wtf
von Haus aus p0d
nicht kennt, legt die Direktive type: "cmdrunner"
fest, dass das Widget vom Typ cmdrunner
ist, und ein Kommandozeilenargument samt Parametern entgegennimmt, das es ausführt, dessen Standardausgabe einsammelt und selbige als Widget-Inhalt im Dashboard anzeigt. Abbildung 4 zeigt das Widget in Aktion, unterhalb des eingangs beschriebenen IP-Widgets. Nun führt das Cockpit schon zwei nützliche Armaturen, Platz ist für mindestens vier weitere, was kommt nun?
Widgets im Wtf-Armaturenbrett zeigen aber nicht nur zeilenweise dynamisch eingeholte Daten an, sondern bieten es Power-Usern auch an, Zeilen aus dem Fensterinhalt auszuwählen und Aktionen auf die jeweils aktive Zeile einzuleiten.
Abbildung 6: Ein drittes Fenster zeigt die neuesten Programmier-Snapshots an. |
Das handgestrickte Widget rechts in Abbildung 6 ist so eines. Es holt von der sagenumwobenen Portalswebseite perlmeister.com
eine Liste mit den neuesten Ausgaben des Programmier-Snapshots ein, zeigt deren Titel mit dem Datum der Ausgabe an, und bringt sogar einen Web-Browser hoch, um ihren Inhalt auf der Webseite des Linux-Magazins anzuzeigen, falls der User im Widget sie auswählt. Was steckt hinter diesem Hexenwerk?
Um mit einem bestimmten Widget zu interagieren, tippt der User in der Terminal-UI in Abbildung 6 die neben der Überschrift angezeigte Ziffer ein (1, 2 oder 3), worauf die UI den Fokus auf das jeweilige Widget legt, und auf Druck der Tasten "k" und "j" im rechten Widget mit der grün unterlegten Auswahl rauf- und runterfährt, ganz wie im Editor "vi". Unsichtbar in den Tiefen des Go-Codes für die Erweiterung ist auch mit jedem Eintrag eine URL verbunden. Drückt der User "Enter", fährt das Widget einen Browser hoch und lädt den ausgewählten Artikel vom Netz (Abbildung 7).
Abbildung 7: Ausgewählte Linux-Artikel aus der Liste öffnen den Browser |
Derlei fortgeschrittene Funktionen erlaubt Wtf
nicht von Haus aus, aber mit etwas Go-Code lässt es sich erweitern. Wer das Github-Repository klont, kann den Code verändern und nach einer Neukompilation mit make build
stehen neue Widgets wie das mit den Listings 5-8 neugeschaffene snapshot
-Widget bereit. Das neue Binary versteht anschließend den Widget-Typ snapshot
, der sich wie in Listing 4 gezeigt in die Yaml-Konfiguration einbinden lässt.
01 snapshot: 02 enabled: true 03 colors: 04 rows: 05 even: "black" 06 odd: "black" 07 position: 08 top: 0 09 left: 1 10 height: 2 11 width: 2 12 refreshInterval: 86400
Dazu muss Listing 5 im Wtf-Sourcecode in der Datei widget_maker.go
erst einmal das neu geschaffene Wtf-Modul snapshot
einbinden. Dies passiert dort einmal über eine neue import
-Anweisung, die den neuen Code aus Listing 6 hereinzieht, und dann über eine zusätzliche case
-Anweisung, die bei der Initialisierung des Programms die Funktionen NewSettings()
sowie NewWidget()
aus dem neuen Go-Paket snapshot
aufruft. Was dabei hinter den Kulissen abläuft, zeigt Listing 6, das, ebenso wie seine Kollegen der Listings 7-8 vor der Neukompilation ins Verzeichnis modules/snapshot
des Open-Source-Projekts kopiert wird.
01 package app 02 03 import ( 04 //... 05 "github.com/wtfutil/wtf/modules/snapshot" 06 // ... 07 ) 08 09 // MakeWidget creates and returns instances of widgets 10 func MakeWidget( 11 // ... 12 switch moduleConfig.UString("type", moduleName) { 13 case "snapshot": 14 // ... 15 settings := snapshot.NewSettingsFromYAML(moduleName, moduleConfig, config) 16 widget = snapshot.NewWidget(tviewApp, redrawChan, pages, settings) 17 // ... 18 } 19 20 return widget 21 }
Das neue snapshot
Widget rechts in Abbildung 6 ist leitet sich ab vom Basistyp view.ScrollableWidget
, ganz wie von Listing 6 in Zeile 13 festgelegt. Damit ist sichergestellt, dass der User den Inhalt des Widgets anfahren und durchblättern kann. Listing 7 initialisiert das neue Widget mit den Yaml-Konfigurationsdaten, und die snapshot
-spezifische Struktur Widget
in Zeile 12 enthält danach eventuelle zusätzliche Yaml-Daten, die in diesem Fall gar nicht vorkommen, da das Widget keine zusätzliche Konfiguration benötigt. Zuzätzlich zu den Yaml-Daten, führt die Struktur Widget
in Zeile 12 in Listing 6 aber noch interne Daten in Form der vom Netz geholten Snapshot-Artikeln, mit ihren Überschriften und URL-Pfaden auf der Seite des Linux-Magazins. Die entsprechende Struktur Link
definiert später erst Listing 8 ab Zeile 13.
01 package snapshot 02 03 import ( 04 "fmt" 05 06 "github.com/gdamore/tcell/v2" 07 "github.com/rivo/tview" 08 "github.com/wtfutil/wtf/utils" 09 "github.com/wtfutil/wtf/view" 10 ) 11 12 type Widget struct { 13 view.ScrollableWidget 14 settings *Settings 15 err error 16 links []Link 17 } 18 19 func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { 20 widget := &Widget{ 21 ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), 22 23 settings: settings, 24 } 25 26 widget.SetRenderFunction(widget.Render) 27 widget.InitializeRefreshKeyboardControl(widget.Refresh) 28 widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) 29 30 widget.SetKeyboardChar("j", widget.Next, "Select next item") 31 widget.SetKeyboardChar("k", widget.Prev, "Select previous item") 32 widget.SetKeyboardKey(tcell.KeyEnter, widget.openLink, "Open story in browser") 33 34 return widget 35 } 36 37 func (widget *Widget) Refresh() { 38 links, err := scrapeLinks() 39 widget.err = err 40 widget.links = links 41 widget.SetItemCount(len(widget.links)) 42 widget.Render() 43 } 44 45 func (widget *Widget) Render() { 46 widget.Redraw(widget.content) 47 } 48 49 func (widget *Widget) content() (string, string, bool) { 50 title := "Programmier-Snapshot" 51 content := "" 52 53 for idx, link := range widget.links { 54 row := fmt.Sprintf(`[%s]%2d. %s`, 55 widget.RowColor(idx), idx+1, 56 tview.Escape(link.title), 57 ) 58 content += utils.HighlightableHelper(widget.View, row, idx, len(link.title)) 59 } 60 61 return title, content, false 62 } 63 64 func (widget *Widget) openLink() { 65 sel := widget.GetSelected() 66 if sel >= 0 && widget.links != nil && sel < len(widget.links) { 67 url := widget.links[sel].url 68 utils.OpenFile(url) 69 } 70 }
Das neue snapshot
-Widget im Wtf
-Universum erzeugt die Funktion NewWidget()
in Listing 6 ab Zeile 19. Sie füllt die Struktur Widget
mit dem Nötigsten und registriert das Widget mit dem Renderer, der es später in die Terminal-UI malt. Dass der im Widget aktuelle ausgewählte Eintrag mit den Tasten "k"
und "j"
auf- und abwandert und die Return-Taste den selektierten Eintag auswählt (samt der voreingestellte Browser-Aktion auf die hinterlegte URL), liegt an den Zeilen 30-33, die dies mit Keyboard-Funktionen festlegen.
Die Funktion Refresh()
ab Zeile 37 läuft immer dann an, wenn die Terminal-UI das Widget neu zeichnet. Mit scrapeLinks()
in Zeile 38 holt sie, wie weiter unten in Listing 8 ausgeführt, die Links aktueller und vergangener Programmier-Snapshots von der Perlmeister-Website und brezelt sie auf, um sie in einem kompakten Format anzuzeigen und zur Auswahl anzubieten.
Auf ein Render()
-Kommando bringt die UI den aktuellen Inhalt des Snapshot-Widgets auf den Schirm. Diesen klaubt die Funktion content()
ab Zeile 49 zusammen. Sie windet sich durch die in der Instanzvariablen links
hinterlegten Snapshot-Artikel, fügt sie der Reihe nach und farblich untermalt in die Zeilen des Widgets ein.
Was passiert, wenn der User nach der Auswahl eines Snapshot-Artikels die Return
-Taste drückt, legen die Zeilen 32 und die Funktion openLink()
ab Zeile 64 in Listing 6 fest. Mit der Indexnummer des selektierten Eintrags in sel
holt Zeile 67 die zum Eintrag passende und in der Datenstruktur hinterlegte URL an und öffnet diese mit utils.OpenFile()
, was wiederum den Standarbrowser hochfährt und ihn den Inhalt der Artikelseite auf der Website des Linux-Magazins anzeigen lässt.
01 package snapshot 02 03 import ( 04 "github.com/olebedev/config" 05 "github.com/wtfutil/wtf/cfg" 06 ) 07 08 const ( 09 defaultFocusable = true 10 ) 11 12 // Settings contains the settings for the snapshot view 13 type Settings struct { 14 *cfg.Common 15 } 16 17 // NewSettingsFromYAML creates the settings for this module from a yaml file 18 func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { 19 snapshot := ymlConfig.UString("snapshot") 20 settings := Settings{ 21 Common: cfg.NewCommonSettingsFromModule(name, snapshot, defaultFocusable, ymlConfig, globalConfig), 22 } 23 24 return &settings 25 }
In den Yaml-Einstellungen der Konfigurationsdatei passiert bei einem Snapshot-Widget nichts Berauschendes, lediglich die Standardfloskeln werden abgearbeitet aber eigene Parameter hat das Widget nicht, was zur Folge hat, dass Listing 7 nur Boilerplate-Code enthält.
Wie weiß das Widget nun, welche Snapshot-Artikel überhaupt auf der Webseite des Linux-Magazins vorliegen? Der Datengreifer in Listing 8 durchforstet die komplette Liste aller in den letzten 25 Jahren veröffentlichten Programmier-Snapshot-Artikel auf der Website perlmeister.com
. Mit dem einfachen HTML der unter der URL in Zeile 21 publizierten Artikel-Links hat der Go-Scraper goquery
leichtes Spiel. Dessen Funktion Find()
geht ab Zeile 39 durch alle Links im Webdokument art_ger.html
und behält nur die im Auge, die den String ausgabe
im Pfad haben. Das sind typischerweise Links zu Snapshot-Artikeln auf der Seite des Linux-Magazins.
01 package snapshot 02 03 import ( 04 "errors" 05 "fmt" 06 "net/http" 07 "regexp" 08 "strings" 09 10 "github.com/PuerkitoBio/goquery" 11 ) 12 13 type Link struct { 14 title string 15 url string 16 } 17 18 func scrapeLinks() ([]Link, error) { 19 links := []Link{} 20 21 res, err := http.Get("https://perlmeister.com/art_ger.html") 22 if err != nil { 23 return links, err 24 } 25 defer res.Body.Close() 26 if res.StatusCode != 200 { 27 return links, errors.New("Fetch failed") 28 } 29 30 doc, err := goquery.NewDocumentFromReader(res.Body) 31 if err != nil { 32 return links, err 33 } 34 35 var maxHits = 5 36 37 daterx := regexp.MustCompile(`\d{4}/\d{2}`) 38 39 doc.Find("a").Each(func(i int, s *goquery.Selection) { 40 if maxHits > 0 { 41 link, _ := s.Attr("href") 42 if strings.Contains(link, "ausgaben") { 43 rs := daterx.FindStringSubmatch(link) 44 title := fmt.Sprintf("%s (%s)", s.Text(), rs[0]) 45 links = append(links, Link{title: title, url: link}) 46 maxHits-- 47 } 48 } 49 }) 50 return links, nil 51 }
Bis zu einer Maximalzahl von 5 Artikeln, gemäß dem in der Variablen maxHist
definierten Wert, sammelt die Funktion deren URLs ein, extrahiert aus deren Pfad Monat und Jahr der Ausgabe, und hängt sie an den Array von Linkstrukturen in links
an. Jeder Eintrag enthält unter dem Feld title
die in der Auswahl anzuzeigende Überschrift sowie unter url
den entsprechenden Link zum Artikel auf der Webseite des Linux-Magazins. Aus dieser Liste generiert der Code später mittels title
die aufgelisteten Artikel, und ein Druck auf die Return-Taste schnappt sich das Attribut url
des Eintrags und bringt den Original-Artikel im Browser hoch.
Fertig ist der Lack! Offensichtlich gibt es weit mehr Möglichkeiten, dem Tool wtf
neue Tricks beizubringen. Wie immer sind der Fantasie kreativer Programmierer keine Grenzen gesetzt!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/10/snapshot/
Das erweiterbare Terminal-UI-Tool WTF: https://github.com/wtfutil/wtf
Uwe Vollbracht, "Tooltipps", Wtf, Linux-Magazin 2022/08, S. 46
Das Tool p0d, das die Internet-Bandbreite misst: https://github.com/simonmittag/p0d
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc