From 08156e7b52fe80c4fe2fdccdac1d2776dd4507b9 Mon Sep 17 00:00:00 2001 From: jandziaslo Date: Mon, 17 Nov 2025 16:34:49 +0100 Subject: [PATCH] =?UTF-8?q?wst=C4=99pna=20wersja=20wid=C5=BCetu=20tailscal?= =?UTF-8?q?e=20-=20ca=C5=82a=20funkcjonalno=C5=9B=C4=87=20wersji=20custom-?= =?UTF-8?q?api=20-=20mo=C5=BCliwo=C5=9B=C4=87=20kopiowania=20ipv4=20danego?= =?UTF-8?q?=20urz=C4=85dzenia=20planowane:=20-=20mo=C5=BCliwo=C5=9B=C4=87?= =?UTF-8?q?=20w=C5=82=C4=85czenia=20ssh=20tailscale=20-=20zmiana=20exit-no?= =?UTF-8?q?de=20-=20zmiana=20advertise-routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TAILSCALE_WIDGET.md | 112 +++++++++++++++++ internal/glance/templates/tailscale.html | 144 ++++++++++++++++++++++ internal/glance/widget-tailscale.go | 145 +++++++++++++++++++++++ internal/glance/widget.go | 2 + 4 files changed, 403 insertions(+) create mode 100644 TAILSCALE_WIDGET.md create mode 100644 internal/glance/templates/tailscale.html create mode 100644 internal/glance/widget-tailscale.go diff --git a/TAILSCALE_WIDGET.md b/TAILSCALE_WIDGET.md new file mode 100644 index 0000000..097f6ed --- /dev/null +++ b/TAILSCALE_WIDGET.md @@ -0,0 +1,112 @@ +# Natywny Widget Tailscale + +## Opis +Natywny widget Tailscale dla Glance oferujący większe możliwości i łatwiejszą konfigurację niż wersja custom-api. + +## Funkcje + +### Co oferuje natywny widget: +- ✅ Łatwiejsza konfiguracja (bez potrzeby pisania szablonów HTML) +- ✅ Automatyczne parsowanie danych z API Tailscale +- ✅ Zachowana kolorystyka z wersji custom-api +- ✅ Wskaźniki: + - Aktualizacji dostępnych (niebieski punkt) + - Status online/offline (zielony/czerwony punkt) + - Informacje o ostatniej aktywności +- ✅ Efekty hover pokazujące adres IP urządzenia +- ✅ **Łatwe kopiowanie IP jednym kliknięciem** (kliknij bezpośrednio na IP) +- ✅ Wizualny feedback przy kopiowaniu (tło zmienia się na zielone z ✓) +- ✅ Działa w HTTP i HTTPS (fallback dla starszych przeglądarek) +- ✅ Możliwość kontrolowania liczby widocznych urządzeń +- ✅ Opcjonalne pokazywanie wskaźnika "online" + +### Możliwości rozszerzenia w przyszłości: +- Zarządzanie urządzeniami +- Włączanie/wyłączanie tras +- Zarządzanie kluczami API +- Statystyki ruchu +- Powiadomienia o zmianach + +## Konfiguracja + +### Minimalna konfiguracja: +```yaml +- type: tailscale + token: your-tailscale-api-token +``` + +### Pełna konfiguracja: +```yaml +- type: tailscale + title: Tailscale # Opcjonalny, domyślnie "Tailscale" + title-url: https://login.tailscale.com/admin/machines # Opcjonalny + token: your-tailscale-api-token # Wymagany + tailnet: "-" # Opcjonalny, domyślnie "-" (current tailnet) + url: https://api.tailscale.com/api/v2/tailnet/-/devices # Opcjonalny, można nadpisać URL API + cache: 10m # Opcjonalny, domyślnie 10m + collapse-after: 4 # Opcjonalny, domyślnie 4 + show-online-indicator: false # Opcjonalny, domyślnie false +``` + +## Parametry + +### `token` (wymagany) +Token API Tailscale. Możesz wygenerować go w panelu administracyjnym Tailscale: +- Przejdź do https://login.tailscale.com/admin/settings/keys +- Kliknij "Generate API access token" +- Skopiuj token i użyj go w konfiguracji + +### `tailnet` (opcjonalny) +Nazwa tailnet. Domyślnie "-" oznacza bieżący tailnet. Możesz podać konkretną nazwę, jeśli masz dostęp do wielu tailnetów. + +### `url` (opcjonalny) +Niestandardowy URL API. Domyślnie widget używa oficjalnego API Tailscale. + +### `cache` (opcjonalny) +Czas cache'owania danych. Domyślnie 10 minut. Przykłady: `5m`, `1h`, `30s`. + +### `collapse-after` (opcjonalny) +Liczba urządzeń widocznych przed przyciskiem "SHOW MORE". Domyślnie 4. Ustaw na `-1`, aby nigdy nie zwijać listy. + +### `show-online-indicator` (opcjonalny) +Czy pokazywać zielony wskaźnik dla urządzeń online. Domyślnie `false` (pokazywany jest tylko czerwony wskaźnik dla urządzeń offline). + +## Wizualne elementy + +Widget zachowuje całą kolorystykę z wersji custom-api: +- **Kolor podstawowy** (`--color-primary`) - nazwa urządzenia i tło IP po hover +- **Kolor pozytywny** (`--color-positive`) - wskaźnik online (jeśli włączony) i tło IP po skopiowaniu +- **Kolor negatywny** (`--color-negative`) - wskaźnik offline +- **Kolor podstawowy** (`--color-primary`) - wskaźnik dostępnej aktualizacji + +### Kopiowanie adresu IP +Po najechaniu na wiersz urządzenia: +1. Zamiast informacji o systemie i użytkowniku pojawia się adres IP +2. Adres IP jest klikalny (hover zmienia tło na niebieski) +3. Kliknięcie w IP kopiuje je do schowka +4. Po skopiowaniu tło zmienia się na zielone i pojawia się ✓ na 2 sekundy +5. Działa w każdej przeglądarce dzięki mechanizmowi fallback + +## Przykładowe zastosowania + +### Podstawowy monitoring: +```yaml +- type: tailscale + token: ${TAILSCALE_TOKEN} +``` + +### Monitoring ze wskaźnikami online: +```yaml +- type: tailscale + token: ${TAILSCALE_TOKEN} + show-online-indicator: true + collapse-after: 10 +``` + +### Monitoring z niestandardowym cache: +```yaml +- type: tailscale + token: ${TAILSCALE_TOKEN} + cache: 5m + title: Moja Sieć Tailscale +``` diff --git a/internal/glance/templates/tailscale.html b/internal/glance/templates/tailscale.html new file mode 100644 index 0000000..90661a2 --- /dev/null +++ b/internal/glance/templates/tailscale.html @@ -0,0 +1,144 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + + +{{ if .Devices }} + +{{ else }} +

Brak urządzeń

+{{ end }} +{{ end }} diff --git a/internal/glance/widget-tailscale.go b/internal/glance/widget-tailscale.go new file mode 100644 index 0000000..3cae98d --- /dev/null +++ b/internal/glance/widget-tailscale.go @@ -0,0 +1,145 @@ +package glance + +import ( + "context" + "fmt" + "html/template" + "net/http" + "strings" + "time" +) + +var tailscaleWidgetTemplate = mustParseTemplate("tailscale.html", "widget-base.html") + +type tailscaleWidget struct { + widgetBase `yaml:",inline"` + URL string `yaml:"url"` + Token string `yaml:"token"` + Tailnet string `yaml:"tailnet"` + CollapseAfter int `yaml:"collapse-after"` + ShowOnlineIndicator bool `yaml:"show-online-indicator"` + Devices []tailscaleDevice +} + +type tailscaleDevice struct { + ID string + Name string + ShortName string + OS string + User string + Addresses []string + PrimaryAddress string + LastSeen time.Time + LastSeenStr string + UpdateAvailable bool + IsOnline bool +} + +type tailscaleAPIResponse struct { + Devices []tailscaleAPIDevice `json:"devices"` +} + +type tailscaleAPIDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + OS string `json:"os"` + User string `json:"user"` + Addresses []string `json:"addresses"` + LastSeen string `json:"lastSeen"` + UpdateAvailable bool `json:"updateAvailable"` +} + +func (widget *tailscaleWidget) initialize() error { + widget.withTitle("Tailscale").withCacheDuration(10 * time.Minute) + + if widget.Token == "" { + return fmt.Errorf("token is required") + } + + if widget.Tailnet == "" { + widget.Tailnet = "-" // Default to current tailnet + } + + if widget.URL == "" { + widget.URL = fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/devices", widget.Tailnet) + } + + if widget.CollapseAfter <= 0 { + widget.CollapseAfter = 4 + } + + return nil +} + +func (widget *tailscaleWidget) update(ctx context.Context) { + devices, err := widget.fetchDevices() + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.Devices = devices +} + +func (widget *tailscaleWidget) fetchDevices() ([]tailscaleDevice, error) { + request, err := http.NewRequest("GET", widget.URL, nil) + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+widget.Token) + + apiResponse, err := decodeJsonFromRequest[tailscaleAPIResponse](defaultHTTPClient, request) + if err != nil { + return nil, err + } + + devices := make([]tailscaleDevice, 0) + now := time.Now() + + for _, apiDevice := range apiResponse.Devices { + device := tailscaleDevice{ + ID: apiDevice.ID, + Name: apiDevice.Name, + ShortName: extractShortName(apiDevice.Name), + OS: apiDevice.OS, + User: apiDevice.User, + Addresses: apiDevice.Addresses, + UpdateAvailable: apiDevice.UpdateAvailable, + } + + // Get primary address + if len(apiDevice.Addresses) > 0 { + device.PrimaryAddress = apiDevice.Addresses[0] + } + + // Parse last seen time + if apiDevice.LastSeen != "" { + lastSeen, err := time.Parse(time.RFC3339, apiDevice.LastSeen) + if err == nil { + device.LastSeen = lastSeen + device.LastSeenStr = lastSeen.Format("Jan 2 3:04pm") + + // Device is considered online if last seen within 10 seconds + device.IsOnline = lastSeen.After(now.Add(-10 * time.Second)) + } + } + + devices = append(devices, device) + } + + return devices, nil +} + +// extractShortName extracts the hostname before the first dot +func extractShortName(fullName string) string { + if idx := strings.Index(fullName, "."); idx > 0 { + return fullName[:idx] + } + return fullName +} + +func (widget *tailscaleWidget) Render() template.HTML { + return widget.renderTemplate(widget, tailscaleWidgetTemplate) +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 8dab526..0bb6723 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -85,6 +85,8 @@ func newWidget(widgetType string) (widget, error) { w = &radyjkoWidget{} case "vikunja": w = &vikunjaWidget{} + case "tailscale": + w = &tailscaleWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }