mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
feat: enforce singleton local node per instance (#1567)
Only one local node is allowed. Creating a second local returns 409, and the last local node cannot be deleted or converted to a remote type. Existing duplicate local nodes from older versions are preserved and can be cleaned up individually. Zero-local recovery auto-assigns the default flag. Frontend delete surfaces and the Add Node form respect the new invariant. Enforced in DatabaseService (addNode/updateNode/deleteNode guards) and routes (error translations). Legacy test fixtures use raw SQL helpers.
This commit is contained in:
@@ -22,6 +22,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -71,12 +72,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
|
||||
// Cordon requires node:manage, matching the backend guard.
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon requires the paid tier AND node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid).
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
|
||||
@@ -288,7 +288,7 @@ export function NodeManager() {
|
||||
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
|
||||
</Button>
|
||||
)}
|
||||
{node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
{node && !node.is_default && nodes.filter(n => n.type === 'local').length > 1 && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -324,6 +324,7 @@ export function NodeManager() {
|
||||
<TableBody>
|
||||
{nodes.map((node) => {
|
||||
const canManageThis = isAdmin || can('node:manage', 'node', String(node.id));
|
||||
const isLastLocal = node.type === 'local' && nodes.filter(n => n.type === 'local').length <= 1;
|
||||
return (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
@@ -493,7 +494,7 @@ export function NodeManager() {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{!node.is_default && canManageThis && (
|
||||
{!node.is_default && !isLastLocal && canManageThis && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -275,40 +275,55 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => {
|
||||
const type = val as NodeFormData['type'];
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
type,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(type, formData.mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit ? (
|
||||
<div className="flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-muted/50">
|
||||
{formData.type === 'local' ? (
|
||||
<><Monitor className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Local</span></>
|
||||
) : (
|
||||
<><Globe className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Remote</span></>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => {
|
||||
const type = val as NodeFormData['type'];
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
type,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(type, formData.mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!nodes.some(n => n.type === 'local') && (
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">Node type cannot be changed after creation.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.type === 'remote' && (
|
||||
@@ -581,9 +596,15 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
{deletingNode?.type === 'local' ? (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Deleting local node <span className="font-medium text-stat-value">{deletingNode?.name}</span> removes its schedules, labels, dossiers, findings, and other node-scoped data. Containers and compose files on the host are <strong>not</strong> affected. This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user