mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user