From dda1671e5a4c6788ba5ad97b00cbac98910f3ef0 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 27 Mar 2026 17:10:47 -0400 Subject: [PATCH] feat: stack context menu, tier icons, centered logo & support (#194) * chore: remove CLAUDE.md and MANUAL_STEPS.md from git tracking These files contain internal project instructions that should not be public. Both are now in .gitignore to prevent re-tracking. * chore: remove build output and generated files from git tracking Remove backend/public/ (frontend build artifacts) and backend/cookies.txt from tracking. Add demo-video/ and website/ to .gitignore. * docs: align mintlify theme with website and app branding Update colors to brand cyan, add Geist font, dark mode default, gradient background, navbar with GitHub link and CTA, and footer matching the website structure. * chore: add shadcn MCP server configuration * feat: add stack context menu, tier icons, centered logo, and support section - Add right-click context menu on sidebar stacks with full actions (alerts, check updates, deploy, stop, restart, update, delete) - Mirror same actions in the three-dot dropdown menu - Create TierBadge component with Globe/Crown/Users icons for Community/Pro/Team tiers, replacing ProBadge across the app - Center logo and app name in sidebar header - Add tiered Support section in Settings with docs, GitHub issues, and tier-appropriate email support channels - Remove duplicate links from About section * docs: update changelog and stack management docs for context menu --- .mcp.json | 11 + CHANGELOG.md | 15 ++ docs/features/stack-management.mdx | 11 +- frontend/package-lock.json | 1 + frontend/package.json | 1 + frontend/src/components/EditorLayout.tsx | 208 ++++++++++++++---- frontend/src/components/ProBadge.tsx | 15 -- frontend/src/components/SettingsModal.tsx | 115 ++++++++-- frontend/src/components/TierBadge.tsx | 39 ++++ .../src/components/UserProfileDropdown.tsx | 6 +- frontend/src/components/ui/context-menu.tsx | 198 +++++++++++++++++ 11 files changed, 538 insertions(+), 82 deletions(-) create mode 100644 .mcp.json delete mode 100644 frontend/src/components/ProBadge.tsx create mode 100644 frontend/src/components/TierBadge.tsx create mode 100644 frontend/src/components/ui/context-menu.tsx diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..bd98b4ff --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 398cd91b..2e84cd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* **sidebar:** right-click context menu on stacks with full lifecycle actions (deploy, stop, restart, update, delete, alerts, check for updates) +* **sidebar:** expanded three-dot dropdown menu to match context menu actions +* **settings:** new "Support" tab with tier-appropriate help channels (docs, GitHub Issues, email support for Pro) +* **ui:** TierBadge component with distinctive icons per tier — Globe (Community), Crown (Pro), Users (Team) + +### Changed + +* **sidebar:** centered logo and app name in sidebar header +* **settings:** removed duplicate Documentation and GitHub Issues links from About section (now in Support tab) +* replaced ProBadge with TierBadge across UserProfileDropdown and Settings + ## [0.9.0](https://github.com/AnsoCode/Sencho/compare/v0.8.0...v0.9.0) (2026-03-27) diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 834c2ab6..1ce5fca2 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -60,11 +60,16 @@ The stack header exposes four actions: Right-click or use the **⋮** button on any stack in the sidebar to access: - Stack context menu showing Alerts option + Stack context menu -- **Alerts** - configure metric-based alerting rules for this stack -- **Check for updates** - manually trigger an image update check +- **Alerts** — configure metric-based alerting rules for this stack +- **Check for updates** — manually trigger an image update check +- **Deploy** — run `docker compose up -d` +- **Stop** — stop all containers in the stack +- **Restart** — restart all containers in the stack +- **Update** — pull latest images and recreate containers +- **Delete** — stop and remove the stack (admin only) ## Converting a `docker run` command diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 075bf391..10690a2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/frontend/package.json b/frontend/package.json index bfead8d0..7ef5114e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index d2d33015..2fb9307e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -16,7 +16,7 @@ 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, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2 } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from 'sonner'; @@ -27,7 +27,8 @@ import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; @@ -891,6 +892,54 @@ export default function EditorLayout() { } }; + // Context-menu-friendly stack actions (accept file name directly) + const executeStackActionByFile = async (stackFile: string, action: string, endpoint: string) => { + if (loadingAction !== null) return; + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); + setLoadingAction(action); + try { + const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || `${action} failed`); + } + toast.success(`Stack ${action}ed successfully!`); + if (selectedFile === stackFile) { + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await containersRes.json(); + setContainers(Array.isArray(conts) ? conts : []); + } + await refreshStacks(true); + if (action === 'deploy' && isPro) { + try { + const backupRes = await apiFetch(`/stacks/${stackName}/backup`); + if (backupRes.ok) setBackupInfo(await backupRes.json()); + } catch { /* ignore */ } + } + } catch (error) { + console.error(`Failed to ${action}:`, error); + const msg = (error as Error).message || `Failed to ${action} stack`; + toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg); + } finally { + setLoadingAction(null); + } + }; + + const checkUpdatesForStack = async () => { + try { + const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + if (res.ok) { + toast.success('Checking for image updates...'); + setTimeout(() => fetchImageUpdates(), 3000); + } else { + const data = await res.json().catch(() => ({})); + toast.error(data.error || 'Failed to check for updates'); + } + } catch { + toast.error('Failed to check for updates'); + } + }; + const handleCreateStack = async () => { if (!newStackName.trim()) return; // Send stackName directly (no .yml extension - backend creates directory) @@ -979,7 +1028,7 @@ export default function EditorLayout() { {/* Left Sidebar (Stacks) */}
{/* Branding Header */} -
+
Sencho Logo

Sencho

@@ -1069,44 +1118,127 @@ export default function EditorLayout() {
) : ( (filteredFiles || []).map(file => ( - loadFile(file)} - className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`} - > -
-
- {getDisplayName(file)} + + +
+ loadFile(file)} + className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`} + > +
+
+ {getDisplayName(file)} - {stackUpdates[file] && ( - - )} + {stackUpdates[file] && ( + + )} -
e.stopPropagation()}> - - - - - - openAlertSheet(file)}> - - Alerts - - - +
e.stopPropagation()}> + + + + + + openAlertSheet(file)}> + + Alerts + + checkUpdatesForStack()}> + + Check for updates + + + executeStackActionByFile(file, 'deploy', 'deploy')}> + + Deploy + + executeStackActionByFile(file, 'stop', 'stop')}> + + Stop + + executeStackActionByFile(file, 'restart', 'restart')}> + + Restart + + executeStackActionByFile(file, 'update', 'update')}> + + Update + + {isAdmin && ( + <> + + { + setStackToDelete(file.replace(/\.(yml|yaml)$/, '')); + setDeleteDialogOpen(true); + }} + > + + Delete + + + )} + + +
+
+
-
-
+ + + openAlertSheet(file)}> + + Alerts + + checkUpdatesForStack()}> + + Check for updates + + + executeStackActionByFile(file, 'deploy', 'deploy')}> + + Deploy + + executeStackActionByFile(file, 'stop', 'stop')}> + + Stop + + executeStackActionByFile(file, 'restart', 'restart')}> + + Restart + + executeStackActionByFile(file, 'update', 'update')}> + + Update + + {isAdmin && ( + <> + + { + setStackToDelete(file.replace(/\.(yml|yaml)$/, '')); + setDeleteDialogOpen(true); + }} + > + + Delete + + + )} + + )) )} diff --git a/frontend/src/components/ProBadge.tsx b/frontend/src/components/ProBadge.tsx deleted file mode 100644 index 3238037a..00000000 --- a/frontend/src/components/ProBadge.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Crown } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; - -interface ProBadgeProps { - className?: string; -} - -export function ProBadge({ className }: ProBadgeProps) { - return ( - - - Pro - - ); -} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0011f520..8667e3a2 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,14 +18,14 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard } from 'lucide-react'; +import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug } from 'lucide-react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; -import { ProBadge } from './ProBadge'; +import { TierBadge } from './TierBadge'; import { ProGate } from './ProGate'; interface Agent { @@ -48,7 +48,7 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about'; +type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; interface WebhookItem { id: number; @@ -190,7 +190,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) { return (
-

Webhooks

+

Webhooks

Trigger stack actions from CI/CD pipelines via HTTP.

@@ -207,7 +207,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
-

Webhooks

+

Webhooks

Trigger stack actions from CI/CD pipelines via HTTP.

@@ -1087,7 +1088,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
- {license?.tier === 'pro' && } +
{license?.status === 'trial' && license.trialDaysRemaining !== null && ( @@ -1525,6 +1526,90 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )} + {activeSection === 'support' && ( +
+
+

Help & Support

+

Get help with Sencho based on your plan.

+
+ + {/* Self-serve channels (all tiers) */} + + + {/* Pro support channels */} + {isPro && ( + + )} + + {/* Upsell for Community */} + {!isPro && ( +
+
+ +
+

Need faster support?

+

+ Upgrade to Pro for direct email support and priority issue handling. +

+ +
+
+
+ )} +
+ )} + {activeSection === 'about' && (
@@ -1539,7 +1624,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
Tier -
{license?.tier === 'pro' ? : Community}
+
License Status @@ -1556,14 +1641,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
diff --git a/frontend/src/components/TierBadge.tsx b/frontend/src/components/TierBadge.tsx new file mode 100644 index 00000000..0d15fb93 --- /dev/null +++ b/frontend/src/components/TierBadge.tsx @@ -0,0 +1,39 @@ +import { Crown, Globe, Users } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { useLicense, type LicenseTier, type LicenseVariant, type LicenseStatus } from '@/context/LicenseContext'; + +interface TierBadgeProps { + tier?: LicenseTier; + variant?: LicenseVariant; + status?: LicenseStatus; + className?: string; +} + +const tierConfig = { + community: { icon: Globe, label: 'Community' }, + pro: { icon: Crown, label: 'Pro' }, + team: { icon: Users, label: 'Team' }, +} as const; + +function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) { + // Only show Team badge for active team licenses, not trials + // (trials default to team variant to unlock all features) + if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team; + if (tier === 'pro') return tierConfig.pro; + return tierConfig.community; +} + +export function TierBadge({ tier, variant, status, className }: TierBadgeProps) { + const { license } = useLicense(); + const resolvedTier = tier ?? license?.tier ?? 'community'; + const resolvedVariant = variant !== undefined ? variant : license?.variant ?? null; + const resolvedStatus = status ?? license?.status ?? 'community'; + const { icon: Icon, label } = resolveTier(resolvedTier, resolvedVariant, resolvedStatus); + + return ( + + + {label} + + ); +} diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index f39cd960..7517fbfc 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Separator } from '@/components/ui/separator'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; -import { ProBadge } from './ProBadge'; +import { TierBadge } from './TierBadge'; type Theme = 'light' | 'dark' | 'auto'; @@ -16,7 +16,7 @@ interface UserProfileDropdownProps { export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { const { logout, user, isAdmin } = useAuth(); - const { license, isPro } = useLicense(); + const { license } = useLicense(); return ( @@ -39,7 +39,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro {user?.role ?? 'admin'} · - {isPro ? : Community} +
diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx new file mode 100644 index 00000000..0cd4b2c0 --- /dev/null +++ b/frontend/src/components/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}