Auf einen Blick (Linux-Magazin, November 2022)

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])

Compilier' und Installier'

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.

Mit Werkzeuggürtel

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.

Listing 1: ip.yml

    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.

Ein Skript, zwo, drei

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.

Listing 2: p0d-runner

    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.

Listing 3: p0d-part.yml

    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?

Handgestrickt

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.

Listing 4: snapshot-part.yml

    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

Aufgemotzt nach Maß

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.

Listing 5: widget_maker-part.go

    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.

Listing 6: widget.go

    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.

Listing 7: settings.go

    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.

Datenfieselei

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.

Listing 8: goquery.go

    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!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/10/snapshot/

[2]

Das erweiterbare Terminal-UI-Tool WTF: https://github.com/wtfutil/wtf

[3]

Uwe Vollbracht, "Tooltipps", Wtf, Linux-Magazin 2022/08, S. 46

[4]

Das Tool p0d, das die Internet-Bandbreite misst: https://github.com/simonmittag/p0d

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc