[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
effectiveDefaultAlgorithms,
|
||||
SSH_ALGORITHM_CATEGORIES,
|
||||
SSHAlgorithmCategory,
|
||||
SUPPORTED_ALGORITHMS_BY_CATEGORY,
|
||||
} from "../../domain/sshAlgorithmList";
|
||||
import type { HostAlgorithmOverrides } from "../../domain/models";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
value: HostAlgorithmOverrides | undefined;
|
||||
onChange: (next: HostAlgorithmOverrides | undefined) => void;
|
||||
/**
|
||||
* The host's current `legacyAlgorithms` value, used to seed the very
|
||||
* first customization in each category with the *effective* default
|
||||
* list (modern-only vs modern+legacy) rather than the full SUPPORTED
|
||||
* set. Without this, unchecking a single algorithm in modern mode
|
||||
* would silently start advertising CBC / arcfour / MD5 algorithms.
|
||||
*/
|
||||
legacyEnabled: boolean;
|
||||
/**
|
||||
* Algorithm overrides this host would inherit from its group when its
|
||||
* own field is unset. Used purely for display: an `undefined` value
|
||||
* here means the host can freely use NetCatty defaults by resetting
|
||||
* a category; a populated value means the host would inherit those
|
||||
* lists, and resetting locally falls back to them — the panel
|
||||
* surfaces that so the user knows the local Reset button doesn't
|
||||
* jump them to NetCatty's defaults in that case.
|
||||
*/
|
||||
inheritedFromGroup?: HostAlgorithmOverrides;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<SSHAlgorithmCategory, string> = {
|
||||
kex: "hostDetails.algorithms.category.kex",
|
||||
cipher: "hostDetails.algorithms.category.cipher",
|
||||
hmac: "hostDetails.algorithms.category.hmac",
|
||||
serverHostKey: "hostDetails.algorithms.category.serverHostKey",
|
||||
compress: "hostDetails.algorithms.category.compress",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-category SSH algorithm override editor.
|
||||
*
|
||||
* When a category's array is `undefined`, that category uses NetCatty's
|
||||
* negotiated default list. When it's a non-empty array, that array fully
|
||||
* replaces the offered list for the category.
|
||||
*
|
||||
* Picking zero algorithms in a category is equivalent to "use default" —
|
||||
* an empty array would make ssh2 fail negotiation, so we normalize it
|
||||
* back to `undefined` on save.
|
||||
*/
|
||||
export const AlgorithmOverridesPanel: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
legacyEnabled,
|
||||
inheritedFromGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const effectiveDefault = useMemo(
|
||||
() => effectiveDefaultAlgorithms(legacyEnabled),
|
||||
[legacyEnabled],
|
||||
);
|
||||
// What the runtime *actually* inherits from the group for display
|
||||
// purposes. `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing boundary: once the host carries any local
|
||||
// `algorithms` object the group's overrides stop being applied — even
|
||||
// for categories the host didn't override. So as soon as `value` is
|
||||
// non-undefined we must stop *displaying* inherited categories,
|
||||
// otherwise the UI lies about what will be negotiated.
|
||||
//
|
||||
// The write-side (`updateCategory` / `toggleAlgorithm` / Reset) still
|
||||
// consults the unconditional `inheritedFromGroup` so that the first
|
||||
// user edit on an unset host carries the inherited categories into
|
||||
// the host object, preventing the runtime's silent widening that
|
||||
// motivated those write-side fixes.
|
||||
const inheritedForDisplay = useMemo(
|
||||
() => (value === undefined ? inheritedFromGroup : undefined),
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
const inheritedCategories = useMemo(() => {
|
||||
if (!inheritedForDisplay) return [] as SSHAlgorithmCategory[];
|
||||
return SSH_ALGORITHM_CATEGORIES.filter((category) => {
|
||||
const list = inheritedForDisplay[category];
|
||||
return Array.isArray(list) && list.length > 0;
|
||||
});
|
||||
}, [inheritedForDisplay]);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory, selected: string[]) => {
|
||||
// Start from the inherited group overrides so that touching one
|
||||
// category doesn't silently drop inheritance for the others.
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary: once the host carries any
|
||||
// explicit object, the host's `algorithms` shadows the group's
|
||||
// `algorithms` entirely. If the user customized cipher locally
|
||||
// and the group restricted serverHostKey, simply storing
|
||||
// `{ cipher: [...] }` on the host would lose the group's
|
||||
// serverHostKey restriction. Persisting the inherited categories
|
||||
// alongside keeps the effective offer intact.
|
||||
const base: HostAlgorithmOverrides = inheritedFromGroup
|
||||
? { ...inheritedFromGroup }
|
||||
: {};
|
||||
const next: HostAlgorithmOverrides = { ...base, ...(value ?? {}) };
|
||||
if (selected.length === 0) {
|
||||
delete next[category];
|
||||
} else {
|
||||
next[category] = selected;
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const toggleAlgorithm = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (!current) {
|
||||
// First click in this category — seed with the *effective* offer
|
||||
// for this category. If the group has set a list for this
|
||||
// category, use that (so customizing one entry doesn't lose the
|
||||
// group's narrowing). Otherwise seed from NetCatty's effective
|
||||
// default, which already accounts for legacy mode. Seeding from
|
||||
// SUPPORTED_ALGORITHMS_BY_CATEGORY would silently introduce
|
||||
// legacy algorithms (CBC, arcfour, MD5) into the offered list.
|
||||
const baseline = inheritedFromGroup?.[category] ?? effectiveDefault[category];
|
||||
if (baseline.includes(algo)) {
|
||||
updateCategory(category, baseline.filter((a) => a !== algo));
|
||||
} else {
|
||||
// The user clicked an algorithm not in the baseline — they
|
||||
// want to opt INTO it. Start the override with the baseline
|
||||
// plus this extra entry.
|
||||
updateCategory(category, [...baseline, algo]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.includes(algo)) {
|
||||
updateCategory(category, current.filter((a) => a !== algo));
|
||||
} else {
|
||||
updateCategory(category, [...current, algo]);
|
||||
}
|
||||
},
|
||||
[value, updateCategory, effectiveDefault, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const resetCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
const next: HostAlgorithmOverrides = { ...(value ?? {}) };
|
||||
if (Array.isArray(inherited) && inherited.length > 0) {
|
||||
// The group has an override for this category. Just deleting
|
||||
// `next[category]` would *widen* the effective offer: because
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary, once any other category
|
||||
// remains on the host the group's `algorithms` object stops
|
||||
// being inherited as a whole, and the missing category falls
|
||||
// back to NetCatty defaults — not the group's narrower list.
|
||||
// Persist the inherited list verbatim instead, so Reset means
|
||||
// "use what this host would otherwise inherit" rather than
|
||||
// "silently switch to NetCatty defaults".
|
||||
next[category] = inherited.slice();
|
||||
} else {
|
||||
delete next[category];
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isCustomized = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const local = value?.[category];
|
||||
if (!Array.isArray(local) || local.length === 0) return false;
|
||||
// If the host's list is identical (order + contents) to the
|
||||
// inherited list, the user hasn't really customized it — they
|
||||
// either reset to the upstream value or never touched it directly.
|
||||
// Suppressing the "customized" badge in that case keeps the UI
|
||||
// honest about what the user actually changed.
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
if (Array.isArray(inherited)
|
||||
&& inherited.length === local.length
|
||||
&& inherited.every((a, i) => a === local[i])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isChecked = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (current) return current.includes(algo);
|
||||
// No host-local override for this category: reflect what the host
|
||||
// would actually advertise. Uses `inheritedForDisplay` (the same
|
||||
// gating the inherited notice uses) so that a host that already
|
||||
// has any local override stops pretending its empty categories
|
||||
// still come from the group — `applyGroupDefaults` won't apply
|
||||
// them, and the runtime falls back to NetCatty defaults.
|
||||
const baseline = inheritedForDisplay?.[category] ?? effectiveDefault[category];
|
||||
return baseline.includes(algo);
|
||||
},
|
||||
[value, effectiveDefault, inheritedForDisplay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.algorithms.advanced.desc")}
|
||||
</p>
|
||||
{inheritedCategories.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 break-words">
|
||||
{t("hostDetails.algorithms.inheritedNotice")
|
||||
.replace(
|
||||
"{categories}",
|
||||
inheritedCategories.map((c) => t(CATEGORY_LABEL_KEY[c])).join(", "),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{SSH_ALGORITHM_CATEGORIES.map((category) => {
|
||||
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
|
||||
const customized = isCustomized(category);
|
||||
return (
|
||||
<Card key={category} className="p-2 space-y-1.5 bg-background border-border/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
{t(CATEGORY_LABEL_KEY[category])}
|
||||
{customized && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.algorithms.customized")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{customized && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => resetCategory(category)}
|
||||
>
|
||||
{t("hostDetails.algorithms.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{supported.map((algo) => (
|
||||
<label
|
||||
key={algo}
|
||||
className="flex items-center gap-2 text-[11px] cursor-pointer select-none hover:bg-accent/40 rounded px-1 py-0.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={isChecked(category, algo)}
|
||||
onChange={() => toggleAlgorithm(category, algo)}
|
||||
/>
|
||||
<span className="font-mono truncate" title={algo}>{algo}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
components/host-details/ChainPanel.test.tsx
Normal file
12
components/host-details/ChainPanel.test.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('ChainPanel virtualizes the searchable available-host picker', () => {
|
||||
const source = readFileSync(new URL('./ChainPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /FixedSizeVirtualList/);
|
||||
assert.match(source, /items=\{filteredHosts\}/);
|
||||
assert.match(source, /CHAIN_HOST_VIEWPORT_HEIGHT/);
|
||||
assert.match(source, /onClick=\{\(\) => onAddHost\(host\.id\)\}/);
|
||||
});
|
||||
181
components/host-details/ChainPanel.tsx
Normal file
181
components/host-details/ChainPanel.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Host Chain Sub-Panel
|
||||
* Panel for configuring SSH jump host chain
|
||||
*/
|
||||
import { ArrowDown,Plus,Search,X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { AsidePanel, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { FixedSizeVirtualList } from '../ui/FixedSizeVirtualList';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
const CHAIN_HOST_ROW_HEIGHT = 52;
|
||||
const CHAIN_HOST_VIEWPORT_HEIGHT = 256;
|
||||
|
||||
export interface ChainPanelProps {
|
||||
formLabel: string;
|
||||
formHostname: string;
|
||||
form: Host;
|
||||
chainedHosts: Host[];
|
||||
availableHostsForChain: Host[];
|
||||
onAddHost: (hostId: string) => void;
|
||||
onRemoveHost: (index: number) => void;
|
||||
onClearChain: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export type ChainPanelPropsWithResize = ChainPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const ChainPanel: React.FC<ChainPanelPropsWithResize> = ({
|
||||
formLabel,
|
||||
formHostname,
|
||||
form,
|
||||
chainedHosts,
|
||||
availableHostsForChain,
|
||||
onAddHost,
|
||||
onRemoveHost,
|
||||
onClearChain,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return availableHostsForChain;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return availableHostsForChain.filter(
|
||||
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableHostsForChain, searchQuery]);
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('hostDetails.chain.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4 w-0 min-w-full">
|
||||
{/* Chain visualization */}
|
||||
<div className="space-y-2">
|
||||
{chainedHosts.map((host, index) => (
|
||||
<React.Fragment key={host.id}>
|
||||
{index > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<ArrowDown size={16} className="text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemoveHost(index)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{chainedHosts.length > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<ArrowDown size={16} className="text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target host (current) */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
||||
<DistroAvatar
|
||||
host={form}
|
||||
fallback={formLabel?.slice(0, 2).toUpperCase() || formHostname?.slice(0, 2).toUpperCase() || "H"}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{formLabel || formHostname || t('hostDetails.chain.target')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('hostDetails.chain.target')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available hosts to add */}
|
||||
{availableHostsForChain.length > 0 && (
|
||||
<Card className="p-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-64"
|
||||
style={{
|
||||
height: Math.min(
|
||||
filteredHosts.length * CHAIN_HOST_ROW_HEIGHT,
|
||||
CHAIN_HOST_VIEWPORT_HEIGHT,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<FixedSizeVirtualList
|
||||
items={filteredHosts}
|
||||
itemHeight={CHAIN_HOST_ROW_HEIGHT}
|
||||
className="h-full"
|
||||
getItemKey={(host) => host.id}
|
||||
renderItem={(host, index) => (
|
||||
<button
|
||||
key={host.id}
|
||||
className={`w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden${index > 0 ? ' mt-1' : ''}`}
|
||||
onClick={() => onAddHost(host.id)}
|
||||
>
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
||||
</div>
|
||||
<Plus size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{chainedHosts.length > 0 && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearChain}>
|
||||
{t('hostDetails.chain.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChainPanel;
|
||||
127
components/host-details/CreateGroupPanel.tsx
Normal file
127
components/host-details/CreateGroupPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Create Group Sub-Panel
|
||||
* Panel for creating new groups within the host details
|
||||
*/
|
||||
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout,type AsidePanelResizeProps } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ToggleRow: React.FC<ToggleRowProps> = ({ label, enabled, onToggle }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface CreateGroupPanelProps {
|
||||
newGroupName: string;
|
||||
setNewGroupName: (name: string) => void;
|
||||
newGroupParent: string;
|
||||
setNewGroupParent: (parent: string) => void;
|
||||
groups: string[];
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export type CreateGroupPanelPropsWithResize = CreateGroupPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const CreateGroupPanel: React.FC<CreateGroupPanelPropsWithResize> = ({
|
||||
newGroupName,
|
||||
setNewGroupName,
|
||||
newGroupParent,
|
||||
setNewGroupParent,
|
||||
groups,
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('hostDetails.group.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">{t('hostDetails.group.general')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 flex items-center justify-center">
|
||||
<FolderPlus size={18} className="text-primary" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('hostDetails.group.namePlaceholder')}
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('hostDetails.group.parentPlaceholder')}
|
||||
value={newGroupParent}
|
||||
onChange={(e) => setNewGroupParent(e.target.value)}
|
||||
list="parent-group-options"
|
||||
className="h-10"
|
||||
/>
|
||||
<datalist id="parent-group-options">
|
||||
{groups.map((g) => <option key={g} value={g} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold">{t('hostDetails.group.cloudSync')}</p>
|
||||
<HelpCircle size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
<ToggleRow label={t('hostDetails.group.cloudSync')} enabled={false} onToggle={() => { }} />
|
||||
</Card>
|
||||
|
||||
<Button variant="ghost" className="w-full h-10 gap-2">
|
||||
<Plus size={16} /> {t('hostDetails.group.addProtocol')}
|
||||
</Button>
|
||||
</AsidePanelContent>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateGroupPanel;
|
||||
133
components/host-details/EnvVarsPanel.tsx
Normal file
133
components/host-details/EnvVarsPanel.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Environment Variables Sub-Panel
|
||||
* Panel for configuring environment variables for SSH connections
|
||||
*/
|
||||
import { Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { EnvVar } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout,type AsidePanelResizeProps } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
export interface EnvVarsPanelProps {
|
||||
hostLabel: string;
|
||||
hostHostname: string;
|
||||
environmentVariables: EnvVar[];
|
||||
newEnvName: string;
|
||||
newEnvValue: string;
|
||||
setNewEnvName: (name: string) => void;
|
||||
setNewEnvValue: (value: string) => void;
|
||||
onAddEnvVar: () => void;
|
||||
onRemoveEnvVar: (index: number) => void;
|
||||
onUpdateEnvVar: (index: number, field: 'name' | 'value', value: string) => void;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export type EnvVarsPanelPropsWithResize = EnvVarsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const EnvVarsPanel: React.FC<EnvVarsPanelPropsWithResize> = ({
|
||||
hostLabel,
|
||||
hostHostname,
|
||||
environmentVariables,
|
||||
newEnvName,
|
||||
newEnvValue,
|
||||
setNewEnvName,
|
||||
setNewEnvValue,
|
||||
onAddEnvVar,
|
||||
onRemoveEnvVar,
|
||||
onUpdateEnvVar,
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('hostDetails.envVars.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('hostDetails.envVars.desc', { host: hostLabel || hostHostname })}
|
||||
<p className="text-xs mt-1">{t('hostDetails.envVars.note')}</p>
|
||||
</div>
|
||||
|
||||
<Button className="w-full h-10" onClick={onAddEnvVar} disabled={!newEnvName.trim()}>
|
||||
<Plus size={14} className="mr-2" /> {t('hostDetails.envVars.add')}
|
||||
</Button>
|
||||
|
||||
{/* Existing variables */}
|
||||
{environmentVariables.map((envVar, index) => (
|
||||
<Card key={index} className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold">{t('hostDetails.envVars.variable')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemoveEnvVar(index)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('hostDetails.envVars.variable')}
|
||||
value={envVar.name}
|
||||
onChange={(e) => onUpdateEnvVar(index, 'name', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('hostDetails.envVars.value')}
|
||||
value={envVar.value}
|
||||
onChange={(e) => onUpdateEnvVar(index, 'value', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* New variable input */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold">{t('hostDetails.envVars.newVariable')}</span>
|
||||
<X size={14} className="text-muted-foreground opacity-0" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('hostDetails.envVars.variableName')}
|
||||
value={newEnvName}
|
||||
onChange={(e) => setNewEnvName(e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('hostDetails.envVars.value')}
|
||||
value={newEnvValue}
|
||||
onChange={(e) => setNewEnvValue(e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</AsidePanelContent>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvVarsPanel;
|
||||
39
components/host-details/HostDetailsOverrideReset.tsx
Normal file
39
components/host-details/HostDetailsOverrideReset.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
export function HostDetailsOverrideReset({
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
size?: "md" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"shrink-0 border-0 bg-transparent text-primary shadow-none hover:bg-transparent hover:text-primary/80",
|
||||
size === "md" ? "h-8 w-8" : "h-7 w-7",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<RotateCcw size={size === "md" ? 14 : 13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
95
components/host-details/HostDetailsSection.tsx
Normal file
95
components/host-details/HostDetailsSection.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Card } from "../ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
export function HostDetailsHelp({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative -top-px flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground [&>svg]:block",
|
||||
className,
|
||||
)}
|
||||
aria-label={typeof children === "string" ? children : undefined}
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[260px] text-left leading-relaxed">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostDetailsSection({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
className,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn("p-3 space-y-3 bg-card border-border/80 shadow-sm", className)}>
|
||||
<div className="flex min-h-5 items-center gap-1.5">
|
||||
<span className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground [&>img]:block [&>img]:h-4 [&>img]:w-4 [&>img]:object-contain [&>svg]:block [&>svg]:h-4 [&>svg]:w-4">
|
||||
{icon}
|
||||
</span>
|
||||
<p className="flex min-h-5 items-center text-xs font-semibold leading-5 text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
|
||||
{action && <div className="ml-auto flex items-center">{action}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostDetailsSettingRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Fixed height so switch / select / input rows stay aligned.
|
||||
"flex h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-5 min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium leading-5 text-foreground">{label}</span>
|
||||
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
341
components/host-details/ProxyPanel.tsx
Normal file
341
components/host-details/ProxyPanel.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Proxy Configuration Sub-Panel
|
||||
* Panel for configuring HTTP/SOCKS5/ProxyCommand proxy settings
|
||||
*/
|
||||
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
} from '../../domain/proxyProfiles';
|
||||
import { Identity, ProxyConfig, ProxyProfile } from '../../types';
|
||||
import { AsidePanel, AsidePanelContent, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
|
||||
export interface ProxyPanelProps {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export type ProxyPanelPropsWithResize = ProxyPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyConfig,
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
const selectedProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
|
||||
[proxyProfiles, selectedProxyProfileId],
|
||||
);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
|
||||
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
|
||||
const effectiveProxyConfig = selectedProfile?.config ?? proxyConfig;
|
||||
const hasMissingIdentity = hasMissingProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === proxyConfig?.identityId),
|
||||
[identities, proxyConfig?.identityId],
|
||||
);
|
||||
const selectedIdentityValue = selectedIdentity?.id || (hasMissingIdentity ? missingIdentityValue : manualCredentialsValue);
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasInvalidManualProxyPort || hasInvalidIdentity) return;
|
||||
onBack();
|
||||
}, [hasInvalidManualProxyPort, hasInvalidIdentity, onBack]);
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('hostDetails.proxyPanel.title')}
|
||||
showBackButton={true}
|
||||
onBack={handleBack}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleBack} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.savedProxy')}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasMissingProfile && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingSaved')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{formatProxyConfigType(selectedProfile.config)}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{formatProxyConfigEndpoint(selectedProfile.config)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasMissingIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasIncompleteIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.incompleteIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasUnreadableIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.unreadableIdentity')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isUsingProfile && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('field.type')}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={proxyConfig?.type || 'http'}
|
||||
onValueChange={(value) => onUpdateProxy('type', value as ProxyConfig['type'])}
|
||||
>
|
||||
<SelectTrigger aria-label={t('field.type')} className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="command">{t('hostDetails.proxyPanel.command')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isCommandProxy ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<SquareTerminal size={14} />
|
||||
<span>{t('hostDetails.proxyPanel.commandHelp')}</span>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.commandPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.commandPlaceholder')}
|
||||
value={proxyConfig?.command || ""}
|
||||
onChange={(e) => onUpdateProxy('command', e.target.value)}
|
||||
className="h-10 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
value={proxyConfig?.host || ""}
|
||||
onChange={(e) => onUpdateProxy('host', e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
|
||||
<Input
|
||||
aria-label={t('hostDetails.port')}
|
||||
type="number"
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={proxyConfig?.port || ""}
|
||||
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
|
||||
className="h-10 w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasInvalidManualProxyPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('proxyProfiles.error.port')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!isCommandProxy && <Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
|
||||
</div>
|
||||
{identities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
</p>
|
||||
<Select
|
||||
value={selectedIdentityValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === missingIdentityValue) return;
|
||||
onUpdateProxy(
|
||||
'identityId',
|
||||
value === manualCredentialsValue ? undefined : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={manualCredentialsValue}>
|
||||
{t('hostDetails.proxyPanel.manualCredentials')}
|
||||
</SelectItem>
|
||||
{hasMissingIdentity && (
|
||||
<SelectItem value={missingIdentityValue}>
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{identities.map((identity) => (
|
||||
<SelectItem key={identity.id} value={identity.id}>
|
||||
{identity.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{hasMissingIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{hasIncompleteIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.incompleteIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{hasUnreadableIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.unreadableIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedIdentity ? (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedIdentity.label} - {selectedIdentity.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
value={proxyConfig?.username || ""}
|
||||
onChange={(e) => onUpdateProxy('username', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
type="password"
|
||||
value={proxyConfig?.password || ""}
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyPanel;
|
||||
24
components/host-details/index.ts
Normal file
24
components/host-details/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Host Details components module
|
||||
* Re-exports all host details sub-components
|
||||
*/
|
||||
|
||||
export { CreateGroupPanel } from './CreateGroupPanel';
|
||||
export type { CreateGroupPanelProps } from './CreateGroupPanel';
|
||||
|
||||
export { ProxyPanel } from './ProxyPanel';
|
||||
export type { ProxyPanelProps } from './ProxyPanel';
|
||||
|
||||
export { ChainPanel } from './ChainPanel';
|
||||
export type { ChainPanelProps } from './ChainPanel';
|
||||
|
||||
export { EnvVarsPanel } from './EnvVarsPanel';
|
||||
export type { EnvVarsPanelProps } from './EnvVarsPanel';
|
||||
|
||||
export {
|
||||
HostDetailsHelp,
|
||||
HostDetailsSection,
|
||||
HostDetailsSettingRow,
|
||||
} from './HostDetailsSection';
|
||||
|
||||
export { HostDetailsOverrideReset } from './HostDetailsOverrideReset';
|
||||
Reference in New Issue
Block a user