diff --git a/ui-react/apps/console/src/pages/DeviceDetails.tsx b/ui-react/apps/console/src/pages/DeviceDetails.tsx
index 46c1f1e3789..0844b5691fb 100644
--- a/ui-react/apps/console/src/pages/DeviceDetails.tsx
+++ b/ui-react/apps/console/src/pages/DeviceDetails.tsx
@@ -2,11 +2,6 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import Breadcrumb from "@/components/common/Breadcrumb";
import {
- TagIcon,
- XMarkIcon,
- PlusIcon,
- PencilSquareIcon,
- CheckIcon,
TrashIcon,
InformationCircleIcon,
ComputerDesktopIcon,
@@ -15,407 +10,31 @@ import {
ChevronDoubleRightIcon,
} from "@heroicons/react/24/outline";
import { useDevice } from "../hooks/useDevice";
-import {
- useRenameDevice,
- useAddDeviceTag,
- useRemoveDeviceTag,
- useRemoveDevice,
- useSetDeviceCustomField,
- useDeleteDeviceCustomField,
-} from "../hooks/useDeviceMutations";
+import { useRemoveDevice } from "../hooks/useDeviceMutations";
import { useNamespace } from "../hooks/useNamespaces";
import { useAuthStore } from "../stores/authStore";
import { useTerminalStore } from "../stores/terminalStore";
import DeviceActionDialog from "./devices/DeviceActionDialog";
import BillingWarning from "../components/billing/BillingWarning";
import ConnectDrawer from "../components/ConnectDrawer";
+import ConfirmDialog from "../components/common/ConfirmDialog";
import CopyButton from "../components/common/CopyButton";
import PlatformBadge from "../components/common/PlatformBadge";
import { formatDateFull, formatRelative } from "../utils/date";
import { buildSshid } from "../utils/sshid";
-import { useHasPermission } from "../hooks/useHasPermission";
import RestrictedAction from "../components/common/RestrictedAction";
import { getConfig } from "../env";
import PageLoader from "@/components/common/PageLoader";
+import InfoItem from "./devices/InfoItem";
+import TagsSection from "./devices/TagsSection";
+import RenameSection from "./devices/RenameSection";
+import CustomFieldsSection from "./devices/CustomFieldsSection";
/* ─── Shared styles ─── */
const LABEL =
"text-2xs font-mono font-semibold uppercase tracking-label text-text-muted";
const VALUE = "text-sm text-text-primary font-medium mt-0.5";
-/* ─── Info Row ─── */
-function InfoItem({
- label,
- value,
- mono,
- copyable,
- truncate,
-}: {
- label: string;
- value: string;
- mono?: boolean;
- copyable?: boolean;
- truncate?: number;
-}) {
- const display = truncate && value ? value.slice(0, truncate) : value;
-
- return (
-
-
{label}
-
-
- {display || "—"}
-
- {copyable && value && }
-
-
- );
-}
-
-/* ─── Tags Section ─── */
-function TagsSection({ uid, tags }: { uid: string; tags: string[] }) {
- const addTagMutation = useAddDeviceTag();
- const removeTagMutation = useRemoveDeviceTag();
- const canEditTags = useHasPermission("tag:edit");
- const [input, setInput] = useState("");
- const [adding, setAdding] = useState(false);
- const [error, setError] = useState(null);
-
- const handleAdd = async () => {
- const tag = input.trim();
- if (!tag) return;
- setError(null);
-
- if (tags && tags.includes(tag)) {
- setError("This tag is already added.");
- return;
- }
- if (tags && tags.length >= 3) return;
- if (tag.length < 3) {
- setError("Tag must be at least 3 characters.");
- return;
- }
- if (tag.length > 255) {
- setError("Tag must be at most 255 characters.");
- return;
- }
- if (!/^[a-zA-Z0-9]+$/.test(tag)) {
- setError("Tag must contain only letters and numbers.");
- return;
- }
-
- setAdding(true);
- try {
- await addTagMutation.mutateAsync({ path: { uid, name: tag } });
- setInput("");
- } catch {
- setError("Failed to add tag.");
- }
- setAdding(false);
- };
-
- const handleRemove = async (tag: string) => {
- try {
- await removeTagMutation.mutateAsync({ path: { uid, name: tag } });
- } catch {
- /* invalidation handles UI update */
- }
- };
-
- return (
-
-
Tags
-
- {tags &&
- tags.map((tag) => (
-
-
- {tag}
- {canEditTags && (
-
- )}
-
- ))}
- {canEditTags && (!tags || tags.length < 3) && (
-
-
{
- setInput(e.target.value);
- setError(null);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- void handleAdd();
- }
- }}
- placeholder="Add tag..."
- pattern="^[a-zA-Z0-9]+$"
- className="w-28 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all"
- />
-
-
- )}
-
- {tags && tags.length >= 3 && (
-
- Maximum of 3 tags reached.
-
- )}
- {error &&
{error}
}
-
- );
-}
-
-/* ─── Rename Inline ─── */
-function RenameSection({
- uid,
- currentName,
-}: {
- uid: string;
- currentName: string;
-}) {
- const renameMutation = useRenameDevice();
- const canRename = useHasPermission("device:rename");
- const [editing, setEditing] = useState(false);
- const [name, setName] = useState(currentName);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
-
- const handleSave = async () => {
- if (!name.trim() || name.trim() === currentName) {
- setEditing(false);
- return;
- }
- setSaving(true);
- setError(null);
- try {
- await renameMutation.mutateAsync({
- path: { uid },
- body: { name: name.trim() },
- });
- setEditing(false);
- } catch {
- setError("Failed to rename device.");
- }
- setSaving(false);
- };
-
- if (!editing) {
- return (
-
-
{currentName}
- {canRename && (
-
- )}
-
- );
- }
-
- return (
-
-
- setName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void handleSave();
- if (e.key === "Escape") setEditing(false);
- }}
- autoFocus
- className="text-2xl font-bold text-text-primary bg-transparent border-b-2 border-primary/50 focus:outline-none focus:border-primary w-full max-w-md"
- />
-
-
-
- {error &&
{error}
}
-
- );
-}
-
-/* ─── Custom Fields Section ─── */
-function CustomFieldsSection({
- uid,
- customFields,
-}: {
- uid: string;
- customFields: Record;
-}) {
- const setMutation = useSetDeviceCustomField();
- const deleteMutation = useDeleteDeviceCustomField();
- const canEdit = useHasPermission("device:customField:update");
- const [keyInput, setKeyInput] = useState("");
- const [valueInput, setValueInput] = useState("");
- const [adding, setAdding] = useState(false);
- const [error, setError] = useState(null);
- const [confirmKey, setConfirmKey] = useState(null);
-
- const handleAdd = async () => {
- const key = keyInput.trim();
- const value = valueInput.trim();
- if (!key || !value) return;
- if (key in customFields) {
- setError("This key already exists.");
- return;
- }
- setError(null);
- setAdding(true);
- try {
- await setMutation.mutateAsync({
- path: { uid, key },
- body: { value },
- });
- setKeyInput("");
- setValueInput("");
- } catch {
- setError("Failed to add custom field.");
- }
- setAdding(false);
- };
-
- const handleRemove = async (key: string) => {
- try {
- await deleteMutation.mutateAsync({
- path: { uid, key },
- });
- } catch {
- /* invalidation handles UI update */
- }
- };
-
- return (
-
-
Custom Fields
-
- {Object.entries(customFields).map(([key, value]) => (
-
-
-
- {key}:
-
-
- {value}
-
-
- {canEdit &&
- (confirmKey === key ? (
-
- Remove?
-
-
-
- ) : (
-
- ))}
-
- ))}
-
- {canEdit && (
-
- )}
- {error &&
{error}
}
-
- );
-}
-
/* ─── Page ─── */
export default function DeviceDetails() {
const { uid } = useParams<{ uid: string }>();
@@ -431,7 +50,7 @@ export default function DeviceDetails() {
const restoreTerminal = useTerminalStore((s) => s.restore);
const [connectOpen, setConnectOpen] = useState(false);
const [showDelete, setShowDelete] = useState(false);
- const [deleting, setDeleting] = useState(false);
+ const [deleteError, setDeleteError] = useState(null);
const [operation, setOperation] = useState<{
device: { uid: string; name: string };
action: "accept" | "reject" | "remove";
@@ -480,19 +99,18 @@ export default function DeviceDetails() {
: [];
const handleDelete = async () => {
- setDeleting(true);
+ setDeleteError(null);
try {
await removeMutation.mutateAsync({ path: { uid: device.uid } });
setShowDelete(false);
void navigate("/devices");
} catch {
- setDeleting(false);
+ setDeleteError("Failed to delete device. Please try again.");
}
};
const handleDeviceActionSuccess = () => {
if (!operation) return;
-
if (operation.action === "remove") void navigate("/devices");
};
@@ -555,6 +173,7 @@ export default function DeviceDetails() {
<>