v1.10.0 #471
@@ -429,13 +429,59 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
// Detect or use provided shell
|
||||
const detectedShell =
|
||||
shell || (await detectShell(sshSession, containerId));
|
||||
sshSession.shell = detectedShell;
|
||||
// Validate or detect shell
|
||||
let shellToUse = shell || "bash";
|
||||
|
||||
// If a shell is explicitly provided, verify it exists in the container
|
||||
if (shell) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.exec(
|
||||
`docker exec ${containerId} which ${shell}`,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not available`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Requested shell not found, detect available shell
|
||||
dockerConsoleLogger.warn(
|
||||
`Requested shell ${shell} not found, detecting available shell`,
|
||||
{
|
||||
operation: "shell_validation",
|
||||
sessionId,
|
||||
containerId,
|
||||
requestedShell: shell,
|
||||
},
|
||||
);
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
} else {
|
||||
// No shell specified, detect available shell
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
|
||||
sshSession.shell = shellToUse;
|
||||
|
||||
// Create docker exec PTY
|
||||
const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`;
|
||||
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
@@ -482,14 +528,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: data.toString("utf8"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Log stderr but don't send to terminal to avoid duplicate error messages
|
||||
dockerConsoleLogger.debug("Docker exec stderr", {
|
||||
operation: "docker_exec_stderr",
|
||||
sessionId,
|
||||
containerId,
|
||||
data: data.toString("utf8"),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
@@ -512,7 +557,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: { shell: detectedShell },
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: shell && shell !== shellToUse,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -520,7 +569,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
operation: "console_start",
|
||||
sessionId,
|
||||
containerId,
|
||||
shell: detectedShell,
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-elevated text-foreground flex flex-col gap-6 rounded-lg border-2 border-edge py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -52,15 +52,15 @@
|
||||
--bg-base: #fcfcfc;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-surface: #f3f4f6;
|
||||
--bg-surface-hover: #e5e7eb; /* Panel hover - replaces dark-bg-panel-hover */
|
||||
--bg-surface-hover: #e5e7eb; /* Panel hover - replaces dark-bg-panel-hover */
|
||||
--bg-input: #ffffff;
|
||||
--bg-deepest: #e5e7eb;
|
||||
--bg-header: #eeeeef;
|
||||
--bg-button: #f3f4f6;
|
||||
--bg-active: #e5e7eb;
|
||||
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
|
||||
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
|
||||
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
|
||||
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
|
||||
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
|
||||
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
|
||||
--border-base: #e5e7eb;
|
||||
--border-panel: #d1d5db;
|
||||
--border-subtle: #f3f4f6;
|
||||
@@ -203,19 +203,18 @@
|
||||
--sidebar-border: #ffffff1a;
|
||||
--sidebar-ring: #71717a;
|
||||
|
||||
/* NEW SEMANTIC VARIABLES - Dark Mode Background Overrides */
|
||||
--bg-base: #18181b;
|
||||
--bg-elevated: #0e0e10;
|
||||
--bg-surface: #1b1b1e;
|
||||
--bg-surface-hover: #232327; /* Panel hover */
|
||||
--bg-surface-hover: #232327;
|
||||
--bg-input: #222225;
|
||||
--bg-deepest: #09090b;
|
||||
--bg-header: #131316;
|
||||
--bg-button: #23232a;
|
||||
--bg-active: #1d1d1f;
|
||||
--bg-light: #141416; /* Light background */
|
||||
--bg-subtle: #101014; /* Very light background */
|
||||
--bg-interact: #2a2a2c; /* Interactive/active state */
|
||||
--bg-light: #141416;
|
||||
--bg-subtle: #101014;
|
||||
--bg-interact: #2a2a2c;
|
||||
--border-base: #303032;
|
||||
--border-panel: #222224;
|
||||
--border-subtle: #5a5a5d;
|
||||
@@ -227,8 +226,8 @@
|
||||
--border-active: #2d2d30;
|
||||
|
||||
/* NEW SEMANTIC VARIABLES - Dark Mode Text Color Overrides */
|
||||
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
|
||||
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
|
||||
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
|
||||
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
|
||||
|
||||
/* Scrollbar Colors - Dark Mode */
|
||||
--scrollbar-thumb: #434345;
|
||||
|
||||
@@ -1591,6 +1591,10 @@
|
||||
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
||||
"failedToChangePermissions": "Failed to change permissions"
|
||||
},
|
||||
"tunnel": {
|
||||
"noTunnelsConfigured": "No Tunnels Configured",
|
||||
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH Tunnels",
|
||||
"noSshTunnels": "No SSH Tunnels",
|
||||
@@ -1601,6 +1605,7 @@
|
||||
"disconnecting": "Disconnecting...",
|
||||
"unknownTunnelStatus": "Unknown",
|
||||
"statusUnknown": "Unknown",
|
||||
"unknown": "Unknown",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"retrying": "Retrying",
|
||||
@@ -2333,5 +2338,95 @@
|
||||
"close": "Close",
|
||||
"hostManager": "Host Manager",
|
||||
"pressToToggle": "Press Left Shift twice to open the command palette"
|
||||
},
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connectingToHost": "Connecting to host...",
|
||||
"error": "Error",
|
||||
"errorCode": "Error code: {{code}}",
|
||||
"version": "Docker {{version}}",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"idLabel": "ID",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"created": "Created",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"pause": "Pause",
|
||||
"unpause": "Unpause",
|
||||
"restart": "Restart",
|
||||
"remove": "Remove",
|
||||
"removeContainer": "Remove Container",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"removing": "Removing...",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"filterByStatusPlaceholder": "Filter by status",
|
||||
"allContainersCount": "All ({{count}})",
|
||||
"statusCount": "{{status}} ({{count}})",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"containerMustBeRunningToViewStats": "Container must be running to view statistics",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"usedLimit": "Used / Limit",
|
||||
"percentage": "Percentage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"disconnectedFromContainer": "Disconnected from container",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"authenticationRequired": "Authentication required",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connecting": "Connecting...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserPlus, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { registerUser } from "@/ui/main-axios";
|
||||
import { registerUser } from "@/ui/main-axios.ts";
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
@@ -95,7 +95,7 @@ export function CreateUserDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
144
src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx
Normal file
144
src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkOIDCToPasswordAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface LinkAccountDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
oidcUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function LinkAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
oidcUser,
|
||||
onSuccess,
|
||||
}: LinkAccountDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [linkTargetUsername, setLinkTargetUsername] = useState("");
|
||||
const [linkLoading, setLinkLoading] = useState(false);
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setLinkTargetUsername("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!oidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
oidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setLinkTargetUsername("");
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
{t("admin.linkOidcToPasswordAccount")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.linkOidcToPasswordAccountDescription", {
|
||||
username: oidcUser?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.linkOidcWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.linkOidcWarningDescription")}
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>{t("admin.linkOidcActionDeleteUser")}</li>
|
||||
<li>{t("admin.linkOidcActionAddCapability")}</li>
|
||||
<li>{t("admin.linkOidcActionDualAuth")}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
{t("admin.linkTargetUsernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder={t("admin.linkTargetUsernamePlaceholder")}
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading
|
||||
? t("admin.linkingAccounts")
|
||||
: t("admin.linkAccountsButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
UserCog,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getUserRoles,
|
||||
getRoles,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
deleteUser,
|
||||
type UserRole,
|
||||
type Role,
|
||||
} from "@/ui/main-axios";
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -354,7 +354,7 @@ export function UserEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-dark-bg border-2 border-dark-border">
|
||||
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCog className="w-5 h-5" />
|
||||
@@ -367,7 +367,7 @@ export function UserEditDialog({
|
||||
|
||||
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
||||
{/* READ-ONLY INFO SECTION */}
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-dark-bg-panel rounded-lg border border-dark-border">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.username")}
|
||||
@@ -408,7 +408,7 @@ export function UserEditDialog({
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminPrivileges")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("admin.administratorRole")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -487,7 +487,7 @@ export function UserEditDialog({
|
||||
{userRoles.map((role) => (
|
||||
<div
|
||||
key={role.roleId}
|
||||
className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel"
|
||||
className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
@@ -508,7 +508,7 @@ export function UserEditDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRole(role.roleId)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -566,7 +566,7 @@ export function UserEditDialog({
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("admin.sessionManagement")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
{t("admin.revokeAllSessions")}
|
||||
319
src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx
Normal file
319
src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { isElectron } from "@/ui/main-axios.ts";
|
||||
|
||||
interface DatabaseSecurityTabProps {
|
||||
currentUser: {
|
||||
is_oidc: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function DatabaseSecurityTab({
|
||||
currentUser,
|
||||
}: DatabaseSecurityTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
const [exportPassword, setExportPassword] = React.useState("");
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}/database/export`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ password: exportPassword }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
setExportPassword("");
|
||||
setShowPasswordInput(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportDatabase = async () => {
|
||||
if (!importFile) {
|
||||
toast.error(t("admin.pleaseSelectImportFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresImportPassword && !importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}/database/import`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
if (requiresImportPassword) {
|
||||
formData.append("password", importPassword);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const summary = result.summary;
|
||||
const imported =
|
||||
summary.sshHostsImported +
|
||||
summary.sshCredentialsImported +
|
||||
summary.fileManagerItemsImported +
|
||||
summary.dismissedAlertsImported +
|
||||
(summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0)
|
||||
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0)
|
||||
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0)
|
||||
details.push(
|
||||
`${summary.fileManagerItemsImported} file manager items`,
|
||||
);
|
||||
if (summary.dismissedAlertsImported > 0)
|
||||
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0)
|
||||
details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
setImportPassword("");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
{showPasswordInput && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="export-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="export-password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleExportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")}
|
||||
</Button>
|
||||
{showPasswordInput && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordInput(false);
|
||||
setExportPassword("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<div className="relative inline-block w-full mb-2">
|
||||
<input
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
: t("admin.pleaseSelectImportFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && requiresImportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="import-password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={
|
||||
importLoading ||
|
||||
!importFile ||
|
||||
(requiresImportPassword && !importPassword.trim())
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx
Normal file
125
src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
allowRegistration: boolean;
|
||||
setAllowRegistration: (value: boolean) => void;
|
||||
allowPasswordLogin: boolean;
|
||||
setAllowPasswordLogin: (value: boolean) => void;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function GeneralSettingsTab({
|
||||
allowRegistration,
|
||||
setAllowRegistration,
|
||||
allowPasswordLogin,
|
||||
setAllowPasswordLogin,
|
||||
oidcConfig,
|
||||
}: GeneralSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const hasOIDCConfigured =
|
||||
oidcConfig.client_id &&
|
||||
oidcConfig.client_secret &&
|
||||
oidcConfig.issuer_url &&
|
||||
oidcConfig.authorization_url &&
|
||||
oidcConfig.token_url;
|
||||
|
||||
if (!hasOIDCConfigured) {
|
||||
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisablePasswordLogin"),
|
||||
async () => {
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
|
||||
if (allowRegistration) {
|
||||
await updateRegistrationAllowed(false);
|
||||
setAllowRegistration(false);
|
||||
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||
} else {
|
||||
toast.success(t("admin.passwordLoginDisabled"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordLogin}
|
||||
onCheckedChange={handleTogglePasswordLogin}
|
||||
disabled={passwordLoginLoading}
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx
Normal file
319
src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { updateOIDCConfig, disableOIDCConfig } from "@/ui/main-axios.ts";
|
||||
|
||||
interface OIDCSettingsTabProps {
|
||||
allowPasswordLogin: boolean;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
};
|
||||
setOidcConfig: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function OIDCSettingsTab({
|
||||
allowPasswordLogin,
|
||||
oidcConfig,
|
||||
setOidcConfig,
|
||||
}: OIDCSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
|
||||
const required = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
];
|
||||
const missing = required.filter(
|
||||
(f) => !oidcConfig[f as keyof typeof oidcConfig],
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(
|
||||
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
|
||||
);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetConfig = async () => {
|
||||
if (!allowPasswordLogin) {
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisableOIDCWarning"),
|
||||
async () => {
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.externalAuthentication")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.configureExternalProvider")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_id", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientId")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
|
||||
<PasswordInput
|
||||
id="client_secret"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_secret", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientSecret")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">
|
||||
{t("admin.authorizationUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("authorization_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.authUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("issuer_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.redirectUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("token_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.tokenUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">
|
||||
{t("admin.userIdentifierPath")}
|
||||
</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("identifier_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.userIdField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("name_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.usernameField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
|
||||
placeholder={t("placeholders.scopes")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
|
||||
<Input
|
||||
id="userinfo_url"
|
||||
value={oidcConfig.userinfo_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("userinfo_url", e.target.value)
|
||||
}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>
|
||||
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetConfig}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type Role,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
export function RoleManagement(): React.ReactElement {
|
||||
export function RolesTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
@@ -234,7 +234,7 @@ export function RoleManagement(): React.ReactElement {
|
||||
|
||||
{/* Create/Edit Role Dialog */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||
213
src/ui/desktop/admin/widgets/SessionManagementTab.tsx
Normal file
213
src/ui/desktop/admin/widgets/SessionManagementTab.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getCookie,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagementTabProps {
|
||||
sessions: Session[];
|
||||
sessionsLoading: boolean;
|
||||
fetchSessions: () => void;
|
||||
}
|
||||
|
||||
export function SessionManagementTab({
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
fetchSessions,
|
||||
}: SessionManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const currentJWT = getCookie("jwt");
|
||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||
const isCurrentSession = currentSession?.id === sessionId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
await revokeSession(sessionId);
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userId);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
fetchSessions();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.sessionManagement")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingSessions")}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.noActiveSessions")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.device")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.lastActive")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={session.isRevoked ? "opacity-50" : undefined}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
{t("admin.revoked")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(session.userId)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title={t("admin.revokeAllUserSessionsTitle")}
|
||||
>
|
||||
{t("admin.revokeAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
src/ui/desktop/admin/widgets/UserManagementTab.tsx
Normal file
177
src/ui/desktop/admin/widgets/UserManagementTab.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { deleteUser } from "@/ui/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
users: User[];
|
||||
usersLoading: boolean;
|
||||
allowPasswordLogin: boolean;
|
||||
fetchUsers: () => void;
|
||||
onCreateUser: () => void;
|
||||
onEditUser: (user: User) => void;
|
||||
onLinkOIDCUser: (user: { id: string; username: string }) => void;
|
||||
onUnlinkOIDC: (userId: string, username: string) => void;
|
||||
}
|
||||
|
||||
export function UserManagementTab({
|
||||
users,
|
||||
usersLoading,
|
||||
allowPasswordLogin,
|
||||
fetchUsers,
|
||||
onCreateUser,
|
||||
onEditUser,
|
||||
onLinkOIDCUser,
|
||||
onUnlinkOIDC,
|
||||
}: UserManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUserQuick = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.deleteUser", { username }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowPasswordLogin && (
|
||||
<Button onClick={onCreateUser} size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t("admin.createUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={fetchUsers}
|
||||
disabled={usersLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{usersLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingUsers")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.authType")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditUser(user)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title={t("admin.manageUser")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function CommandPalette({
|
||||
>
|
||||
<Command
|
||||
className={cn(
|
||||
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-edge shadow-md flex flex-col",
|
||||
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-edge shadow-md flex flex-col bg-elevated",
|
||||
"transition-all duration-200 ease-out",
|
||||
!isOpen && "scale-95 opacity-0",
|
||||
)}
|
||||
|
||||
@@ -484,10 +484,16 @@ export function CredentialEditor({
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="bg-button border border-edge-medium">
|
||||
<TabsTrigger value="general" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
||||
<TabsTrigger
|
||||
value="general"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("credentials.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="authentication" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
||||
<TabsTrigger
|
||||
value="authentication"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("credentials.authentication")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -693,10 +699,16 @@ export function CredentialEditor({
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList className="bg-button border border-edge-medium">
|
||||
<TabsTrigger value="password" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
||||
<TabsTrigger
|
||||
value="password"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("credentials.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
||||
<TabsTrigger
|
||||
value="key"
|
||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||
>
|
||||
{t("credentials.key")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -719,7 +731,7 @@ export function CredentialEditor({
|
||||
</TabsContent>
|
||||
<TabsContent value="key">
|
||||
<div className="mt-2">
|
||||
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
|
||||
<div className="mb-3 p-3 border border-muted rounded-md">
|
||||
<FormLabel className="mb-2 font-bold block">
|
||||
{t("credentials.generateKeyPair")}
|
||||
</FormLabel>
|
||||
@@ -954,7 +966,8 @@ export function CredentialEditor({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
scrollbarColor:
|
||||
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
@@ -1116,7 +1129,8 @@ export function CredentialEditor({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
scrollbarColor:
|
||||
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
|
||||
@@ -407,7 +407,7 @@ export function Dashboard({
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold shrink-0"
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -419,7 +419,7 @@ export function Dashboard({
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0"
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -431,7 +431,7 @@ export function Dashboard({
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0"
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -443,7 +443,7 @@ export function Dashboard({
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0"
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
@@ -467,10 +467,7 @@ export function Dashboard({
|
||||
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<History size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
@@ -495,10 +492,7 @@ export function Dashboard({
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
@@ -513,10 +507,7 @@ export function Dashboard({
|
||||
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Database size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
@@ -536,10 +527,7 @@ export function Dashboard({
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server
|
||||
size={16}
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<Server size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalServers")}
|
||||
</p>
|
||||
@@ -550,10 +538,7 @@ export function Dashboard({
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Network
|
||||
size={16}
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<Network size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
@@ -566,10 +551,7 @@ export function Dashboard({
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key
|
||||
size={16}
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<Key size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
@@ -591,7 +573,7 @@ export function Dashboard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-edge h-7"
|
||||
className="border-2 !border-edge h-7 !bg-canvas"
|
||||
onClick={handleResetActivity}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
@@ -626,7 +608,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge bg-canvas min-w-0"
|
||||
className="border-2 !border-edge !bg-canvas min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
@@ -654,7 +636,7 @@ export function Dashboard({
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={handleAddHost}
|
||||
>
|
||||
<Server
|
||||
@@ -667,7 +649,7 @@ export function Dashboard({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={handleAddCredential}
|
||||
>
|
||||
<Key
|
||||
@@ -681,7 +663,7 @@ export function Dashboard({
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={handleOpenAdminSettings}
|
||||
>
|
||||
<Settings
|
||||
@@ -695,7 +677,7 @@ export function Dashboard({
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={handleOpenUserProfile}
|
||||
>
|
||||
<User
|
||||
@@ -732,7 +714,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0"
|
||||
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={() =>
|
||||
handleServerStatClick(server.id, server.name)
|
||||
}
|
||||
|
||||
@@ -230,8 +230,8 @@ export function DockerManager({
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
// Check if Docker is enabled
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
@@ -252,9 +252,7 @@ export function DockerManager({
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("docker.notEnabled")}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +279,7 @@ export function DockerManager({
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{isValidating
|
||||
? t("docker.validating")
|
||||
: t("docker.connectingToHost")}
|
||||
@@ -338,7 +336,7 @@ export function DockerManager({
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
{dockerValidation?.version && (
|
||||
<p className="text-xs text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("docker.version", { version: dockerValidation.version })}
|
||||
</p>
|
||||
)}
|
||||
@@ -363,7 +361,7 @@ export function DockerManager({
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">No session available</p>
|
||||
<p className="text-muted-foreground">No session available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -377,7 +375,7 @@ export function DockerManager({
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">
|
||||
<p className="text-muted-foreground">
|
||||
Select a container to view details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -93,9 +93,18 @@ export function ConsoleTerminal({
|
||||
terminal.options.cursorBlink = true;
|
||||
terminal.options.fontSize = 14;
|
||||
terminal.options.fontFamily = "monospace";
|
||||
|
||||
// Get theme colors from CSS variables
|
||||
const backgroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--bg-elevated")
|
||||
.trim();
|
||||
const foregroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--foreground")
|
||||
.trim();
|
||||
|
||||
terminal.options.theme = {
|
||||
background: "#18181b",
|
||||
foreground: "#c9d1d9",
|
||||
background: backgroundColor || "#ffffff",
|
||||
foreground: foregroundColor || "#09090b",
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -152,12 +161,11 @@ export function ConsoleTerminal({
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
terminal.write(`${t("docker.disconnectedFromContainer")}\r\n`);
|
||||
} catch (error) {
|
||||
// Terminal might be disposed
|
||||
}
|
||||
}
|
||||
}, [terminal]);
|
||||
}, [terminal, t]);
|
||||
|
||||
const connect = React.useCallback(() => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
@@ -216,7 +224,15 @@ export function ConsoleTerminal({
|
||||
case "connected":
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
toast.success(t("docker.connectedTo", { containerName }));
|
||||
|
||||
// Check if shell was changed due to unavailability
|
||||
if (msg.data?.shellChanged) {
|
||||
toast.warning(
|
||||
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
|
||||
);
|
||||
} else {
|
||||
toast.success(t("docker.connectedTo", { containerName }));
|
||||
}
|
||||
|
||||
// Fit terminal and send resize to ensure correct dimensions
|
||||
setTimeout(() => {
|
||||
@@ -251,7 +267,9 @@ export function ConsoleTerminal({
|
||||
case "error":
|
||||
setIsConnecting(false);
|
||||
toast.error(msg.message || t("docker.consoleError"));
|
||||
terminal.write(`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`);
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -341,9 +359,11 @@ export function ConsoleTerminal({
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400 text-lg">{t("docker.containerNotRunning")}</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.containerNotRunning")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.startContainerToAccess")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -359,7 +379,9 @@ export function ConsoleTerminal({
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
<span className="text-base font-medium">{t("docker.console")}</span>
|
||||
<span className="text-base font-medium">
|
||||
{t("docker.console")}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedShell}
|
||||
@@ -423,9 +445,11 @@ export function ConsoleTerminal({
|
||||
{!isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400">{t("docker.notConnected")}</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.notConnected")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.clickToConnect")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -437,7 +461,7 @@ export function ConsoleTerminal({
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{t("docker.connectingTo", { containerName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer } from "@/types/index.js";
|
||||
import {
|
||||
startDockerContainer,
|
||||
@@ -57,6 +58,7 @@ export function ContainerCard({
|
||||
isSelected = false,
|
||||
onRefresh,
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
@@ -102,10 +104,10 @@ export function ContainerCard({
|
||||
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
},
|
||||
dead: {
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/20",
|
||||
text: "text-gray-400",
|
||||
badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
bg: "bg-muted/10",
|
||||
border: "border-muted/20",
|
||||
text: "text-muted-foreground",
|
||||
badge: "bg-muted/20 text-muted-foreground border-muted/30",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -260,26 +262,32 @@ export function ContainerCard({
|
||||
<CardContent className="space-y-3 px-4 pb-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.image")}</span>
|
||||
<span className="truncate text-gray-200 text-xs">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.image")}
|
||||
</span>
|
||||
<span className="truncate text-foreground text-xs">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.idLabel")}</span>
|
||||
<span className="font-mono text-xs text-gray-200">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.idLabel")}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">{t("docker.ports")}</span>
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
|
||||
{t("docker.ports")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.map((port, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
|
||||
className="text-xs font-mono bg-muted/10 text-muted-foreground border-muted/30"
|
||||
>
|
||||
{port}
|
||||
</Badge>
|
||||
@@ -287,7 +295,7 @@ export function ContainerCard({
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
|
||||
className="text-xs bg-muted/10 text-muted-foreground border-muted/30"
|
||||
>
|
||||
{t("docker.noPorts")}
|
||||
</Badge>
|
||||
@@ -295,8 +303,10 @@ export function ContainerCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.created")}</span>
|
||||
<span className="text-gray-200 text-xs">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.created")}
|
||||
</span>
|
||||
<span className="text-foreground text-xs">
|
||||
{formatCreatedDate(container.created)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -390,7 +400,8 @@ export function ContainerCard({
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("docker.restart")}</TooltipContent> </Tooltip>
|
||||
<TooltipContent>{t("docker.restart")}</TooltipContent>{" "}
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -407,7 +418,8 @@ export function ContainerCard({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("docker.remove")}</TooltipContent> </Tooltip>
|
||||
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -431,7 +443,9 @@ export function ContainerCard({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemoving}>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isRemoving}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer, SSHHost } from "@/types/index.js";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
@@ -28,6 +29,7 @@ export function ContainerDetail({
|
||||
hostConfig,
|
||||
onBack,
|
||||
}: ContainerDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = React.useState("logs");
|
||||
|
||||
const container = containers.find((c) => c.id === containerId);
|
||||
@@ -36,7 +38,9 @@ export function ContainerDetail({
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400 text-lg">{t("docker.containerNotFound")}</p>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.containerNotFound")}
|
||||
</p>
|
||||
<Button onClick={onBack} variant="outline">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t("docker.backToList")}
|
||||
@@ -56,7 +60,9 @@ export function ContainerDetail({
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-bold text-lg truncate">{container.name}</h2>
|
||||
<p className="text-sm text-gray-400 truncate">{container.image}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{container.image}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
@@ -72,7 +78,9 @@ export function ContainerDetail({
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="logs">{t("docker.logs")}</TabsTrigger>
|
||||
<TabsTrigger value="stats">{t("docker.stats")}</TabsTrigger>
|
||||
<TabsTrigger value="console">{t("docker.consoleTab")}</TabsTrigger>
|
||||
<TabsTrigger value="console">
|
||||
{t("docker.consoleTab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Search, Filter } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer } from "@/types/index.js";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
@@ -26,6 +27,7 @@ export function ContainerList({
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
}: ContainerListProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
||||
|
||||
@@ -55,8 +57,12 @@ export function ContainerList({
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400 text-lg">{t("docker.noContainersFound")}</p>
|
||||
<p className="text-gray-500 text-sm">{t("docker.noContainersFoundHint")}</p>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.noContainersFound")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.noContainersFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -67,7 +73,7 @@ export function ContainerList({
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docker.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
@@ -76,16 +82,23 @@ export function ContainerList({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:min-w-[200px]">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("docker.filterByStatusPlaceholder")} />
|
||||
<SelectValue
|
||||
placeholder={t("docker.filterByStatusPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("docker.allContainersCount", { count: containers.length })}</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("docker.allContainersCount", { count: containers.length })}
|
||||
</SelectItem>
|
||||
{Object.entries(statusCounts).map(([status, count]) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{t("docker.statusCount", { status: status.charAt(0).toUpperCase() + status.slice(1), count })}
|
||||
{t("docker.statusCount", {
|
||||
status: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
count,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -97,8 +110,12 @@ export function ContainerList({
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400">{t("docker.noContainersMatchFilters")}</p>
|
||||
<p className="text-gray-500 text-sm">{t("docker.noContainersMatchFiltersHint")}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.noContainersMatchFiltersHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -42,7 +42,9 @@ export function ContainerStats({
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("docker.failedToFetchStats"));
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -61,11 +63,11 @@ export function ContainerStats({
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400 text-lg">
|
||||
<Activity className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.containerNotRunning")}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.startContainerToViewStats")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -78,7 +80,9 @@ export function ContainerStats({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">{t("docker.loadingStats")}</p>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{t("docker.loadingStats")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -91,7 +95,7 @@ export function ContainerStats({
|
||||
<p className="text-red-400 text-lg">
|
||||
{t("docker.errorLoadingStats")}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm">{error}</p>
|
||||
<p className="text-muted-foreground text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -100,7 +104,7 @@ export function ContainerStats({
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-gray-400">{t("docker.noStatsAvailable")}</p>
|
||||
<p className="text-muted-foreground">{t("docker.noStatsAvailable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +125,10 @@ export function ContainerStats({
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.current")}</span> <span className="font-mono font-semibold text-blue-300">
|
||||
<span className="text-muted-foreground">
|
||||
{t("docker.current")}
|
||||
</span>{" "}
|
||||
<span className="font-mono font-semibold text-blue-400">
|
||||
{stats.cpu}
|
||||
</span>
|
||||
</div>
|
||||
@@ -141,14 +148,18 @@ export function ContainerStats({
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.usedLimit")}</span>
|
||||
<span className="font-mono font-semibold text-purple-300">
|
||||
<span className="text-muted-foreground">
|
||||
{t("docker.usedLimit")}
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-purple-400">
|
||||
{stats.memoryUsed} / {stats.memoryLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.percentage")}</span>
|
||||
<span className="font-mono text-purple-300">
|
||||
<span className="text-muted-foreground">
|
||||
{t("docker.percentage")}
|
||||
</span>
|
||||
<span className="font-mono text-purple-400">
|
||||
{stats.memoryPercent}
|
||||
</span>
|
||||
</div>
|
||||
@@ -168,12 +179,14 @@ export function ContainerStats({
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.input")}</span>
|
||||
<span className="font-mono text-green-300">{stats.netInput}</span>
|
||||
<span className="text-muted-foreground">{t("docker.input")}</span>
|
||||
<span className="font-mono text-green-400">{stats.netInput}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.output")}</span>
|
||||
<span className="font-mono text-green-300">
|
||||
<span className="text-muted-foreground">
|
||||
{t("docker.output")}
|
||||
</span>
|
||||
<span className="font-mono text-green-400">
|
||||
{stats.netOutput}
|
||||
</span>
|
||||
</div>
|
||||
@@ -192,21 +205,23 @@ export function ContainerStats({
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.read")}</span>
|
||||
<span className="font-mono text-orange-300">
|
||||
<span className="text-muted-foreground">{t("docker.read")}</span>
|
||||
<span className="font-mono text-orange-400">
|
||||
{stats.blockRead}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.write")}</span>
|
||||
<span className="font-mono text-orange-300">
|
||||
<span className="text-muted-foreground">{t("docker.write")}</span>
|
||||
<span className="font-mono text-orange-400">
|
||||
{stats.blockWrite}
|
||||
</span>
|
||||
</div>
|
||||
{stats.pids && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">{t("docker.pids")}</span>
|
||||
<span className="font-mono text-orange-300">{stats.pids}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("docker.pids")}
|
||||
</span>
|
||||
<span className="font-mono text-orange-400">{stats.pids}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,17 +239,17 @@ export function ContainerStats({
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">{t("docker.name")}</span>
|
||||
<span className="font-mono text-gray-200">{containerName}</span>
|
||||
<span className="text-muted-foreground">{t("docker.name")}</span>
|
||||
<span className="font-mono text-foreground">{containerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">{t("docker.id")}</span>
|
||||
<span className="font-mono text-sm text-gray-300">
|
||||
<span className="text-muted-foreground">{t("docker.id")}</span>
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{containerId.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">{t("docker.state")}</span>
|
||||
<span className="text-muted-foreground">{t("docker.state")}</span>
|
||||
<span className="font-semibold text-green-400 capitalize">
|
||||
{containerState}
|
||||
</span>
|
||||
|
||||
@@ -209,13 +209,13 @@ export function LogViewer({
|
||||
{/* Search Filter */}
|
||||
<div className="mt-2">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-full pl-10 pr-4 py-2 border border-input rounded-md text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,9 +231,11 @@ export function LogViewer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto thin-scrollbar">
|
||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
|
||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-foreground leading-relaxed">
|
||||
{filteredLogs || (
|
||||
<span className="text-gray-500">No logs available</span>
|
||||
<span className="text-muted-foreground">
|
||||
No logs available
|
||||
</span>
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</pre>
|
||||
|
||||
@@ -477,7 +477,7 @@ export function ServerStats({
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-2 border-edge m-3 bg-elevated p-4 overflow-y-auto thin-scrollbar relative flex-1 flex flex-col">
|
||||
<div className="border-edge m-1 p-2 overflow-y-auto thin-scrollbar relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
|
||||
@@ -30,8 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
|
||||
@@ -27,8 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<UserCheck className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
@@ -45,7 +45,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||
<div className="bg-elevated p-2 rounded border border-edge/30">
|
||||
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>{t("serverStats.totalLogins")}</span>
|
||||
@@ -54,7 +54,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
{totalLogins}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-elevated p-2 rounded border border-edge/30">
|
||||
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{t("serverStats.uniqueIPs")}</span>
|
||||
@@ -80,7 +80,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
{recentLogins.slice(0, 5).map((login, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-elevated p-2 rounded border border-edge/30 flex justify-between items-center"
|
||||
className="text-xs bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-green-400 font-mono truncate">
|
||||
|
||||
@@ -30,8 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
|
||||
@@ -24,8 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const interfaces = network?.interfaces || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-indigo-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
@@ -43,7 +42,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
interfaces.map((iface, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 rounded-lg bg-canvas/50 border border-edge/30 hover:bg-canvas/60 transition-colors"
|
||||
className="p-3 rounded-lg bg-canvas/40 border border-edge/30 hover:bg-canvas/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const topProcesses = processes?.top || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<List className="h-5 w-5 text-yellow-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
@@ -62,7 +62,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
{topProcesses.map((proc, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-2.5 rounded-lg bg-canvas/30 hover:bg-canvas/50 transition-colors border border-edge/20"
|
||||
className="p-2.5 rounded-lg bg-canvas/40 hover:bg-canvas/50 border border-edge/30"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-mono text-muted-foreground font-medium">
|
||||
|
||||
@@ -21,8 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
|
||||
const system = metricsWithSystem?.system;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Server className="h-5 w-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
|
||||
@@ -20,8 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
||||
const uptime = metricsWithUptime?.uptime;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Clock className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
|
||||
@@ -247,7 +247,8 @@ export function SSHToolsSidebar({
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "user_profile",
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1246,7 +1247,7 @@ export function SSHToolsSidebar({
|
||||
{terminalTabs.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t("snippets.selectTerminals")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1702,7 +1703,7 @@ export function SSHToolsSidebar({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t("splitScreen.availableTabs")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
@@ -1745,7 +1746,7 @@ export function SSHToolsSidebar({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t("splitScreen.layout")}
|
||||
</label>
|
||||
<div
|
||||
@@ -1763,7 +1764,9 @@ export function SSHToolsSidebar({
|
||||
const assignedTabId =
|
||||
splitAssignments.get(idx);
|
||||
const assignedTab = assignedTabId
|
||||
? tabs.find((t) => t.id === assignedTabId)
|
||||
? splittableTabs.find(
|
||||
(t) => t.id === assignedTabId,
|
||||
)
|
||||
: null;
|
||||
const isHovered = dragOverCellIndex === idx;
|
||||
const isEmpty = !assignedTabId;
|
||||
@@ -2050,7 +2053,7 @@ export function SSHToolsSidebar({
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white flex items-center gap-1">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1">
|
||||
{t("snippets.folderName")}
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
@@ -2074,7 +2077,7 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("snippets.folderColor")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -2101,7 +2104,7 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("snippets.folderIcon")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
@@ -2126,7 +2129,7 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-white">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("snippets.preview")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
|
||||
|
||||
@@ -94,8 +94,8 @@ export function TunnelManager({
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
|
||||
@@ -322,7 +322,9 @@ export function AppView({
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors = TERMINAL_THEMES[terminalConfig.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
||||
themeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const backgroundColor = themeColors.background;
|
||||
|
||||
@@ -331,7 +333,9 @@ export function AppView({
|
||||
<div
|
||||
className="absolute inset-0 rounded-md overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: isTerminal ? backgroundColor : "var(--bg-base)",
|
||||
backgroundColor: isTerminal
|
||||
? backgroundColor
|
||||
: "var(--bg-base)",
|
||||
}}
|
||||
>
|
||||
{t.type === "terminal" ? (
|
||||
@@ -409,7 +413,7 @@ export function AppView({
|
||||
const handleStyle = {
|
||||
pointerEvents: "auto",
|
||||
zIndex: 12,
|
||||
background: "var(--color-border-base)",
|
||||
background: isDarkMode ? "#303032" : "#e5e7eb",
|
||||
} as React.CSSProperties;
|
||||
const commonGroupProps: {
|
||||
onLayout: () => void;
|
||||
@@ -703,7 +707,9 @@ export function AppView({
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
containerThemeColors = TERMINAL_THEMES[terminalConfig.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
||||
containerThemeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const terminalBackgroundColor = containerThemeColors.background;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user