mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
1f8ce773ff
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists.
177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { formatCount } from '@/lib/utils';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2,
|
|
} from 'lucide-react';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import type { ConfigurationStatusPayload } from '@/components/dashboard';
|
|
|
|
interface FleetNodeConfiguration {
|
|
id: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
status: 'online' | 'offline';
|
|
configuration: ConfigurationStatusPayload | null;
|
|
}
|
|
|
|
function SummaryRow({ icon: Icon, label, value }: {
|
|
icon: typeof Bell;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 py-0.5">
|
|
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
|
<span className="text-xs flex-1 text-stat-subtitle">{label}</span>
|
|
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NodeCard({ node, isPaid }: { node: FleetNodeConfiguration; isPaid: boolean }) {
|
|
const isRemote = node.type === 'remote';
|
|
if (!node.configuration) {
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
|
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
|
{isRemote ? 'Remote' : 'Local'}
|
|
</Badge>
|
|
<WifiOff className="h-3.5 w-3.5 text-stat-subtitle ml-auto" strokeWidth={1.5} />
|
|
<span className="text-xs text-stat-subtitle">Offline</span>
|
|
</div>
|
|
<p className="text-xs text-stat-subtitle/60">Node is unreachable. Configuration unavailable.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const { notifications, automation, security, backup, thresholds } = node.configuration;
|
|
|
|
const agentCount = [
|
|
notifications.agents.discord.enabled,
|
|
notifications.agents.slack.enabled,
|
|
notifications.agents.webhook.enabled,
|
|
].filter(Boolean).length;
|
|
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
|
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
|
{isRemote ? 'Remote' : 'Local'}
|
|
</Badge>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" strokeWidth={1.5} />
|
|
<span className="text-xs text-success">Online</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
|
<SummaryRow icon={Bell} label="Agents"
|
|
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
|
<SummaryRow icon={Bell} label="Alert rules"
|
|
value={formatCount(notifications.alertRules, 'rule')} />
|
|
{isPaid && (
|
|
<SummaryRow icon={Zap} label="Auto-heal"
|
|
value={automation.autoHeal.total === 0
|
|
? 'None'
|
|
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
|
)}
|
|
{!automation.webhooks.locked && (
|
|
<SummaryRow icon={Zap} label="Webhooks"
|
|
value={formatCount(automation.webhooks.enabled, 'active')} />
|
|
)}
|
|
{!isRemote && (
|
|
<SummaryRow icon={Shield} label="MFA"
|
|
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
|
|
)}
|
|
{!security.scanPolicies.locked && (
|
|
<SummaryRow icon={Shield} label="Scanning"
|
|
value={formatCount(security.scanPolicies.enabled, 'policy')} />
|
|
)}
|
|
{!isRemote && !backup.locked && (
|
|
<SummaryRow icon={HardDrive} label="Backup"
|
|
value={backup.provider === 'disabled' ? 'Disabled' : 'Enabled'} />
|
|
)}
|
|
<SummaryRow icon={HardDrive} label="Crash detect"
|
|
value={thresholds.globalCrash ? 'On' : 'Off'} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function FleetConfiguration() {
|
|
const { isPaid } = useLicense();
|
|
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/fleet/configuration', { localOnly: true });
|
|
if (!res.ok) {
|
|
setError('Failed to fetch fleet configuration.');
|
|
return;
|
|
}
|
|
const data = await res.json() as FleetNodeConfiguration[];
|
|
setNodes(data);
|
|
setError(null);
|
|
} catch {
|
|
setError('Unable to reach the server.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchData();
|
|
}, [fetchData]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<Card key={i} className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="h-4 w-32 rounded-sm bg-accent/10 animate-pulse" />
|
|
{Array.from({ length: 4 }).map((_, j) => (
|
|
<div key={j} className="h-3 rounded-sm bg-accent/10 animate-pulse" />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
|
<p className="text-sm">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (nodes.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
|
<p className="text-sm">No nodes configured.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
|
{nodes.map(node => <NodeCard key={node.id} node={node} isPaid={isPaid} />)}
|
|
</div>
|
|
);
|
|
}
|