Compare commits

..
13 changed files with 131 additions and 169 deletions

No files matched your search

-3
View File
@@ -1,5 +1,4 @@
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)",
@@ -44,8 +43,6 @@ 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,5 +1,4 @@
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)",
@@ -44,8 +43,6 @@ 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,5 +1,4 @@
export const ruMonitorMessages = {
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
"monitor.password": "Пароль (только для этого запуска)",
@@ -43,8 +42,6 @@ export const ruMonitorMessages = {
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
"monitor.rootDisk": "Корневой раздел",
"monitor.network": "Сеть",
"monitor.download": "Загрузка",
"monitor.upload": "Отправка",
"monitor.updated": "Обновлено",
"monitor.updatedAt": "Обновлено: {time}",
"monitor.noSamples": "Ожидание первого измерения",
@@ -1,5 +1,4 @@
export const zhCNMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
"monitor.password": "密码(仅本次使用)",
@@ -42,8 +41,6 @@ export const zhCNMonitorMessages = {
"monitor.diskCapacityUnknown": "暂无容量读数",
"monitor.rootDisk": "根分区",
"monitor.network": "网络",
"monitor.download": "下载",
"monitor.upload": "上传",
"monitor.updated": "更新时间",
"monitor.updatedAt": "更新于 {time}",
"monitor.noSamples": "等待第一次读数",
@@ -1,5 +1,4 @@
export const zhTWMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
"monitor.password": "密碼(僅本次使用)",
@@ -42,8 +41,6 @@ export const zhTWMonitorMessages = {
"monitor.diskCapacityUnknown": "暫無容量讀數",
"monitor.rootDisk": "根分割區",
"monitor.network": "網路",
"monitor.download": "下載",
"monitor.upload": "上傳",
"monitor.updated": "更新時間",
"monitor.updatedAt": "更新於 {time}",
"monitor.noSamples": "等待第一次讀數",
+22 -37
View File
@@ -4,7 +4,6 @@ import {
EyeOff,
FileText,
FolderPlus,
PlugZap,
Plus,
Settings2,
Tag,
@@ -62,7 +61,6 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
ChainPanel,
@@ -1017,39 +1015,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
actions={
<>
{!pluginProtocolActive && form.protocol !== "telnet" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleStartTest}
disabled={!canSaveHost}
aria-label={t("hostConnectionTest.title")}
>
<PlugZap size={16} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("hostConnectionTest.title")}
</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!canSaveHost}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
</Button>
</>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!canSaveHost}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
</Button>
}
>
<AsidePanelContent>
@@ -1371,9 +1346,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<AsidePanelFooter className="flex flex-row gap-2">
{!pluginProtocolActive && form.protocol !== "telnet" && (
<Button
variant="outline"
className="h-10 px-4"
onClick={handleStartTest}
disabled={!canSaveHost}
>
{t("hostConnectionTest.test")}
</Button>
)}
<Button
className="w-full h-10"
className="flex-1 h-10"
onClick={handleSubmit}
disabled={!canSaveHost}
>
+4 -2
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' : 'hover:bg-muted/70',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
@@ -487,7 +487,9 @@ 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',
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
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',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
+7 -3
View File
@@ -34,9 +34,13 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
assert.match(source, /navigableIndexByKey/);
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
assert.doesNotMatch(source, /navigable\.findIndex/);
// 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/);
// 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'/,
);
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\}/);
+2 -4
View File
@@ -173,18 +173,16 @@ 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, triggerClassName, ariaLabel }) => {
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
<MoreVertical size={18} />
</button>
</PopoverTrigger>
+15 -2
View File
@@ -1,6 +1,7 @@
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";
@@ -14,13 +15,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",
@@ -29,7 +30,19 @@ export function MonitorNavButton({
)}
>
<Gauge size={16} className="shrink-0" />
{!collapsed && <span>{t("monitor.title")}</span>}
{!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>
)}
</RippleButton>
</TooltipTrigger>
<TooltipContent side="right">{t("monitor.title")}</TooltipContent>
+1 -5
View File
@@ -158,11 +158,7 @@ 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.equal(document.querySelector("header .lucide-gauge"), null);
assert.ok(document.querySelector(".lucide-gauge"));
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: "]);
}
});
}
+80 -96
View File
@@ -42,6 +42,7 @@ 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,
@@ -68,7 +69,7 @@ import SelectHostPanel from "../SelectHostPanel";
import { DistroAvatar } from "../DistroAvatar";
import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { VaultHeaderSearch, VaultPageHeader } from "./VaultPageHeader";
import { toast } from "../ui/toast";
const emptyMonitorPoints: MonitorPoint[] = [];
@@ -450,87 +451,19 @@ 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 [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>
<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")}
</Button>
)}
<Button
variant="secondary"
className={cn(vaultHeaderSecondaryButtonClass, "shrink-0")}
aria-label={t("monitor.create")}
title={t("monitor.create")}
onClick={() => startEdit(null)}
>
<Button size="sm" className="gap-1.5" onClick={() => startEdit(null)}>
<Plus size={14} />
<span className="monitor-header-action-label">{t("monitor.create")}</span>
{t("monitor.create")}
</Button>
</VaultPageHeader>
{runtime.unavailable && (
@@ -554,6 +487,63 @@ 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) => (
@@ -647,7 +637,7 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
<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="p-4">
{!rows.length ? (
<p className="p-8 text-center text-sm text-muted-foreground">
@@ -660,8 +650,8 @@ export function MonitorPanels({
key={id}
{...dragProps(id)}
className={cn(
"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",
"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",
dropTarget === id && "bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
@@ -757,8 +747,8 @@ export function MonitorPanels({
key={id}
{...dragProps(id)}
className={cn(
"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",
"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",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
@@ -767,7 +757,7 @@ export function MonitorPanels({
<td className="px-3 py-3">
<button
draggable
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
className="flex items-center gap-2.5 text-left hover:underline"
onClick={() => {
setEditor(null);
setDetail(id);
@@ -808,16 +798,10 @@ export function MonitorPanels({
<MonitorDiskUsage stats={s} />
</td>
<td className="px-3 tabular-nums whitespace-nowrap">
<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>
↓{" "}
{rate(s?.networkReady ? s.netRxSpeed : null)}
<br />↑{" "}
{rate(s?.networkReady ? s.netTxSpeed : null)}
</td>
<td className="px-3 whitespace-nowrap">
{state?.at
@@ -835,7 +819,7 @@ export function MonitorPanels({
{t("monitor.memoryOnlyHint")}
</p>
</div>
</div>
</ScrollArea>
</>
)}
</div>
-5
View File
@@ -2130,8 +2130,3 @@ 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; }
}