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, +}