mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-27 00:56:40 +00:00
New version 1.2.5
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5. Highlights: - Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal. - LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle. - Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods. - Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73). - NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC. - Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install. Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// Deterministic OKLCH colouring for category badges. Shared between
|
||||
// the Apps dashboard and the LXC App tab so both surfaces show the
|
||||
// exact same colour for a given category name.
|
||||
//
|
||||
// Hue exclusions
|
||||
// --------------
|
||||
// Two bands are skipped because their meaning is already reserved by
|
||||
// the rest of the Monitor and a chip in those hues on the same view
|
||||
// would be visually confusing:
|
||||
// * 260–319° purple/violet — "update available" (ArrowUpCircle)
|
||||
// * 340–19° red — error / danger signal
|
||||
// Green and yellow ARE used elsewhere for health status, but only as
|
||||
// tiny dots in other views — a chip in those hues on an app card
|
||||
// carries no false meaning, so they stay in the allowed range.
|
||||
//
|
||||
// Allowed ranges after the exclusions:
|
||||
// [20, 260) ∪ [320, 340) = 240° + 20° = 260° of usable hues.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function hueForCategory(text: string): number {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
|
||||
}
|
||||
const raw = Math.abs(hash) % 260
|
||||
if (raw < 240) return 20 + raw // 0-239 → 20-259 (orange..blue)
|
||||
return 320 + (raw - 240) // 240-259 → 320-339 (pink/magenta)
|
||||
}
|
||||
|
||||
// OKLCH is perceptually uniform — L=0.80 looks equally bright for a
|
||||
// blue and a yellow. HSL fails this because eyes weight green/yellow
|
||||
// more, so the same L% renders visually darker for blues.
|
||||
export function categoryChipStyle(text: string, isLight: boolean): {
|
||||
backgroundColor: string
|
||||
color: string
|
||||
borderColor: string
|
||||
} {
|
||||
const h = hueForCategory(text)
|
||||
if (isLight) {
|
||||
return {
|
||||
backgroundColor: `oklch(0.55 0.20 ${h} / 0.14)`,
|
||||
color: `oklch(0.42 0.19 ${h})`,
|
||||
borderColor: `oklch(0.55 0.20 ${h} / 0.5)`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundColor: `oklch(0.60 0.16 ${h} / 0.18)`,
|
||||
color: `oklch(0.80 0.16 ${h})`,
|
||||
borderColor: `oklch(0.60 0.16 ${h} / 0.55)`,
|
||||
}
|
||||
}
|
||||
|
||||
// Read the effective theme from next-themes' hooks on <html>:
|
||||
// `class="dark|light"` (Tailwind class strategy) or `data-theme`.
|
||||
// Falls back to the OS setting when the user hasn't chosen one.
|
||||
export function readIsLightTheme(): boolean {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return false
|
||||
const el = document.documentElement
|
||||
if (el.classList.contains("dark")) return false
|
||||
if (el.classList.contains("light")) return true
|
||||
const attr = el.getAttribute("data-theme")
|
||||
if (attr === "light") return true
|
||||
if (attr === "dark") return false
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
}
|
||||
|
||||
// React hook — recomputes when the user toggles theme or the OS pref
|
||||
// flips. Watches <html>'s attributes (data-theme + class) and the
|
||||
// system media query. Used by any component that renders category
|
||||
// chips so they stay legible after a theme change.
|
||||
export function useIsLightTheme(): boolean {
|
||||
const [isLight, setIsLight] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
const update = () => setIsLight(readIsLightTheme())
|
||||
update()
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)")
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "class"],
|
||||
})
|
||||
mq.addEventListener("change", update)
|
||||
return () => { observer.disconnect(); mq.removeEventListener("change", update) }
|
||||
}, [])
|
||||
return isLight
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
|
||||
// Persistent top-level tab order for the Monitor dashboard.
|
||||
//
|
||||
// Only the seven top-level slots are user-orderable; the internal
|
||||
// items of the Node and Admin dropdowns keep their canonical order —
|
||||
// grouped items move as a single unit.
|
||||
|
||||
export type TabId = "overview" | "apps" | "vms" | "node" | "backup" | "terminal" | "admin"
|
||||
|
||||
export const DEFAULT_TAB_ORDER: TabId[] = [
|
||||
"overview",
|
||||
"apps",
|
||||
"vms",
|
||||
"node",
|
||||
"backup",
|
||||
"terminal",
|
||||
"admin",
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "proxmenux-nav-order"
|
||||
const CHANGE_EVENT = "proxmenux-nav-order-changed"
|
||||
|
||||
function isTabId(v: unknown): v is TabId {
|
||||
return typeof v === "string" && (DEFAULT_TAB_ORDER as string[]).includes(v)
|
||||
}
|
||||
|
||||
// Read + normalise: unknown ids are dropped, missing ones are
|
||||
// appended in their default position so a future release adding a
|
||||
// new tab still surfaces it for users with a stored order.
|
||||
export function readTabOrder(): TabId[] {
|
||||
if (typeof window === "undefined") return DEFAULT_TAB_ORDER
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_TAB_ORDER
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return DEFAULT_TAB_ORDER
|
||||
const seen = new Set<TabId>()
|
||||
const clean: TabId[] = []
|
||||
for (const item of parsed) {
|
||||
if (isTabId(item) && !seen.has(item)) {
|
||||
clean.push(item)
|
||||
seen.add(item)
|
||||
}
|
||||
}
|
||||
for (const id of DEFAULT_TAB_ORDER) {
|
||||
if (!seen.has(id)) clean.push(id)
|
||||
}
|
||||
return clean
|
||||
} catch {
|
||||
return DEFAULT_TAB_ORDER
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTabOrder(order: TabId[]): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
// Storage full / disabled — the in-memory state still updates.
|
||||
}
|
||||
}
|
||||
|
||||
// Map a top-level slot id to the concrete `activeTab` value the
|
||||
// Tabs component uses. Direct tabs pass through; grouped slots
|
||||
// (Node/Admin) resolve to the first child in the dropdown so the
|
||||
// dashboard lands on a real tab, not a group header.
|
||||
const GROUP_FIRST_CHILD: Record<TabId, string> = {
|
||||
overview: "overview",
|
||||
apps: "apps",
|
||||
vms: "vms",
|
||||
node: "storage",
|
||||
backup: "backup",
|
||||
terminal: "terminal",
|
||||
admin: "logs",
|
||||
}
|
||||
|
||||
export function firstActualTab(order: TabId[] = readTabOrder()): string {
|
||||
const head = order[0]
|
||||
return (head && GROUP_FIRST_CHILD[head]) || "overview"
|
||||
}
|
||||
|
||||
export function resetTabOrder(): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that keeps every consumer in sync. Firing a custom event on
|
||||
// write means the Settings card and the top navigation update in the
|
||||
// same tick without prop-drilling.
|
||||
export function useTabOrder(): {
|
||||
order: TabId[]
|
||||
setOrder: (next: TabId[]) => void
|
||||
reset: () => void
|
||||
isCustom: boolean
|
||||
} {
|
||||
const [order, setOrderState] = useState<TabId[]>(DEFAULT_TAB_ORDER)
|
||||
|
||||
useEffect(() => {
|
||||
setOrderState(readTabOrder())
|
||||
const onChange = () => setOrderState(readTabOrder())
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setOrder = useCallback((next: TabId[]) => {
|
||||
writeTabOrder(next)
|
||||
setOrderState(next)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetTabOrder()
|
||||
setOrderState(DEFAULT_TAB_ORDER)
|
||||
}, [])
|
||||
|
||||
const isCustom =
|
||||
order.length !== DEFAULT_TAB_ORDER.length ||
|
||||
order.some((id, idx) => id !== DEFAULT_TAB_ORDER[idx])
|
||||
|
||||
return { order, setOrder, reset, isCustom }
|
||||
}
|
||||
@@ -8,4 +8,4 @@
|
||||
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
|
||||
//
|
||||
// Keep the three in sync on every bump.
|
||||
export const APP_VERSION = "1.2.4.2-beta"
|
||||
export const APP_VERSION = "1.2.5"
|
||||
|
||||
Reference in New Issue
Block a user