feat: конфигурация Glance с виджетами и скриптами развертывания

- Главная конфигурация home.yml и виджеты (countdown, qbittorrent)
- PowerShell и Bash скрипты для автоматической загрузки на сервер
- Обновлен README с документацией
This commit is contained in:
2025-12-06 09:45:17 +03:00
parent c9748f1bbd
commit abd62d8f73
8 changed files with 987 additions and 1 deletions

161
README.md
View File

@@ -1,2 +1,161 @@
# glance
# Glance Dashboard Configuration
Конфигурация для [Glance](https://github.com/glanceapp/glance) - минималистичного дашборда для самостоятельного хостинга.
## 📁 Структура проекта
```
glance/
├── home.yml # Главный файл конфигурации
├── widgets/ # Отдельные виджеты (модульная структура)
│ ├── bookmarks-homelab.yml # Закладки Homelab
│ ├── qbittorrent.yml # Виджет qBittorrent
│ └── ... # Другие виджеты
├── upload-glance-config.sh # Shell скрипт для загрузки конфигурации
├── upload-glance-config.ps1 # PowerShell обертка (использует WSL)
└── README.md # Этот файл
```
## 🚀 Установка и настройка
### Требования
**Для Linux/macOS:**
- `bash`
- `ssh` и `scp` (обычно предустановлены)
- SSH доступ к серверу с Glance
**Для Windows:**
- [WSL (Windows Subsystem for Linux)](https://docs.microsoft.com/ru-ru/windows/wsl/install)
- PowerShell 5.1+ (предустановлен в Windows 10/11)
- SSH доступ к серверу с Glance
### Установка WSL (только для Windows)
```powershell
# Запустите PowerShell от имени администратора
wsl --install
```
После установки перезагрузите компьютер.
## 📤 Загрузка конфигурации на сервер
### Linux/macOS
```bash
# С параметрами по умолчанию (./glance, root@192.168.50.114)
./upload-glance-config.sh
# С произвольными параметрами
./upload-glance-config.sh /path/to/glance username 192.168.50.100
```
### Windows
```powershell
# С параметрами по умолчанию
.\upload-glance-config.ps1
# С произвольными параметрами
.\upload-glance-config.ps1 -LocalDir "e:\repos\glance" -User "root" -Host "192.168.50.114"
# Или в кратком виде
.\upload-glance-config.ps1 "e:\repos\glance" "root" "192.168.50.114"
```
> **Примечание:** PowerShell скрипт является оберткой, которая вызывает shell скрипт через WSL, избегая дублирования кода.
## ⚙️ Параметры скрипта
| Параметр | Описание | Значение по умолчанию |
|----------|----------|----------------------|
| `LOCAL_DIR` / `-LocalDir` | Локальная директория с конфигурацией | `./glance` |
| `USER` / `-User` | Пользователь SSH | `root` |
| `HOST` / `-Host` | IP адрес или hostname сервера | `192.168.50.114` |
## 📋 Что делает скрипт
1. **Загружает `home.yml`**`/opt/glance/config/glance.yml` на сервере
2. **Синхронизирует директорию `widgets/`**`/opt/glance/config/widgets/` на сервере
3. **Автоматически создает** необходимые директории на сервере
4. **Проверяет наличие** файлов и директорий перед загрузкой
> Glance автоматически перезагружает конфигурацию при обнаружении изменений.
## 🎨 Модульная структура виджетов
Конфигурация использует функцию `include` для организации виджетов:
```yaml
# В home.yml
pages:
- name: Главная
columns:
- size: full
widgets:
- type: include
path: widgets/bookmarks-homelab.yml
```
### Преимущества модульной структуры
- ✅ Легче управлять и редактировать отдельные виджеты
- ✅ Переиспользование виджетов в разных страницах
- ✅ Простота версионирования и отладки
- ✅ Чище git история изменений
## 🔧 Настройка SSH ключей (рекомендуется)
Для автоматической загрузки без ввода пароля:
```bash
# Сгенерируйте SSH ключ (если еще не создан)
ssh-keygen -t ed25519 -C "glance-config"
# Скопируйте ключ на сервер
ssh-copy-id root@192.168.50.114
```
## 🐛 Устранение неполадок
### Windows: "wsl: команда не найдена"
Убедитесь, что WSL установлен:
```powershell
wsl --version
```
Если WSL не установлен, выполните:
```powershell
wsl --install
```
### Ошибка SSH подключения
Проверьте подключение вручную:
```bash
ssh root@192.168.50.114
```
### Директория не найдена
Убедитесь, что вы запускаете скрипт из правильной директории:
```bash
# Linux/macOS
ls -la glance/
# PowerShell
Get-ChildItem glance/
```
## 📚 Полезные ссылки
- [Glance Documentation](https://github.com/glanceapp/glance)
- [Glance Configuration Examples](https://github.com/glanceapp/glance/blob/main/docs/configuration.md)
- [WSL Documentation (RU)](https://docs.microsoft.com/ru-ru/windows/wsl/)
## 📝 Лицензия
Этот проект следует лицензии основного проекта [Glance](https://github.com/glanceapp/glance).

60
home.yml Normal file
View File

@@ -0,0 +1,60 @@
theme:
name: nord
pages:
- name: Home
columns:
- size: small
widgets:
- type: calendar
first-day-of-week: monday
- type: repository
repository: awesome-selfhosted/awesome-selfhosted
- type: rss
title: News
limit: 5
feeds:
- url: https://habr.com/ru/rss/all/
title: Habr
- size: full
widgets:
- type: search
search-engine: google
- $include: widgets/countdown.yml
- $include: widgets/bookmarks-general.yml
- $include: widgets/bookmarks-homelab.yml
- size: small
widgets:
- type: clock
title: Time
- type: to-do
- type: custom-api
title: Immich stats
cache: 1d
url: http://192.168.50.101:2283/api/server/statistics
headers:
x-api-key: izNdobgXRRg7agxZzEQvNEKpsnwPT8x0cs6Vi7E
Accept: application/json
template: |
<div class="flex justify-between text-center">
<div>
<div class="color-highlight size-h3">{{ .JSON.Int "photos" | formatNumber }}</div>
<div class="size-h6">PHOTOS</div>
</div>
<div>
<div class="color-highlight size-h3">{{ .JSON.Int "videos" | formatNumber }}</div>
<div class="size-h6">VIDEOS</div>
</div>
<div>
<div class="color-highlight size-h3">{{ div (.JSON.Int "usage" | toFloat) 1073741824 | toInt | formatNumber }}GB</div>
<div class="size-h6">USAGE</div>
</div>
</div>
- type: dns-stats
service: adguard
url: http://192.168.50.2/
username: Dokril
password: baHYc2VgeRJfdZ
- $include: widgets/qbittorrent.yml

71
upload-glance-config.ps1 Normal file
View File

@@ -0,0 +1,71 @@
# PowerShell скрипт для обновления конфигурации Glance
# Использование: .\upload-glance-config.ps1 [LOCAL_DIR] [USER] [HOST]
param(
[string]$LocalDir = ".",
[string]$User = "root",
[string]$RemoteHost = "192.168.50.114"
)
$RemoteGlanceConfig = "/opt/glance/config"
$SshOpts = @("-q", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null")
$ScpOpts = @("-q", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null")
# Функция для выхода с ошибкой
function Exit-OnError {
param([string]$Message)
Write-Host "Ошибка: $Message" -ForegroundColor Red
exit 1
}
# Проверяем наличие локальной директории glance
if (-not (Test-Path $LocalDir -PathType Container)) {
Exit-OnError "Директория $LocalDir не найдена!"
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Обновление конфигурации Glance" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# 1. Загружаем home.yml на сервер
$LocalHomeYml = Join-Path $LocalDir "home.yml"
if (Test-Path $LocalHomeYml -PathType Leaf) {
Write-Host "`n📄 Загружаем главный файл конфигурации..." -ForegroundColor Yellow
& scp @ScpOpts $LocalHomeYml "${User}@${RemoteHost}:${RemoteGlanceConfig}/glance.yml"
if ($LASTEXITCODE -ne 0) {
Exit-OnError "Ошибка при загрузке home.yml"
}
Write-Host "✅ home.yml успешно загружен" -ForegroundColor Green
} else {
Write-Host "⚠️ Файл home.yml не найден в $LocalDir" -ForegroundColor Yellow
}
# 2. Загружаем директорию widgets рекурсивно
$LocalWidgetsDir = Join-Path $LocalDir "widgets"
if (Test-Path $LocalWidgetsDir -PathType Container) {
Write-Host "`n📦 Загружаем директорию widgets..." -ForegroundColor Yellow
# Создаем директорию widgets на сервере, если не существует
& ssh @SshOpts "${User}@${RemoteHost}" "mkdir -p ${RemoteGlanceConfig}/widgets"
if ($LASTEXITCODE -ne 0) {
Exit-OnError "Не удалось создать директорию widgets на сервере"
}
# Копируем все файлы из widgets рекурсивно
& scp @ScpOpts -r "$LocalWidgetsDir/*" "${User}@${RemoteHost}:${RemoteGlanceConfig}/widgets/"
if ($LASTEXITCODE -ne 0) {
Exit-OnError "Ошибка при загрузке директории widgets"
}
Write-Host "✅ Директория widgets успешно загружена" -ForegroundColor Green
} else {
Write-Host "⚠️ Директория widgets не найдена в $LocalDir" -ForegroundColor Yellow
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "Конфигурация успешно обновлена! 🎉" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Примечание: Glance автоматически перезагрузит конфигурацию" -ForegroundColor Yellow

72
upload-glance-config.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# Скрипт для обновления только конфигурации Glance (без бэкенда)
# Использование: ./upload-glance-config.sh [LOCAL_DIR] [USER] [HOST]
# Параметры по умолчанию
LOCAL_GLANCE_DIR="${1:-./glance}"
REMOTE_USER="${2:-root}"
REMOTE_HOST="${3:-192.168.50.114}"
REMOTE_GLANCE_CONFIG="/opt/glance/config"
SSH_OPTS="-q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
SCP_OPTS="-q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Функция для выхода с ошибкой
exit_on_error() {
echo -e "${RED}Ошибка: $1${NC}" >&2
exit 1
}
# Проверяем наличие локальной директории glance
if [ ! -d "$LOCAL_GLANCE_DIR" ]; then
exit_on_error "Директория $LOCAL_GLANCE_DIR не найдена!"
fi
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN}Обновление конфигурации Glance${NC}"
echo -e "${CYAN}========================================${NC}"
# 1. Загружаем home.yml на сервер
LOCAL_HOME_YML="$LOCAL_GLANCE_DIR/home.yml"
if [ -f "$LOCAL_HOME_YML" ]; then
echo -e "\n${YELLOW}📄 Загружаем главный файл конфигурации...${NC}"
scp $SCP_OPTS "$LOCAL_HOME_YML" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_GLANCE_CONFIG}/glance.yml"
if [ $? -ne 0 ]; then
exit_on_error "Ошибка при загрузке home.yml"
fi
echo -e "${GREEN}✅ home.yml успешно загружен${NC}"
else
echo -e "${YELLOW}⚠️ Файл home.yml не найден в $LOCAL_GLANCE_DIR${NC}"
fi
# 2. Загружаем директорию widgets рекурсивно
LOCAL_WIDGETS_DIR="$LOCAL_GLANCE_DIR/widgets"
if [ -d "$LOCAL_WIDGETS_DIR" ]; then
echo -e "\n${YELLOW}📦 Загружаем директорию widgets...${NC}"
# Создаем директорию widgets на сервере, если не существует
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_GLANCE_CONFIG}/widgets"
if [ $? -ne 0 ]; then
exit_on_error "Не удалось создать директорию widgets на сервере"
fi
# Копируем все файлы из widgets рекурсивно
scp $SCP_OPTS -r "${LOCAL_WIDGETS_DIR}"/* "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_GLANCE_CONFIG}/widgets/"
if [ $? -ne 0 ]; then
exit_on_error "Ошибка при загрузке директории widgets"
fi
echo -e "${GREEN}✅ Директория widgets успешно загружена${NC}"
else
echo -e "${YELLOW}⚠️ Директория widgets не найдена в $LOCAL_GLANCE_DIR${NC}"
fi
echo -e "\n${CYAN}========================================${NC}"
echo -e "${GREEN}Конфигурация успешно обновлена! 🎉${NC}"
echo -e "${CYAN}========================================${NC}"
echo -e "${YELLOW}Примечание: Glance автоматически перезагрузит конфигурацию${NC}"

View File

@@ -0,0 +1,45 @@
- type: bookmarks
groups:
- title: Email
type: main
links:
- title: Gmail
url: https://mail.google.com/mail/u/0/
- title: Games
type: main
links:
- title: itch.io Idle
url: https://itch.io/games/new-and-popular/tag-idle
- title: Entertainment
color: 10 70 50
type: public
links:
- title: YouTube
url: https://www.youtube.com/
- title: Social
type: main
links:
- title: Twitter(X)
url: https://x.com/home
- title: Shopping
color: 10 70 50
type: public
links:
- title: Yandex
url: https://market.yandex.ru/
- title: Ozon
url: https://www.ozon.ru/
- title: AI
type: main
links:
- title: ChatGPT
url: https://chatgpt.com/
- title: Gemini
url: https://gemini.google.com/
- title: Trackers
type: main
links:
- title: Rutracker
url: https://rutracker.net/
- title: RuTor
url: https://pornolab.net/

View File

@@ -0,0 +1,44 @@
- type: bookmarks
groups:
- title: Homelab - Services
color: 200 50 50
type: homelab
links:
- title: Router
url: https://192.168.50.1:8443
- title: Proxmox
url: http://192.168.50.113:8006
- title: NPM
url: http://192.168.50.38:81/nginx/proxy
- title: Gitea
url: http://192.168.50.109:3000/
- title: Immich
url: http://192.168.50.101:2283/
- title: Media
url: http://192.168.50.108:9999
- title: qBittorrent
url: http://192.168.50.108:8080
- title: Homelab - Useful Links
color: 200 50 50
type: homelab
links:
- title: Proxmox Community Scripts
url: https://community-scripts.github.io/ProxmoxVE/scripts
- title: Homelab - Monitoring
color: 200 50 50
type: homelab
links:
- title: Grafana
url: http://192.168.50.106:3000/d/af346btjrod8gd/main?from=now-6h&to=now&timezone=browser&refresh=5s
- title: Prometheus
url: http://192.168.50.105:9090/targets
- title: Homelab - Cloud
color: 200 50 50
type: homelab
links:
- title: Timeweb Cloud
url: https://timeweb.cloud/my/projects/1882441
- title: Senko VM
url: https://vm.senko.digital/vm/manager/host/list
- title: Aeza VM
url: https://my.aeza.net/services

348
widgets/countdown.yml Normal file
View File

@@ -0,0 +1,348 @@
- type: custom-api
title: "Важные даты"
url: http://192.168.50.114:5555/countdown
cache: 5s
template: |
{{/* ===== НАСТРОЙКИ ВРЕМЕННОЙ ШКАЛЫ ===== */}}
{{/* Переменная шкала: 2 недели (14 дней) + 40 недель = ~1 год */}}
{{/* Первая зона: 2 недели = 14 делений (по дням) = 25% шкалы */}}
{{/* Вторая зона: 40 недель = 40 делений (по неделям) = 75% шкалы */}}
{{ $daysInFirstZone := 14 }}
{{ $weeksInSecondZone := 40 }}
{{ $firstZonePercent := 25.0 }}
{{ $secondZonePercent := 75.0 }}
<div style="position: relative; padding: 20px 10px;">
<!-- ===== КОНТЕЙНЕР TIMELINE3 ===== -->
<div id="glance-timeline" style="position: relative; height: 70px; margin: 10px 0;">
{{/* ===== ОТРИСОВКА ЛИНИИ ВРЕМЕНИ ===== */}}
{{/* Зона 1 (0-25%): 14 делений по дням - с мягким красным градиентом */}}
{{/* Зона 2 (25-100%): 40 делений по неделям */}}
<div style="position: absolute; left: 0; width: 25%; top: 50%; transform: translateY(-50%); height: 3px; background: linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4)); background-image: linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4)), linear-gradient(to right, rgba(180, 180, 180, 0.6) 1px, transparent 1px); background-size: 100% 100%, calc(100% / 14) 100%; background-repeat: no-repeat, repeat-x;">
</div>
<div style="position: absolute; left: 25%; width: 75%; top: 50%; transform: translateY(-50%); height: 3px; background-color: transparent; background-image: linear-gradient(to right, rgba(128,128,128,0.45) 1px, transparent 1px), linear-gradient(to right, rgba(128,128,128,0.75) 1px, transparent 1px); background-size: calc(100% / 40) 100%, calc(100% / 10) 100%; background-repeat: repeat-x, repeat-x;">
</div>
{{/* ===== МАРКЕР "СЕГОДНЯ" ===== */}}
{{/* Огонёк показывает текущий момент */}}
<div style="position: absolute; left: 0; top: 50%; transform: translate(-50%, -50%); font-size: 24px; text-shadow: 0 0 8px rgba(255, 100, 0, 0.6); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); z-index: 15;">
🔥
</div>
{{/* ===== ОТОБРАЖЕНИЕ СОБЫТИЙ ===== */}}
{{/* Позиции рассчитываются с учетом двух зон шкалы */}}
{{ $daysZ1 := toFloat $daysInFirstZone }}
{{ $weeksZ2 := toFloat $weeksInSecondZone }}
{{ $z1Pct := $firstZonePercent }}
{{ $z2Pct := $secondZonePercent }}
{{ $maxDays := add $daysZ1 (mul $weeksZ2 7.0) }}
{{ range .JSON.Array "events" }}
{{/* Получаем количество дней до события из API /countdown */}}
{{ $days := .Int "days" }}
{{ $daysFloat := toFloat $days }}
{{/* ФОРМУЛА: Рассчитываем позицию в зависимости от зоны */}}
{{ $pos := 0.0 }}
{{/* Показываем только события в пределах диапазона (14 дней + 40 недель = 294 дня) */}}
{{ if lt $daysFloat $maxDays }}
{{ if lt $daysFloat $daysZ1 }}
{{/* Зона 1: 0-14 дней -> 0-25% шкалы */}}
{{ $pos = mul (div $daysFloat $daysZ1) $z1Pct }}
{{ else }}
{{/* Зона 2: 14+ дней -> 25-100% шкалы */}}
{{ $daysAfterZ1 := sub $daysFloat $daysZ1 }}
{{ $weeksInZ2 := div $daysAfterZ1 7.0 }}
{{ $posInZ2 := mul (div $weeksInZ2 $weeksZ2) $z2Pct }}
{{ $pos = add $z1Pct $posInZ2 }}
{{ end }}
{{/* ===== КОНТЕЙНЕР СОБЫТИЯ ===== */}}
{{/* Абсолютное позиционирование: left = рассчитанный процент */}}
{{/* transform: translate(-50%, -50%) центрирует emoji точно на линии */}}
<div class="event-item" style="position: absolute; left: {{ $pos }}%; top: 35%; transform: translate(-50%, -50%); cursor: pointer; z-index: 10; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);">
{{/* ===== EMOJI СОБЫТИЯ ===== */}}
{{/* При наведении увеличивается и раздвигает соседей */}}
<div class="event-emoji" style="font-size: 28px; text-shadow: 0 0 3px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);">{{ .String "emoji" }}</div>
{{/* ===== РАСШИРЕННЫЙ TOOLTIP ===== */}}
{{/* Большой информативный tooltip с детальной информацией */}}
<div class="event-tooltip" style="position: absolute; bottom: 45px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, rgba(20,20,25,0.98) 0%, rgba(30,30,40,0.98) 100%); color: white; padding: 12px 16px; border-radius: 10px; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: all 0.2s ease; box-shadow: 0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1); backdrop-filter: blur(10px); min-width: 200px;">
{{/* Emoji и название */}}
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.15);">
<div style="font-size: 24px;">{{ .String "emoji" }}</div>
<div style="font-weight: bold; font-size: 14px; color: #fff;">{{ .String "name" }}</div>
</div>
{{/* Точная дата события */}}
<div style="margin-bottom: 8px; display: flex; align-items: center; gap: 6px;">
<div style="opacity: 0.7; font-size: 11px;">📅 Дата:</div>
<div style="font-weight: 600; color: #8ab4f8;">{{ .String "date" }}</div>
</div>
{{/* Информация о годовщине (если это anniversary) */}}
{{ if eq (.String "type") "anniversary" }}
<div style="background: rgba(147, 51, 234, 0.15); padding: 8px; border-radius: 6px; margin-bottom: 8px; border-left: 3px solid #9333ea;">
<div style="opacity: 0.9; font-size: 11px; margin-bottom: 3px;">🎉 Годовщина</div>
{{ $years := .Int "years_passed" }}
{{ $lastDigit := mod $years 10 }}
{{ $lastTwoDigits := mod $years 100 }}
<div style="font-weight: 700; font-size: 15px; color: #c084fc;">{{ $years }} {{ if and (eq $lastDigit 1) (ne $lastTwoDigits 11) }}год{{ else if and (or (eq $lastDigit 2) (eq $lastDigit 3) (eq $lastDigit 4)) (not (or (eq $lastTwoDigits 12) (eq $lastTwoDigits 13) (eq $lastTwoDigits 14))) }}года{{ else }}лет{{ end }}</div>
</div>
{{ end }}
{{/* Детальный countdown */}}
<div style="background: rgba(255,255,255,0.08); padding: 8px; border-radius: 6px;">
<div style="opacity: 0.7; font-size: 10px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">⏰ Осталось</div>
{{ $days := .Int "days" }}
{{ if ge $days 30 }}
{{/* Если >= 30 дней: показываем месяцы и дни */}}
{{ $months := div $days 30 }}
{{ $remainingDays := mod $days 30 }}
<div style="display: flex; gap: 12px; justify-content: space-around;">
<div style="text-align: center;">
<div style="font-size: 16px; font-weight: bold; color: #ff6b9d;">{{ $months }}</div>
<div style="font-size: 9px; opacity: 0.6; margin-top: 2px;">{{ if eq $months 1 }}месяц{{ else if or (eq $months 2) (eq $months 3) (eq $months 4) }}месяца{{ else }}месяцев{{ end }}</div>
</div>
<div style="text-align: center;">
<div style="font-size: 16px; font-weight: bold; color: #ffa94d;">{{ $remainingDays }}</div>
<div style="font-size: 9px; opacity: 0.6; margin-top: 2px;">{{ if eq $remainingDays 1 }}день{{ else if or (eq $remainingDays 2) (eq $remainingDays 3) (eq $remainingDays 4) }}дня{{ else }}дней{{ end }}</div>
</div>
</div>
{{ else }}
{{/* Если < 30 дней: показываем дни и часы */}}
<div style="display: flex; gap: 12px; justify-content: space-around;">
<div style="text-align: center;">
<div style="font-size: 16px; font-weight: bold; color: #ff6b9d;">{{ $days }}</div>
<div style="font-size: 9px; opacity: 0.6; margin-top: 2px;">{{ if eq $days 1 }}день{{ else if or (eq $days 2) (eq $days 3) (eq $days 4) }}дня{{ else }}дней{{ end }}</div>
</div>
<div style="text-align: center;">
<div style="font-size: 16px; font-weight: bold; color: #ffa94d;">{{ .Int "hours" }}</div>
<div style="font-size: 9px; opacity: 0.6; margin-top: 2px;">{{ if eq (.Int "hours") 1 }}час{{ else if or (eq (.Int "hours") 2) (eq (.Int "hours") 3) (eq (.Int "hours") 4) }}часа{{ else }}часов{{ end }}</div>
</div>
</div>
{{ end }}
</div>
{{/* Треугольная стрелка вниз */}}
<div style="position: absolute; bottom: -6px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid rgba(20,20,25,0.98);"></div>
</div>
</div>
{{ end }}
{{ end }}
{{/* ===== ИНТЕРАКТИВНАЯ СЕТКА ДЛЯ НАВЕДЕНИЯ ===== */}}
{{/* Невидимые зоны, которые показывают дату при наведении */}}
{{ range .JSON.Array "timeline_grid" }}
{{ $gridDays := .Int "days" }}
{{ $gridDaysFloat := toFloat $gridDays }}
{{/* Рассчитываем позицию точки на шкале */}}
{{ $gridPos := 0.0 }}
{{ $gridWidth := 0.0 }}
{{ if lt $gridDaysFloat $maxDays }}
{{ if lt $gridDaysFloat $daysZ1 }}
{{/* Зона 1: деления по дням */}}
{{ $gridPos = mul (div $gridDaysFloat $daysZ1) $z1Pct }}
{{ $gridWidth = div $z1Pct $daysZ1 }}
{{ else }}
{{/* Зона 2: деления по неделям */}}
{{ $daysAfterZ1 := sub $gridDaysFloat $daysZ1 }}
{{ $weeksInZ2 := div $daysAfterZ1 7.0 }}
{{ $posInZ2 := mul (div $weeksInZ2 $weeksZ2) $z2Pct }}
{{ $gridPos = add $z1Pct $posInZ2 }}
{{ $gridWidth = div $z2Pct $weeksZ2 }}
{{ end }}
{{/* Интерактивная зона */}}
<div class="timeline-grid-item" style="position: absolute; left: {{ $gridPos }}%; top: 35%; transform: translateY(-50%); width: {{ $gridWidth }}%; height: 40px; cursor: pointer; z-index: 5;">
{{/* Tooltip с датой */}}
<div class="grid-tooltip" style="position: absolute; bottom: 45px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, rgba(30,30,40,0.95) 0%, rgba(40,40,50,0.95) 100%); color: white; padding: 8px 12px; border-radius: 8px; font-size: 11px; white-space: nowrap; opacity: 0; pointer-events: none; transition: all 0.15s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08); backdrop-filter: blur(8px);">
{{/* День недели */}}
<div style="text-align: center; margin-bottom: 4px; font-weight: 600; color: #8ab4f8; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
{{ .String "weekday" }}
</div>
{{/* Дата */}}
<div style="text-align: center; font-weight: 500; font-size: 13px;">
📅 {{ .String "date" }}
</div>
{{/* Треугольная стрелка */}}
<div style="position: absolute; bottom: -5px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid rgba(30,30,40,0.95);"></div>
</div>
</div>
{{ end }}
{{ end }}
{{/* ===== МЕТКИ МЕСЯЦЕВ ПОД ШКАЛОЙ ===== */}}
{{/* Используем данные о месяцах из API для элегантного отображения */}}
{{ range .JSON.Array "months" }}
{{ $monthDays := .Int "days" }}
{{ $monthDaysFloat := toFloat $monthDays }}
{{/* Показываем только метки в пределах видимого диапазона */}}
{{ if lt $monthDaysFloat $maxDays }}
{{/* Рассчитываем позицию метки */}}
{{ $monthPos := 0.0 }}
{{if lt $monthDaysFloat $daysZ1 }}
{{ $monthPos = mul (div $monthDaysFloat $daysZ1) $z1Pct }}
{{ else }}
{{ $daysAfter := sub $monthDaysFloat $daysZ1 }}
{{ $weeksIn := div $daysAfter 7.0 }}
{{ $posIn := mul (div $weeksIn $weeksZ2) $z2Pct }}
{{ $monthPos = add $z1Pct $posIn }}
{{ end }}
{{/* Отображаем метку месяца */}}
<div style="position: absolute; left: {{ $monthPos }}%; top: 65%; transform: translateX(-50%); font-size: 10px; color: rgba(128,128,128,0.8); white-space: nowrap;">
{{ .String "name" }}
</div>
{{ end }}
{{ end }}
</div>
</div>
<style>
/* Показываем tooltip при наведении на родительский элемент */
.event-item:hover > .event-tooltip { opacity: 1 !important; pointer-events: auto; }
/* Показываем tooltip при наведении на интерактивную зону сетки */
.timeline-grid-item:hover > .grid-tooltip { opacity: 1 !important; pointer-events: auto; }
/* Увеличиваем эмодзи при наведении */
.event-item:hover .event-emoji {
transform: scale(1.4);
}
/* Поднимаем z-index у наведенного элемента */
.event-item:hover {
z-index: 100 !important;
}
/* Плавные переходы */
.event-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Анимации тряски с разной интенсивностью */
@keyframes shake-weak {
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
25% { transform: translate(-50%, -50%) rotate(0.5deg); }
75% { transform: translate(-50%, -50%) rotate(-0.5deg); }
}
@keyframes shake-medium {
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
25% { transform: translate(-50%, -50%) rotate(1deg); }
75% { transform: translate(-50%, -50%) rotate(-1deg); }
}
@keyframes shake-strong {
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
25% { transform: translate(-50%, -50%) rotate(2deg); }
75% { transform: translate(-50%, -50%) rotate(-2deg); }
}
@keyframes shake-very-strong {
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
10% { transform: translate(-50%, -50%) rotate(3deg); }
30% { transform: translate(-50%, -50%) rotate(-3deg); }
50% { transform: translate(-50%, -50%) rotate(3deg); }
70% { transform: translate(-50%, -50%) rotate(-3deg); }
90% { transform: translate(-50%, -50%) rotate(3deg); }
}
.shake-weak { animation: shake-weak 2s ease-in-out infinite; }
.shake-medium { animation: shake-medium 1.5s ease-in-out infinite; }
.shake-strong { animation: shake-strong 1s ease-in-out infinite; }
.shake-very-strong { animation: shake-very-strong 0.5s ease-in-out infinite; }
a:hover { color: rgba(255,255,255,0.8) !important; }
</style>
<div style="text-align: right; margin-top: 5px; padding-right: 10px;">
<a href="http://192.168.50.114:5555/manage" target="_blank" style="color: rgba(255,255,255,0.3); text-decoration: none; font-size: 10px; transition: color 0.2s;">⚙️ Управление</a>
</div>
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" style="display:none;" onload="
(function() {
const timeline = document.getElementById('glance-timeline');
if (!timeline) return;
const eventItems = Array.from(timeline.querySelectorAll('.event-item'));
if (eventItems.length === 0) return;
// Применяем тряску в зависимости от позиции (чем левее - тем сильнее)
eventItems.forEach(item => {
const style = window.getComputedStyle(item);
const leftValue = parseFloat(style.left);
// Позиция в процентах: 0-25% = первая зона (0-14 дней)
if (leftValue <= 25) {
// Чем ближе к 0%, тем сильнее тряска
if (leftValue <= 5) {
item.classList.add('shake-very-strong');
} else if (leftValue <= 12) {
item.classList.add('shake-strong');
} else if (leftValue <= 18) {
item.classList.add('shake-medium');
} else {
item.classList.add('shake-weak');
}
}
});
const REPULSION_RADIUS = 80;
const REPULSION_STRENGTH = 20;
eventItems.forEach(hoverItem => {
hoverItem.addEventListener('mouseenter', function() {
const hoveredRect = hoverItem.getBoundingClientRect();
const timelineRect = timeline.getBoundingClientRect();
const hoveredX = hoveredRect.left + hoveredRect.width / 2 - timelineRect.left;
const hoveredY = hoveredRect.top + hoveredRect.height / 2 - timelineRect.top;
eventItems.forEach(item => {
if (item === hoverItem) return;
const itemRect = item.getBoundingClientRect();
const itemX = itemRect.left + itemRect.width / 2 - timelineRect.left;
const itemY = itemRect.top + itemRect.height / 2 - timelineRect.top;
const dx = itemX - hoveredX;
const dy = itemY - hoveredY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < REPULSION_RADIUS && distance > 0) {
const nx = dx / distance;
const ny = dy / distance;
const force = (1 - distance / REPULSION_RADIUS) * REPULSION_STRENGTH;
const offsetX = nx * force;
const offsetY = ny * force;
item.style.transform = 'translate(calc(-50% + ' + offsetX + 'px), calc(-50% + ' + offsetY + 'px))';
}
});
});
hoverItem.addEventListener('mouseleave', function() {
eventItems.forEach(item => {
if (item !== hoverItem) {
item.style.transform = 'translate(-50%, -50%)';
}
});
});
});
})();
">

187
widgets/qbittorrent.yml Normal file
View File

@@ -0,0 +1,187 @@
- type: custom-api
title: qBittorrent
cache: 10s
options:
view: "basic" # "basic" or "detailed"
mode: "default" # "default" or "upload"
subrequests:
transfer:
url: http://192.168.50.108:8080/api/v2/transfer/info
seeding:
url: http://192.168.50.108:8080/api/v2/torrents/info
parameters:
filter: seeding
leeching:
url: http://192.168.50.108:8080/api/v2/torrents/info
parameters:
filter: downloading
template: |
{{ $transfer := .Subrequest "transfer" }}
{{ $seeding := .Subrequest "seeding" }}
{{ $leeching := .Subrequest "leeching" }}
{{ if and (eq $transfer.Response.StatusCode 200) (eq $seeding.Response.StatusCode 200) (eq $leeching.Response.StatusCode 200) }}
{{ $isDetailed := eq (.Options.StringOr "view" "detailed") "detailed" }}
{{ $mode := .Options.StringOr "mode" "default" }}
{{ if $isDetailed }}
<!-- Detailed View -->
<div class="list" style="--list-gap: 15px;">
<div class="flex justify-between text-center">
<div>
{{ $dlSpeed := $transfer.JSON.Float "dl_info_speed" }}
{{ if eq $mode "upload" }}
<div class="color-highlight size-h3">{{ printf "%.1f MB/s" (div $dlSpeed 1000000.0) }}</div>
{{ else }}
{{ if lt $dlSpeed 1048576.0 }}
<div class="color-highlight size-h3">{{ printf "%.0f KiB/s" (div $dlSpeed 1024.0) }}</div>
{{ else }}
<div class="color-highlight size-h3">{{ printf "%.1f MiB/s" (div $dlSpeed 1048576.0) }}</div>
{{ end }}
{{ end }}
<div class="size-h6">DOWNLOADING</div>
</div>
{{ if eq $mode "upload" }}
<div>
{{ $ulSpeed := $transfer.JSON.Float "up_info_speed" }}
<div class="color-highlight size-h3">{{ printf "%.1f MB/s" (div $ulSpeed 1000000.0) }}</div>
<div class="size-h6">UPLOADING</div>
</div>
{{ end }}
<div>
<div class="color-highlight size-h3">{{ len ($seeding.JSON.Array "") }}</div>
<div class="size-h6">SEEDING</div>
</div>
{{ if eq $mode "default" }}
<div>
<div class="color-highlight size-h3">{{ len ($leeching.JSON.Array "") }}</div>
<div class="size-h6">LEECHING</div>
</div>
{{ end }}
</div>
<!-- Downloading list -->
{{ $downloadingTorrents := $leeching.JSON.Array "" }}
{{ if gt (len $downloadingTorrents) 0 }}
<div style="margin-top: 15px;">
<ul class="list collapsible-container" data-collapse-after="0" style="--list-gap: 15px;">
{{ range $t := $downloadingTorrents }}
{{ $state := $t.String "state" }}
{{ $icon := "?" }}
{{ if ge ($t.Int "completed") ($t.Int "size") }}{{ $icon = "✔" }}
{{ else if eq $state "downloading" "forcedDL" }}{{ $icon = "↓" }}
{{ else if eq $state "pausedDL" "stoppedDL" "pausedUP" "stalledDL" "stalledUP" "queuedDL" "queuedUP" }}{{ $icon = "❚❚" }}
{{ else if eq $state "error" "missingFiles" }}{{ $icon = "!" }}
{{ else if eq $state "checkingDL" "checkingUP" "allocating" }}{{ $icon = "…" }}
{{ else if eq $state "checkingResumeData" }}{{ $icon = "⟳" }}
{{ end }}
<li class="flex items-center" style="gap: 10px;">
<div class="size-h4" style="flex-shrink: 0;">{{ $icon }}</div>
<div style="flex-grow: 1; min-width: 0;">
<div class="text-truncate color-highlight">{{ $t.String "name" }}</div>
<div title="{{ $t.Float "progress" | mul 100 | printf "%.1f" }}%" style="background: rgba(128, 128, 128, 0.2); border-radius: 5px; height: 6px; margin-top: 5px; overflow: hidden;">
<div style="width: {{ $t.Float "progress" | mul 100 }}%; background-color: var(--color-positive); height: 100%; border-radius: 5px;"></div>
</div>
</div>
<div style="flex-shrink: 0; text-align: right; width: 80px;">
{{ $dlSpeed := $t.Float "dlspeed" }}
<div class="size-sm color-paragraph">
{{ if eq $mode "upload" }}
{{ if lt $dlSpeed 1000.0 }}--{{ else }}{{ printf "%.1f MB/s" (div $dlSpeed 1000000.0) }}{{ end }}
{{ else }}
{{ if lt $dlSpeed 1024.0 }}--{{ else if lt $dlSpeed 1048576.0 }}{{ printf "%.0f KiB/s" (div $dlSpeed 1024.0) }}{{ else }}{{ printf "%.1f MiB/s" (div $dlSpeed 1048576.0) }}{{ end }}
{{ end }}
</div>
{{ $eta := $t.Int "eta" }}
<div class="size-sm color-paragraph">
{{ if eq $eta 8640000 }}∞
{{ else if gt $eta 3600 }}{{ printf "%dh %dm" (div $eta 3600) (mod (div $eta 60) 60) }}
{{ else if gt $eta 0 }}{{ printf "%dm" (div $eta 60) }}
{{ else }}--{{ end }}
</div>
</div>
</li>
{{ end }}
</ul>
</div>
{{ end }}
<!-- Seeding list -->
{{ if eq $mode "upload" }}
{{ $seedingTorrents := $seeding.JSON.Array "" }}
{{ if gt (len $seedingTorrents) 0 }}
<div style="margin-top: 20px;">
<ul class="list collapsible-container" data-collapse-after="0" style="--list-gap: 15px;">
{{ range $t := $seedingTorrents }}
{{ $state := $t.String "state" }}
{{ $icon := "↑" }}
{{ if eq $state "pausedUP" "stoppedUP" "stalledUP" "queuedUP" }}{{ $icon = "❚❚" }}
{{ else if eq $state "error" "missingFiles" }}{{ $icon = "!" }}
{{ else if eq $state "checkingUP" }}{{ $icon = "…" }}
{{ else if eq $state "checkingResumeData" }}{{ $icon = "⟳" }}
{{ end }}
<li class="flex items-center" style="gap: 10px;">
<div class="size-h4" style="flex-shrink: 0;">{{ $icon }}</div>
<div style="flex-grow: 1; min-width: 0;">
<div class="text-truncate color-highlight">{{ $t.String "name" }}</div>
<div class="size-sm color-paragraph">
Ratio: {{ printf "%.2f" ($t.Float "ratio") }} |
Size: {{ printf "%.1f GB" (div ($t.Float "size") 1073741824.0) }}
</div>
</div>
<div style="flex-shrink: 0; text-align: right; width: 80px;">
{{ $ulSpeed := $t.Float "upspeed" }}
<div class="size-sm color-paragraph">
{{ if lt $ulSpeed 1000.0 }}--{{ else }}{{ printf "%.1f MB/s" (div $ulSpeed 1000000.0) }}{{ end }}
</div>
<div class="size-sm color-paragraph">Upload</div>
</div>
</li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}
</div>
{{ else }}
<!-- Basic View -->
<div class="flex justify-between text-center">
<div>
{{ $dlSpeed := $transfer.JSON.Float "dl_info_speed" }}
<div class="color-highlight size-h3">{{ printf "%.1f MB/s" (div $dlSpeed 1000000.0) }}</div>
<div class="size-h6">DOWNLOADING</div>
</div>
{{ if eq $mode "upload" }}
<div>
{{ $ulSpeed := $transfer.JSON.Float "up_info_speed" }}
<div class="color-highlight size-h3">{{ printf "%.1f MB/s" (div $ulSpeed 1000000.0) }}</div>
<div class="size-h6">UPLOADING</div>
</div>
{{ end }}
<div>
<div class="color-highlight size-h3">{{ len ($seeding.JSON.Array "") }}</div>
<div class="size-h6">SEEDING</div>
</div>
{{ if eq $mode "default" }}
<div>
<div class="color-highlight size-h3">{{ len ($leeching.JSON.Array "") }}</div>
<div class="size-h6">LEECHING</div>
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
<div class="color-negative text-center">
<p>Error fetching qBittorrent data.</p>
<p class="size-sm">Check URL and authentication bypass settings.</p>
</div>
{{ end }}