wstępna wersja widżetu tailscale

- cała funkcjonalność wersji custom-api
- możliwość kopiowania ipv4 danego urządzenia
planowane:
- możliwość włączenia ssh tailscale
- zmiana exit-node
- zmiana advertise-routes
pull/878/head
jandziaslo 2025-11-17 16:34:49 +07:00
parent 18f8b1a1e0
commit 08156e7b52
No known key found for this signature in database
GPG Key ID: E939F8F12F8D3A5A
4 changed files with 403 additions and 0 deletions

@ -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
```

@ -0,0 +1,144 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<style>
.tailscale-device-info-container {
position: relative;
overflow: hidden;
height: 1.5em;
}
.tailscale-device-info {
display: flex;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.tailscale-device-ip {
position: absolute;
top: 0;
left: 0;
transform: translateY(-100%);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.tailscale-device-info-container:hover .tailscale-device-info {
transform: translateY(100%);
opacity: 0;
}
.tailscale-device-info-container:hover .tailscale-device-ip {
transform: translateY(0);
opacity: 1;
}
.tailscale-update-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-primary);
display: inline-block;
margin-left: 4px;
vertical-align: middle;
}
.tailscale-offline-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-negative);
display: inline-block;
margin-left: 4px;
vertical-align: middle;
}
.tailscale-online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-positive);
display: inline-block;
margin-left: 4px;
vertical-align: middle;
}
.tailscale-device-name-container {
display: flex;
align-items: center;
gap: 8px;
}
.tailscale-indicators-container {
display: flex;
align-items: center;
gap: 4px;
}
.tailscale-ip-clickable {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
user-select: none;
}
.tailscale-ip-clickable:hover {
background-color: var(--color-primary);
color: var(--color-background);
}
.tailscale-ip-clickable.copied {
background-color: var(--color-positive);
color: var(--color-background);
}
.tailscale-ip-clickable.copied::after {
content: ' ✓';
margin-left: 4px;
}
</style>
{{ if .Devices }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Devices }}
<li>
<div class="flex items-center gap-10">
<div class="tailscale-device-name-container grow">
<span class="size-h4 block text-truncate color-primary">
{{ .ShortName }}
</span>
<div class="tailscale-indicators-container">
{{ if .UpdateAvailable }}
<span class="tailscale-update-indicator" data-popover-type="text" data-popover-text="Aktualizacja dostępna"></span>
{{ end }}
{{ if not .IsOnline }}
<span class="tailscale-offline-indicator" data-popover-type="text"
data-popover-text="Offline - Ostatnio aktywny {{ .LastSeenStr }}"></span>
{{ else if $.ShowOnlineIndicator }}
<span class="tailscale-online-indicator" data-popover-type="text" data-popover-text="Online"></span>
{{ end }}
</div>
</div>
</div>
<div class="tailscale-device-info-container">
<ul class="list-horizontal-text tailscale-device-info">
<li>{{ .OS }}</li>
<li>{{ .User }}</li>
</ul>
<div class="tailscale-device-ip">
<span class="tailscale-ip-clickable"
data-ip="{{ .PrimaryAddress }}"
title="Kliknij aby skopiować IP"
onclick="event.stopPropagation(); event.preventDefault(); var ip = this.getAttribute('data-ip'); var self = this; var textarea = document.createElement('textarea'); textarea.value = ip; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); self.classList.add('copied'); setTimeout(function() { self.classList.remove('copied'); }, 2000); } catch(err) { console.error('Kopiowanie nie powiodło się:', err); } document.body.removeChild(textarea);">
{{ .PrimaryAddress }}
</span>
</div>
</div>
</li>
{{ end }}
</ul>
{{ else }}
<p class="text-center color-paragraph">Brak urządzeń</p>
{{ end }}
{{ end }}

@ -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)
}

@ -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)
}