feat: add Community/Pro licensing, fleet view, and UI reorganization (#145)

* feat: add license gating system with Lemon Squeezy integration

Add Community/Pro tier infrastructure:
- LicenseService singleton with Lemon Squeezy license API integration
- /api/license endpoints (GET info, POST activate/deactivate/validate)
- 14-day Pro trial activated automatically on first boot
- 72-hour periodic validation with 30-day offline grace period
- LicenseContext provider for frontend tier awareness
- License settings tab with activation UI and status display
- ProBadge and ProGate reusable components for feature gating
- requirePro per-route guard for backend Pro-only endpoints
- Proxy bypass for /api/license routes (local-only, never proxied)

* feat: add user profile dropdown and reorganize top navigation

- Create UserProfileDropdown component with settings, billing, theme
  toggle (System/Light/Dark), documentation links, and logout button
- Remove logout button from sidebar header
- Remove standalone settings button from top bar
- Move theme toggle from Settings modal to profile dropdown
- Inject app version via Vite define from root package.json
- Add globals.d.ts for __APP_VERSION__ type declaration

* refactor(settings): remove appearance tab from settings modal

Theme toggle was moved to the User Profile Dropdown in the previous
commit. Remove the now-redundant Appearance section, its nav button,
and the unused theme/setTheme props from SettingsModal.

* feat: add fleet view dashboard and about settings section

Fleet Overview: aggregates all nodes into a card grid showing status,
container counts, CPU/RAM/disk usage bars. Pro tier unlocks stack
drill-down with auto-refresh (30s). Backend endpoints /api/fleet/overview
and /api/fleet/node/:nodeId/stacks query nodes in parallel.

About section in Settings: displays version, license tier, status,
instance ID, and links to docs/changelog/issues.

Sidebar perf fix: stack status fetches now run in parallel via
Promise.allSettled instead of sequential for-loop, significantly
reducing load time for nodes with many stacks.

Also removes version number from User Profile Dropdown (now in About).

* fix(ci): resolve Docker build and E2E test failures

- Copy root package.json into frontend build stage so vite.config.ts
  can read the app version during Docker multi-stage build.
- Update auth E2E test: logout button moved into User Profile Dropdown.
- Update nodes E2E test: Settings button moved into User Profile Dropdown.
This commit is contained in:
Anso
2026-03-25 08:32:07 -04:00
committed by GitHub
parent 19d8daf0f1
commit 4f26f22cce
16 changed files with 1636 additions and 96 deletions
+41 -41
View File
@@ -16,8 +16,8 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from 'sonner';
import { Label } from './ui/label';
@@ -34,6 +34,7 @@ import { StackAlertSheet } from './StackAlertSheet';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
@@ -68,7 +69,6 @@ const formatBytes = (bytes: number) => {
};
export default function EditorLayout() {
const { logout } = useAuth();
const { nodes, activeNode, setActiveNode } = useNodes();
// Stable ref so notification callbacks always read the latest nodes list
// without needing nodes in their dependency arrays (which would cause loops).
@@ -112,7 +112,7 @@ export default function EditorLayout() {
window.matchMedia('(prefers-color-scheme: dark)').matches
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -181,16 +181,19 @@ export default function EditorLayout() {
const fileList: string[] = Array.isArray(data) ? data : [];
setFiles(fileList);
// Fetch status for each stack
const statuses: StackStatus = {};
for (const file of fileList) {
try {
// Fetch status for all stacks in parallel
const statusResults = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
const containers = await containersRes.json();
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
statuses[file] = hasRunning ? 'running' : (Array.isArray(containers) && containers.length > 0 ? 'exited' : 'unknown');
} catch {
statuses[file] = 'unknown';
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
})
);
const statuses: StackStatus = {};
for (const result of statusResults) {
if (result.status === 'fulfilled') {
statuses[result.value.file] = result.value.status;
}
}
setStackStatuses(statuses);
@@ -926,26 +929,11 @@ export default function EditorLayout() {
{/* Left Sidebar (Stacks) */}
<div className="w-64 border-r border-border bg-card flex flex-col">
{/* Branding Header */}
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
<div className="h-16 flex items-center px-4 border-b border-border">
<div className="flex items-center gap-2">
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-12 h-12" />
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground"
>
<LogOut className="w-5 h-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Logout</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Node Switcher */}
@@ -1117,6 +1105,17 @@ export default function EditorLayout() {
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Fleet Overview Toggle */}
<Button
variant={activeView === 'fleet' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'fleet' ? (selectedFile ? 'editor' : 'dashboard') : 'fleet')}
title="Fleet Overview"
>
<Radar className="w-4 h-4 mr-2" />
Fleet
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
@@ -1161,18 +1160,6 @@ export default function EditorLayout() {
Logs
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
@@ -1244,6 +1231,13 @@ export default function EditorLayout() {
</ScrollArea>
</PopoverContent>
</Popover>
{/* User Profile Dropdown */}
<UserProfileDropdown
theme={theme}
setTheme={setTheme}
onOpenSettings={() => setSettingsModalOpen(true)}
/>
</div>{/* end right-side buttons */}
</div>
@@ -1537,6 +1531,14 @@ export default function EditorLayout() {
</ErrorBoundary>
) : activeView === 'global-observability' ? (
<GlobalObservabilityView />
) : activeView === 'fleet' ? (
<FleetView onNavigateToNode={(nodeId) => {
const node = nodes.find(n => n.id === nodeId);
if (node) {
setActiveNode(node);
setActiveView('dashboard');
}
}} />
) : (
<HomeDashboard />
)}
@@ -1584,8 +1586,6 @@ export default function EditorLayout() {
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
theme={theme}
setTheme={setTheme}
/>
{/* Stack Alert Sheet */}