diff --git a/README.md b/README.md index 9e1c92d..f629831 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,17 @@ - состояние, заполнение, фрагментацию и scrub/resilver ZFS-пулов; - состояние UPS через NUT или apcupsd, заряд, автономность, нагрузку и напряжение. - состояние VM и LXC, CPU, память, выделенные ядра и uptime. +- IP-адреса VM через QEMU Guest Agent, автозапуск, storage, заполнение диска и + суммарный сетевой трафик гостей; - последнюю найденную резервную копию каждой VM/LXC, storage, размер и возраст. +- версию Proxmox, запущенное и последнее установленное ядро, доступные + обновления, необходимость перезагрузки, последнее обновление системы, + ближайшие backup и ZFS scrub. Через кнопку настроек можно скрывать неиспользуемые карточки и переключать -светлую или тёмную тему. Выбор сохраняется локально в браузере. +светлую или тёмную тему. Карточки можно переставлять перетаскиванием или +стрелками в настройках. Видимость, порядок и тема сохраняются локально в +браузере. Центр уведомлений объединяет критические события, предупреждения и информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и diff --git a/alerts.go b/alerts.go index 6109f1b..c27fd03 100644 --- a/alerts.go +++ b/alerts.go @@ -26,6 +26,12 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler } else if metrics.Update.State == "error" { add("dashboard-update-error", "warning", "Dashboard", "Не удалось проверить обновления", metrics.Update.Message) } + if metrics.Maintenance.RebootRequired || metrics.Maintenance.NewKernelAvailable { + add("maintenance-reboot", "warning", "Proxmox", "Требуется перезагрузка хоста", "Установлено новое ядро или система запросила перезагрузку.") + } + if metrics.Maintenance.AvailableUpdates > 0 { + add("maintenance-updates", "info", "Proxmox", "Доступны обновления системы", fmt.Sprintf("Пакетов для обновления: %d.", metrics.Maintenance.AvailableUpdates)) + } if metrics.CPU.UsagePercent >= thresholds.CPUCritical { add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent)) diff --git a/guests.go b/guests.go index 50b8e4c..afe4c79 100644 --- a/guests.go +++ b/guests.go @@ -3,24 +3,35 @@ package main import ( "context" "encoding/json" + "fmt" "os/exec" "sort" + "strconv" + "strings" "sync" "time" ) type GuestMetrics struct { - VMID int `json:"vmid"` - Name string `json:"name"` - Type string `json:"type"` - Node string `json:"node"` - Status string `json:"status"` - CPU float64 `json:"cpu"` - MaxCPU float64 `json:"maxcpu"` - MemoryBytes uint64 `json:"mem"` - MaxMemory uint64 `json:"maxmem"` - Uptime uint64 `json:"uptime"` - Template int `json:"template"` + VMID int `json:"vmid"` + Name string `json:"name"` + Type string `json:"type"` + Node string `json:"node"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + MaxCPU float64 `json:"maxcpu"` + MemoryBytes uint64 `json:"mem"` + MaxMemory uint64 `json:"maxmem"` + Uptime uint64 `json:"uptime"` + Template int `json:"template"` + DiskBytes uint64 `json:"disk"` + MaxDisk uint64 `json:"maxdisk"` + NetInBytes uint64 `json:"netin"` + NetOutBytes uint64 `json:"netout"` + OnBoot bool `json:"onboot"` + Agent bool `json:"agent"` + IPAddresses []string `json:"ipAddresses"` + Storage []string `json:"storage"` } type GuestsMetrics struct { @@ -29,9 +40,17 @@ type GuestsMetrics struct { } type guestCollector struct { - mu sync.Mutex - cached GuestsMetrics - updatedAt time.Time + mu sync.Mutex + cached GuestsMetrics + updatedAt time.Time + details map[int]guestDetails + detailsUpdatedAt time.Time + detailsRefreshing bool +} + +type guestDetails struct { + OnBoot, Agent bool + IPAddresses, Storage []string } func (c *guestCollector) collect() GuestsMetrics { @@ -41,10 +60,143 @@ func (c *guestCollector) collect() GuestsMetrics { return c.cached } c.cached = readGuests() + if time.Since(c.detailsUpdatedAt) > time.Minute && !c.detailsRefreshing { + c.detailsRefreshing = true + guests := append([]GuestMetrics(nil), c.cached.Guests...) + go func() { + details := readGuestDetails(guests) + c.mu.Lock() + c.details = details + c.detailsUpdatedAt = time.Now() + c.detailsRefreshing = false + c.mu.Unlock() + }() + } + for i := range c.cached.Guests { + if detail, ok := c.details[c.cached.Guests[i].VMID]; ok { + c.cached.Guests[i].OnBoot = detail.OnBoot + c.cached.Guests[i].Agent = detail.Agent + c.cached.Guests[i].IPAddresses = detail.IPAddresses + c.cached.Guests[i].Storage = detail.Storage + } + } c.updatedAt = time.Now() return c.cached } +func readGuestDetails(guests []GuestMetrics) map[int]guestDetails { + result := map[int]guestDetails{} + for _, guest := range guests { + if guest.Template != 0 { + continue + } + kind := guest.Type + if kind != "qemu" { + kind = "lxc" + } + data := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/"+kind+"/"+strconv.Itoa(guest.VMID)+"/config", "--output-format", "json") + var config map[string]any + if json.Unmarshal([]byte(data), &config) != nil { + continue + } + detail := guestDetails{} + detail.OnBoot = numberValue(config["onboot"]) == 1 + detail.Agent = agentEnabled(config["agent"]) + storageSet := map[string]bool{} + ipSet := map[string]bool{} + for key, value := range config { + s := fmt.Sprint(value) + if key == "rootfs" || strings.HasPrefix(key, "scsi") || strings.HasPrefix(key, "sata") || strings.HasPrefix(key, "virtio") { + if name := storageName(s); name != "" { + storageSet[name] = true + } + } + if strings.HasPrefix(key, "net") || strings.HasPrefix(key, "ipconfig") { + for _, ip := range ipsFromConfig(s) { + ipSet[ip] = true + } + } + } + if guest.Status == "running" && kind == "qemu" { + agent := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/qemu/"+strconv.Itoa(guest.VMID)+"/agent/network-get-interfaces", "--output-format", "json") + for _, ip := range ipsFromAgent(agent) { + ipSet[ip] = true + } + if agent != "" { + detail.Agent = true + } + } + for value := range storageSet { + detail.Storage = append(detail.Storage, value) + } + for value := range ipSet { + detail.IPAddresses = append(detail.IPAddresses, value) + } + sort.Strings(detail.Storage) + sort.Strings(detail.IPAddresses) + result[guest.VMID] = detail + } + return result +} + +func numberValue(value any) float64 { + switch v := value.(type) { + case float64: + return v + case string: + n, _ := strconv.ParseFloat(v, 64) + return n + } + return 0 +} +func agentEnabled(value any) bool { + s := strings.ToLower(fmt.Sprint(value)) + return s == "1" || strings.Contains(s, "enabled=1") +} +func storageName(value string) string { + first := strings.SplitN(value, ",", 2)[0] + parts := strings.SplitN(first, ":", 2) + if len(parts) == 2 { + return parts[0] + } + return "" +} +func ipsFromConfig(value string) []string { + var result []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "ip=") || strings.HasPrefix(part, "ip6=") { + ip := strings.SplitN(part, "=", 2)[1] + if ip != "dhcp" && ip != "auto" { + result = append(result, strings.SplitN(ip, "/", 2)[0]) + } + } + } + return result +} +func ipsFromAgent(data string) []string { + var payload struct { + Result []struct { + IPAddresses []struct { + Address string `json:"ip-address"` + Type string `json:"ip-address-type"` + } `json:"ip-addresses"` + } `json:"result"` + } + if json.Unmarshal([]byte(data), &payload) != nil { + return nil + } + var result []string + for _, iface := range payload.Result { + for _, ip := range iface.IPAddresses { + if ip.Address != "127.0.0.1" && ip.Address != "::1" && !strings.HasPrefix(ip.Address, "fe80:") { + result = append(result, ip.Address) + } + } + } + return result +} + func readGuests() GuestsMetrics { if _, err := exec.LookPath("pvesh"); err != nil { return GuestsMetrics{} diff --git a/guests_test.go b/guests_test.go index e1540f3..4728d96 100644 --- a/guests_test.go +++ b/guests_test.go @@ -15,3 +15,16 @@ func TestGuestJSON(t *testing.T) { t.Fatalf("неожиданные данные гостя: %+v", guest) } } + +func TestGuestConfigHelpers(t *testing.T) { + if got := storageName("local-zfs:vm-101-disk-0,size=32G"); got != "local-zfs" { + t.Fatalf("storage: %s", got) + } + ips := ipsFromConfig("name=eth0,bridge=vmbr0,ip=192.168.0.20/24,ip6=auto") + if len(ips) != 1 || ips[0] != "192.168.0.20" { + t.Fatalf("ips: %+v", ips) + } + if !agentEnabled("enabled=1,fstrim_cloned_disks=1") { + t.Fatal("agent should be enabled") + } +} diff --git a/maintenance.go b/maintenance.go new file mode 100644 index 0000000..b79c742 --- /dev/null +++ b/maintenance.go @@ -0,0 +1,264 @@ +package main + +import ( + "bufio" + "compress/gzip" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type MaintenanceMetrics struct { + Available bool `json:"available"` + ProxmoxVersion string `json:"proxmoxVersion"` + KernelVersion string `json:"kernelVersion"` + LatestInstalledKernel string `json:"latestInstalledKernel"` + NewKernelAvailable bool `json:"newKernelAvailable"` + RebootRequired bool `json:"rebootRequired"` + AvailableUpdates int `json:"availableUpdates"` + LastSystemUpdate time.Time `json:"lastSystemUpdate"` + NextBackup string `json:"nextBackup"` + NextScrub string `json:"nextScrub"` + CheckedAt time.Time `json:"checkedAt"` +} + +type maintenanceCollector struct { + mu sync.Mutex + cached MaintenanceMetrics + updatedAt time.Time + refreshing bool +} + +func (c *maintenanceCollector) collect() MaintenanceMetrics { + c.mu.Lock() + if time.Since(c.updatedAt) >= 15*time.Minute && !c.refreshing { + c.refreshing = true + go func() { + metrics := readMaintenance() + c.mu.Lock() + c.cached = metrics + c.updatedAt = time.Now() + c.refreshing = false + c.mu.Unlock() + }() + } + result := c.cached + c.mu.Unlock() + return result +} + +func commandOutput(timeout time.Duration, name string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + command := exec.CommandContext(ctx, name, args...) + command.Env = append(os.Environ(), "LC_ALL=C") + output, err := command.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func readMaintenance() MaintenanceMetrics { + result := MaintenanceMetrics{Available: true, CheckedAt: time.Now()} + result.ProxmoxVersion = firstLine(commandOutput(5*time.Second, "pveversion")) + result.KernelVersion = commandOutput(3*time.Second, "uname", "-r") + result.RebootRequired = fileExists("/var/run/reboot-required") + apt := commandOutput(45*time.Second, "apt-get", "-s", "-o", "Debug::NoLocking=1", "upgrade") + for _, line := range strings.Split(apt, "\n") { + if strings.HasPrefix(line, "Inst ") { + result.AvailableUpdates++ + } + } + result.LastSystemUpdate = lastAPTUpdate("/var/log/apt/history.log") + installed := commandOutput(8*time.Second, "dpkg-query", "-W", "-f=${Package} ${Version}\\n", "pve-kernel-*", "proxmox-kernel-*") + result.LatestInstalledKernel = newestKernel(installed) + result.NewKernelAvailable = result.LatestInstalledKernel != "" && !strings.Contains(result.KernelVersion, result.LatestInstalledKernel) + result.NextBackup = nextBackupSchedule() + result.NextScrub = nextScrubTimer(commandOutput(8*time.Second, "systemctl", "list-timers", "--all", "--no-legend", "--plain")) + if result.NextScrub == "" { + result.NextScrub = nextZFSScrubCron("/etc/cron.d/zfsutils-linux", time.Now()) + } + return result +} + +func firstLine(value string) string { + if i := strings.IndexByte(value, '\n'); i >= 0 { + return value[:i] + } + return value +} +func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } + +func lastAPTUpdate(path string) time.Time { + file, err := os.Open(path) + if err == nil { + defer file.Close() + if result := scanAPTUpdate(file); !result.IsZero() { + return result + } + } + files, _ := filepath.Glob(path + ".*.gz") + sort.Slice(files, func(i, j int) bool { + a, _ := os.Stat(files[i]) + b, _ := os.Stat(files[j]) + return a.ModTime().After(b.ModTime()) + }) + for _, name := range files { + compressed, err := os.Open(name) + if err != nil { + continue + } + reader, err := gzip.NewReader(compressed) + if err != nil { + compressed.Close() + continue + } + result := scanAPTUpdate(reader) + reader.Close() + compressed.Close() + if !result.IsZero() { + return result + } + } + return time.Time{} +} +func scanAPTUpdate(reader io.Reader) time.Time { + scanner := bufio.NewScanner(reader) + var result time.Time + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "End-Date:") { + value := strings.TrimSpace(strings.TrimPrefix(line, "End-Date:")) + if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil { + result = parsed + } + } + } + return result +} + +var versionNumbers = regexp.MustCompile(`\d+`) + +func newestKernel(output string) string { + var versions []string + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) > 1 && !strings.Contains(fields[0], "helper") { + versions = append(versions, fields[1]) + } + } + sort.Slice(versions, func(i, j int) bool { return compareNumericVersion(versions[i], versions[j]) > 0 }) + if len(versions) > 0 { + return versions[0] + } + return "" +} +func compareNumericVersion(a, b string) int { + aa, bb := versionNumbers.FindAllString(a, -1), versionNumbers.FindAllString(b, -1) + for i := 0; i < len(aa) || i < len(bb); i++ { + av, bv := "0", "0" + if i < len(aa) { + av = aa[i] + } + if i < len(bb) { + bv = bb[i] + } + if len(av) != len(bv) { + if len(av) > len(bv) { + return 1 + } + return -1 + } + if av != bv { + if av > bv { + return 1 + } + return -1 + } + } + return 0 +} + +func nextBackupSchedule() string { + data := commandOutput(8*time.Second, "pvesh", "get", "/cluster/backup", "--output-format", "json") + if data == "" { + return "" + } + var jobs []struct { + Enabled *int `json:"enabled"` + Schedule string `json:"schedule"` + } + if json.Unmarshal([]byte(data), &jobs) != nil { + return "" + } + var next []string + for _, job := range jobs { + if (job.Enabled != nil && *job.Enabled == 0) || job.Schedule == "" { + continue + } + calendar := commandOutput(5*time.Second, "systemd-analyze", "calendar", "--iterations=1", job.Schedule) + for _, line := range strings.Split(calendar, "\n") { + if strings.Contains(line, "Next elapse:") { + trimmed := strings.TrimSpace(line) + next = append(next, strings.TrimSpace(strings.TrimPrefix(trimmed, "Next elapse:"))) + } + } + } + sort.Strings(next) + if len(next) > 0 { + return next[0] + } + return "" +} +func nextScrubTimer(output string) string { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(strings.ToLower(line), "zfs") && strings.Contains(strings.ToLower(line), "scrub") { + fields := strings.Fields(line) + if len(fields) >= 4 { + return strings.Join(fields[:4], " ") + } + } + } + return "" +} + +func nextZFSScrubCron(path string, now time.Time) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.Contains(line, "/usr/lib/zfs-linux/scrub") { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 { + return "" + } + minute, _ := strconv.Atoi(fields[0]) + hour, _ := strconv.Atoi(fields[1]) + rangeParts := strings.SplitN(fields[2], "-", 2) + if len(rangeParts) != 2 { + return "" + } + from, _ := strconv.Atoi(rangeParts[0]) + to, _ := strconv.Atoi(rangeParts[1]) + for offset := 0; offset < 70; offset++ { + day := time.Date(now.Year(), now.Month(), now.Day()+offset, hour, minute, 0, 0, now.Location()) + if day.After(now) && day.Day() >= from && day.Day() <= to && day.Weekday() == time.Sunday { + return day.Format("Mon 2006-01-02 15:04 MST") + } + } + } + return "" +} diff --git a/maintenance_test.go b/maintenance_test.go new file mode 100644 index 0000000..7e4fac0 --- /dev/null +++ b/maintenance_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestNewestKernel(t *testing.T) { + output := "proxmox-kernel-6.8 6.8.12-5\nproxmox-kernel-6.14 6.14.8-2" + if got := newestKernel(output); got != "6.14.8-2" { + t.Fatalf("unexpected newest kernel: %s", got) + } +} + +func TestNextZFSScrubCron(t *testing.T) { + path := filepath.Join(t.TempDir(), "zfs") + if err := os.WriteFile(path, []byte("24 0 8-14 * * root /usr/lib/zfs-linux/scrub\n"), 0600); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + if got := nextZFSScrubCron(path, now); got != "Sun 2026-08-09 00:24 UTC" { + t.Fatalf("unexpected cron scrub: %s", got) + } +} + +func TestNextScrubTimer(t *testing.T) { + line := "Sun 2026-08-02 00:24:00 MSK 1 day left zfs-scrub-weekly@tank.timer" + if got := nextScrubTimer(line); got != "Sun 2026-08-02 00:24:00 MSK" { + t.Fatalf("unexpected scrub: %s", got) + } +} diff --git a/store.go b/store.go index 63a977b..eebbe0b 100644 --- a/store.go +++ b/store.go @@ -138,7 +138,7 @@ func (s *Store) SaveThresholds(value AlertThresholds) error { } func defaultEmailSettings() EmailSettings { - return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Dashboard"}} + return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard"}} } func (s *Store) EmailSettings(includePassword bool) EmailSettings { diff --git a/web.go b/web.go index 055e056..3fbd444 100644 --- a/web.go +++ b/web.go @@ -10,21 +10,22 @@ import ( ) type dashboardMetrics struct { - CPU CPUMetrics `json:"cpu"` - Memory MemoryMetrics `json:"memory"` - Storage StorageMetrics `json:"storage"` - Disks []DiskMetrics `json:"disks"` - Network NetworkMetrics `json:"network"` - ZFS ZFSMetrics `json:"zfs"` - UPS UPSMetrics `json:"ups"` - Guests GuestsMetrics `json:"guests"` - Backups BackupMetrics `json:"backups"` - Alerts []Alert `json:"alerts"` - Update UpdateStatus `json:"update"` - Timestamp time.Time `json:"timestamp"` + CPU CPUMetrics `json:"cpu"` + Memory MemoryMetrics `json:"memory"` + Storage StorageMetrics `json:"storage"` + Disks []DiskMetrics `json:"disks"` + Network NetworkMetrics `json:"network"` + ZFS ZFSMetrics `json:"zfs"` + UPS UPSMetrics `json:"ups"` + Guests GuestsMetrics `json:"guests"` + Backups BackupMetrics `json:"backups"` + Alerts []Alert `json:"alerts"` + Update UpdateStatus `json:"update"` + Maintenance MaintenanceMetrics `json:"maintenance"` + Timestamp time.Time `json:"timestamp"` } -func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, store *Store) (dashboardMetrics, error) { +func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, maintenanceCollector *maintenanceCollector, store *Store) (dashboardMetrics, error) { cpu, err := cpuCollector.collect() if err != nil { return dashboardMetrics{}, err @@ -45,7 +46,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle CPU: cpu, Memory: memory, Storage: storage, Disks: diskCollector.collect(), Network: network, ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), - Guests: guestCollector.collect(), Backups: backupCollector.collect(), Update: readUpdateStatus(), Timestamp: time.Now(), + Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Update: readUpdateStatus(), Timestamp: time.Now(), } metrics.Alerts = evaluateAlerts(metrics, store.Thresholds()) metrics.Alerts, err = store.SyncAlerts(metrics.Alerts) @@ -63,8 +64,9 @@ func routes(collector *cpuCollector, store *Store) http.Handler { ups := &upsCollector{} guests := &guestCollector{} backups := &backupCollector{} + maintenance := &maintenanceCollector{} collect := func() (dashboardMetrics, error) { - return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store) + return collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, store) } go runHistoryCollector(collect, store) go runEmailNotifier(collect, store) @@ -241,6 +243,7 @@ html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9 footer{color:#657581;font-size:12px;margin-top:17px} @media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs{overflow-x:auto}} .save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}.ack-alert{margin-top:11px;padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font-size:11px;cursor:pointer}.history-state{display:inline-block;margin-top:8px;color:var(--muted);font-size:11px}.alert-panel[hidden]{display:none} +.maintenance .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.card-order{display:grid;gap:7px;margin-top:11px}.order-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.order-item.dragging{opacity:.45}.order-handle{cursor:grab;color:var(--muted);font-size:17px}.order-item span:nth-child(2){flex:1}.order-button{width:30px;height:28px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);cursor:pointer}