Compare commits

...
12 changed files with 132 additions and 109 deletions

No files matched your search

+3
View File
@@ -1,4 +1,5 @@
export const enMonitorMessages = {
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
"monitor.password": "Password (this run only)",
@@ -43,6 +44,8 @@ export const enMonitorMessages = {
"monitor.diskCapacityUnknown": "Capacity unavailable",
"monitor.rootDisk": "Root partition",
"monitor.network": "Network",
"monitor.download": "Download",
"monitor.upload": "Upload",
"monitor.updated": "Updated",
"monitor.updatedAt": "Updated {time}",
"monitor.noSamples": "Waiting for the first reading",
+3
View File
@@ -1,4 +1,5 @@
export const esMonitorMessages = {
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
"monitor.password": "Contraseña (solo esta ejecución)",
@@ -43,6 +44,8 @@ export const esMonitorMessages = {
"monitor.diskCapacityUnknown": "Capacidad no disponible",
"monitor.rootDisk": "Partición raíz",
"monitor.network": "Red",
"monitor.download": "Descarga",
"monitor.upload": "Subida",
"monitor.updated": "Actualizado",
"monitor.updatedAt": "Actualizado: {time}",
"monitor.noSamples": "Esperando la primera lectura",
+3
View File
@@ -1,4 +1,5 @@
export const ruMonitorMessages = {
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
"monitor.password": "Пароль (только для этого запуска)",
@@ -42,6 +43,8 @@ export const ruMonitorMessages = {
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
"monitor.rootDisk": "Корневой раздел",
"monitor.network": "Сеть",
"monitor.download": "Загрузка",
"monitor.upload": "Отправка",
"monitor.updated": "Обновлено",
"monitor.updatedAt": "Обновлено: {time}",
"monitor.noSamples": "Ожидание первого измерения",
@@ -1,4 +1,5 @@
export const zhCNMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
"monitor.password": "密码(仅本次使用)",
@@ -41,6 +42,8 @@ export const zhCNMonitorMessages = {
"monitor.diskCapacityUnknown": "暂无容量读数",
"monitor.rootDisk": "根分区",
"monitor.network": "网络",
"monitor.download": "下载",
"monitor.upload": "上传",
"monitor.updated": "更新时间",
"monitor.updatedAt": "更新于 {time}",
"monitor.noSamples": "等待第一次读数",
@@ -1,4 +1,5 @@
export const zhTWMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
"monitor.password": "密碼(僅本次使用)",
@@ -41,6 +42,8 @@ export const zhTWMonitorMessages = {
"monitor.diskCapacityUnknown": "暫無容量讀數",
"monitor.rootDisk": "根分割區",
"monitor.network": "網路",
"monitor.download": "下載",
"monitor.upload": "上傳",
"monitor.updated": "更新時間",
"monitor.updatedAt": "更新於 {time}",
"monitor.noSamples": "等待第一次讀數",
+2 -4
View File
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
data-active={isActive ? 'true' : undefined}
className={cn(
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
@@ -487,9 +487,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
aria-label={t('selectHost.toggleHost', { name: host.label })}
className={cn(
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
// Keep keyboard cursor visible even when the host is already selected.
isActive && 'ring-1 ring-primary/40',
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
+3 -7
View File
@@ -34,13 +34,9 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
assert.match(source, /navigableIndexByKey/);
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
assert.doesNotMatch(source, /navigable\.findIndex/);
// Selected hosts must still show the keyboard active ring.
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
assert.doesNotMatch(
source,
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
);
// The keyboard cursor stays visible on selected hosts through background alone.
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
// One listbox tab stop — not per-host tabIndex under virtualization.
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
+4 -2
View File
@@ -173,16 +173,18 @@ export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: strin
// Action Menu Component (for the ... button)
interface AsideActionMenuProps {
children: ReactNode;
triggerClassName?: string;
ariaLabel?: string;
}
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children, triggerClassName, ariaLabel }) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
<MoreVertical size={18} />
</button>
</PopoverTrigger>
+2 -15
View File
@@ -1,7 +1,6 @@
import React from "react";
import { Gauge } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useMonitorSnapshot } from "../../application/state/monitorRuntimeStore";
import { cn } from "../../lib/utils";
import { RippleButton } from "../ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@@ -15,13 +14,13 @@ export function MonitorNavButton({
onClick: () => void;
}) {
const { t } = useI18n();
const { count } = useMonitorSnapshot();
return (
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={active ? "secondary" : "ghost"}
onClick={onClick}
aria-label={t("monitor.title")}
className={cn(
"w-full h-10",
collapsed ? "justify-center p-0" : "justify-start gap-3",
@@ -30,19 +29,7 @@ export function MonitorNavButton({
)}
>
<Gauge size={16} className="shrink-0" />
{!collapsed && (
<>
<span>{t("monitor.title")}</span>
{count > 0 && (
<span className="ml-auto text-xs text-primary tabular-nums">
{count}
</span>
)}
</>
)}
{collapsed && count > 0 && (
<span className="text-[10px] text-primary">{count}</span>
)}
{!collapsed && <span>{t("monitor.title")}</span>}
</RippleButton>
</TooltipTrigger>
<TooltipContent side="right">{t("monitor.title")}</TooltipContent>
+5 -1
View File
@@ -158,7 +158,11 @@ for (const view of ["list", "charts"] as const) {
assert.ok(icon);
assert.equal((icon.parentElement as HTMLElement).style.backgroundColor, "rgb(147, 51, 234)");
assert.ok(document.querySelector('img[src="/distro/ubuntu.svg"]'));
assert.ok(document.querySelector(".lucide-gauge"));
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const network = document.querySelector("tbody tr")!.children[5];
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
}
+96 -80
View File
@@ -42,7 +42,6 @@ import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import { Combobox } from "../ui/combobox";
import { ScrollArea } from "../ui/scroll-area";
import {
Select,
SelectContent,
@@ -69,7 +68,7 @@ import SelectHostPanel from "../SelectHostPanel";
import { DistroAvatar } from "../DistroAvatar";
import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { VaultHeaderSearch, VaultPageHeader } from "./VaultPageHeader";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { toast } from "../ui/toast";
const emptyMonitorPoints: MonitorPoint[] = [];
@@ -451,19 +450,87 @@ export function MonitorPanels({
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
data-section="vault-monitor"
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<VaultPageHeader>
<Gauge size={18} />
<h1 className="font-semibold text-sm">{t("monitor.title")}</h1>
<div className="flex-1" />
{!!runtime.count && (
<Button variant="ghost" size="sm" onClick={monitorActions.stopAll}>
{t("monitor.stopAll")}
<div className="flex min-h-0 min-w-0 flex-1 flex-col [container-type:inline-size] [container-name:monitor-main]">
<VaultPageHeader contentClassName="gap-2 overflow-x-auto [scrollbar-width:none]">
{panel && (
<>
<Combobox
options={panels.map((p) => ({
value: p.id,
label: p.name,
sublabel: p.description,
}))}
value={panel.id}
onValueChange={(id) => {
setSelected(id);
setDetail(null);
setEditor(null);
}}
triggerClassName="w-56 min-w-24 shrink"
clearable={false}
ariaLabel={t("monitor.choosePanel")}
/>
<Button
variant="ghost"
size="icon"
className={cn(vaultHeaderIconButtonClass, "shrink-0")}
aria-label={t("monitor.edit")}
onClick={() => startEdit(panel)}
>
<Pencil size={15} />
</Button>
<AsideActionMenu triggerClassName={cn(vaultHeaderIconButtonClass, "shrink-0 inline-flex items-center justify-center")} ariaLabel={t("monitor.actions")}>
<AsideActionMenuItem
onClick={() => {
void duplicate();
}}
>
<Copy size={14} className="mr-2" />
{t("monitor.copy")}
</AsideActionMenuItem>
<AsideActionMenuItem
variant="destructive"
onClick={() => setDeleting(panel)}
>
<Trash2 size={14} className="mr-2" />
{t("monitor.delete")}
</AsideActionMenuItem>
{!!runtime.count && (
<AsideActionMenuItem onClick={monitorActions.stopAll}>
<Square size={14} className="mr-2" />
{t("monitor.stopAll")}
</AsideActionMenuItem>
)}
</AsideActionMenu>
</>
)}
<div className="min-w-0 flex-1" />
{panel && (
<Button
variant="secondary"
size="sm"
className={cn(vaultHeaderSecondaryButtonClass, "shrink-0")}
aria-label={t(running ? "monitor.stop" : "monitor.start")}
title={t(running ? "monitor.stop" : "monitor.start")}
onClick={() =>
running
? monitorActions.stop(panel.id)
: monitorActions.start(panel.id)
}
>
{running ? <Square size={13} /> : <Play size={13} />}
<span className="monitor-header-action-label">{t(running ? "monitor.stop" : "monitor.start")}</span>
</Button>
)}
<Button size="sm" className="gap-1.5" onClick={() => startEdit(null)}>
<Button
variant="secondary"
className={cn(vaultHeaderSecondaryButtonClass, "shrink-0")}
aria-label={t("monitor.create")}
title={t("monitor.create")}
onClick={() => startEdit(null)}
>
<Plus size={14} />
{t("monitor.create")}
<span className="monitor-header-action-label">{t("monitor.create")}</span>
</Button>
</VaultPageHeader>
{runtime.unavailable && (
@@ -487,63 +554,6 @@ export function MonitorPanels({
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2 p-4 pb-2">
<Combobox
options={panels.map((p) => ({
value: p.id,
label: p.name,
sublabel: p.description,
}))}
value={panel.id}
onValueChange={(id) => {
setSelected(id);
setDetail(null);
setEditor(null);
}}
triggerClassName="w-64 max-w-full shrink-0"
clearable={false}
ariaLabel={t("monitor.choosePanel")}
/>
<Button
variant="ghost"
size="icon"
aria-label={t("monitor.edit")}
onClick={() => startEdit(panel)}
>
<Pencil size={15} />
</Button>
<AsideActionMenu>
<AsideActionMenuItem
onClick={() => {
void duplicate();
}}
>
<Copy size={14} className="mr-2" />
{t("monitor.copy")}
</AsideActionMenuItem>
<AsideActionMenuItem
variant="destructive"
onClick={() => setDeleting(panel)}
>
<Trash2 size={14} className="mr-2" />
{t("monitor.delete")}
</AsideActionMenuItem>
</AsideActionMenu>
<div className="flex-1" />
<Button
variant={running ? "secondary" : "default"}
size="sm"
className="gap-1.5"
onClick={() =>
running
? monitorActions.stop(panel.id)
: monitorActions.start(panel.id)
}
>
{running ? <Square size={13} /> : <Play size={13} />}
{t(running ? "monitor.stop" : "monitor.start")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 px-4 py-2">
<div className="flex h-8 rounded-md bg-muted/50 p-0.5">
{(["list", "charts"] as const).map((view) => (
@@ -637,7 +647,7 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
<ScrollArea className="min-h-0 min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
<div className="p-4">
{!rows.length ? (
<p className="p-8 text-center text-sm text-muted-foreground">
@@ -650,8 +660,8 @@ export function MonitorPanels({
key={id}
{...dragProps(id)}
className={cn(
"flex h-full min-w-0 cursor-grab flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card active:cursor-grabbing focus-visible:outline focus-visible:outline-primary",
dragging?.panelId === panel.id && dragging.hostId === id && "opacity-40",
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
@@ -747,8 +757,8 @@ export function MonitorPanels({
key={id}
{...dragProps(id)}
className={cn(
"cursor-grab [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
dragging?.panelId === panel.id && dragging.hostId === id && "opacity-40",
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
@@ -757,7 +767,7 @@ export function MonitorPanels({
<td className="px-3 py-3">
<button
draggable
className="flex items-center gap-2.5 text-left hover:underline"
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
onClick={() => {
setEditor(null);
setDetail(id);
@@ -798,10 +808,16 @@ export function MonitorPanels({
<MonitorDiskUsage stats={s} />
</td>
<td className="px-3 tabular-nums whitespace-nowrap">
↓{" "}
{rate(s?.networkReady ? s.netRxSpeed : null)}
<br />↑{" "}
{rate(s?.networkReady ? s.netTxSpeed : null)}
<div className="flex items-center gap-1.5">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.download")}: </span>
{rate(s?.networkReady ? s.netRxSpeed : null)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.upload")}: </span>
{rate(s?.networkReady ? s.netTxSpeed : null)}
</div>
</td>
<td className="px-3 whitespace-nowrap">
{state?.at
@@ -819,7 +835,7 @@ export function MonitorPanels({
{t("monitor.memoryOnlyHint")}
</p>
</div>
</ScrollArea>
</div>
</>
)}
</div>
+5
View File
@@ -2130,3 +2130,8 @@ html[data-theme-transition] *::after {
--terminal-host-tree-drop-bg: var(--nc-term-host-tree-drop-bg, transparent);
--terminal-host-tree-folder-fg: var(--nc-term-host-tree-folder-fg, var(--foreground));
}
/* Monitoring's single-row header follows the available pane width. */
@container monitor-main (max-width: 760px) {
.monitor-header-action-label { display: none; }
}