diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c2c6cd..6e29768c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,14 +16,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * **stacks:** bulk `/api/stacks/statuses` endpoint — fetches all stack statuses in a single Docker API call instead of N individual `docker compose ps` invocations. Falls back to per-stack queries for remote nodes on older versions. +* **fleet:** tag filter dropdown — replaced inline pills with a multi-select combobox for cleaner filtering +* **editor:** ⌘K / Ctrl+K keyboard shortcut to focus the sidebar stack search +* **billing:** signed Lemon Squeezy Customer Portal URLs — "Billing" button now opens an auto-authenticated portal session via the sencho.io proxy, available for all license tiers ### Fixed * **stacks:** sidebar status indicators showing "--" (unknown) after stopping a stack instead of "DN". Root cause was a race condition where `refreshStacks` queried container state before Docker had fully transitioned containers. +* **logs:** toolbar (search, filters, actions) was a hover-only floating bar that could disappear — now a permanent pinned toolbar always visible at the top +* **logs:** replaced all hardcoded colors with design tokens for proper light/dark theme support +* **audit:** Export dropdown caused toolbar to scroll out of view due to Radix scroll-locking (`modal={false}` fix) +* **audit:** light theme button contrast — outline buttons were invisible due to `--input` matching `--background` +* **toast:** notification colors used hardcoded Tailwind values instead of oklch design tokens +* **editor:** button row (Discard / Save Only / Save & Deploy) had inconsistent heights across variants ### Changed * **stacks:** stack actions (deploy, stop, restart, update) are now tracked per-stack instead of globally. Users can fire actions on multiple stacks concurrently without the UI blocking. Sidebar shows a spinner per stack during in-flight actions. +* **resources:** removed redundant inner border and background on tab navigation wrapper ## [0.34.0](https://github.com/AnsoCode/Sencho/compare/v0.33.1...v0.34.0) (2026-04-03) diff --git a/backend/src/index.ts b/backend/src/index.ts index a335c198..b86108c6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1062,6 +1062,20 @@ app.post('/api/license/validate', async (_req: Request, res: Response): Promise< } }); +app.get('/api/license/billing-portal', async (_req: Request, res: Response): Promise => { + try { + const url = await LicenseService.getInstance().getBillingPortalUrl(); + if (!url) { + res.status(404).json({ error: 'No billing portal available. Ensure you have an active license.' }); + return; + } + res.json({ url }); + } catch (error) { + console.error('[License] Billing portal error:', error); + res.status(500).json({ error: 'Failed to retrieve billing portal URL' }); + } +}); + // --- Self-Update --- /** Respond 202 and trigger the "last breath" self-update after the response flushes. */ diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 75d6ae05..b69a51cd 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -243,7 +243,7 @@ export class LicenseService { validUntil, trialDaysRemaining, instanceId, - portalUrl: db.getSystemState('customer_portal_url') || null, + portalUrl: db.getSystemState('billing_portal_url') || db.getSystemState('customer_portal_url') || null, }; } @@ -291,6 +291,13 @@ export class LicenseService { if (data.meta?.variant_name) { db.setSystemState('license_variant_name', data.meta.variant_name); } + if (data.meta?.customer_id) { + db.setSystemState('customer_id', String(data.meta.customer_id)); + } + + // Clear any cached portal URL so it's refreshed on next request + db.setSystemState('billing_portal_url', ''); + db.setSystemState('billing_portal_expires', ''); console.log('[License] Activated successfully.'); return { success: true }; @@ -345,6 +352,8 @@ export class LicenseService { 'update_payment_url', 'order_id', 'receipt_url', + 'billing_portal_url', + 'billing_portal_expires', ]; for (const key of keysToRemove) { db.setSystemState(key, ''); @@ -415,6 +424,9 @@ export class LicenseService { if (data.meta?.variant_name) { db.setSystemState('license_variant_name', data.meta.variant_name); } + if (data.meta?.customer_id && !db.getSystemState('customer_id')) { + db.setSystemState('customer_id', String(data.meta.customer_id)); + } console.log('[License] Validation successful.'); return { success: true }; @@ -425,6 +437,53 @@ export class LicenseService { } } + /** + * Fetch a signed billing portal URL via the sencho.io proxy. + * Returns a pre-signed Lemon Squeezy Customer Portal URL (valid 24hrs). + * Caches the URL for 12 hours to reduce external API calls. + */ + public async getBillingPortalUrl(): Promise { + const db = DatabaseService.getInstance(); + const status = db.getSystemState('license_status'); + const licenseKey = db.getSystemState('license_key'); + + if (status !== 'active' || !licenseKey) { + return null; + } + + // Check cache (12hr TTL) + const cachedUrl = db.getSystemState('billing_portal_url'); + const cachedExpires = db.getSystemState('billing_portal_expires'); + if (cachedUrl && cachedExpires && Date.now() < parseInt(cachedExpires, 10)) { + return cachedUrl; + } + + try { + const response = await axios.post<{ url: string }>( + 'https://sencho.io/api/billing-portal', + { license_key: licenseKey }, + { timeout: 15000 } + ); + + const url = response.data?.url; + if (!url) { + return null; + } + + // Cache for 12 hours + const ttl = 12 * 60 * 60 * 1000; + db.setSystemState('billing_portal_url', url); + db.setSystemState('billing_portal_expires', String(Date.now() + ttl)); + + return url; + } catch (err) { + console.warn('[License] Failed to fetch billing portal URL:', (err as Error).message); + // Return stale cache if available + if (cachedUrl) return cachedUrl; + return null; + } + } + /** * Start periodic background validation every 72 hours. */ diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index 4d1be187..b18b7b69 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -123,9 +123,9 @@ export function AuditLogView() { Audit Log
- + - diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 16a8ba09..caac8a65 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -19,7 +19,7 @@ import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; 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, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check } 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, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill'; import { LabelAssignPopover } from './LabelAssignPopover'; @@ -259,6 +259,19 @@ export default function EditorLayout() { localStorage.setItem('sencho-theme', theme); }, [isDarkMode, theme]); + // ⌘K / Ctrl+K — focus stack search input + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + const input = document.querySelector('[cmdk-input]'); + input?.focus(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + // Listen for cross-component navigation (e.g., NodeManager → Schedules) useEffect(() => { const handler = (e: Event) => { @@ -1367,13 +1380,16 @@ export default function EditorLayout() { {/* Search Input & Stack List */} -
+
+ + {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K +
{isPro && labels.length > 0 && (
@@ -2001,7 +2017,7 @@ export default function EditorLayout() { {/* Right Column (The Editor) */} -
+
setActiveTab(value as 'compose' | 'env')}> @@ -2032,27 +2048,36 @@ export default function EditorLayout() { )}
{can('stack:edit', 'stack', stackName) && ( -
+
{!isEditing ? ( ) : ( - <> - - - - + + + + + + + + Save Only + + + + Discard Changes + + + +
)}
)} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 211ea22e..8bd8b867 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -26,7 +26,8 @@ import { useLicense } from '@/context/LicenseContext'; import { ProGate } from './ProGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; -import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill'; +import { LabelDot, type Label as StackLabel } from './LabelPill'; +import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; // --- Types --- @@ -1022,24 +1023,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {fleetLabels.length > 0 && ( <>
-
- {fleetLabels.map(label => ( - { - setLabelFilters(prev => { - const next = new Set(prev); - if (next.has(label.id)) next.delete(label.id); - else next.add(label.id); - return next; - }); - }} - /> - ))} -
+ ({ value: String(l.id), label: l.name, color: l.color }))} + selected={new Set(Array.from(labelFilters).map(String))} + onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))} + placeholder="Tags" + renderOption={(option) => ( + + + {option.label} + + )} + /> )}
diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index f99b353e..11761709 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -200,16 +200,16 @@ export function GlobalObservabilityView() { }; return ( -
- {/* Node Context Indicator */} - {activeNode?.type === 'remote' && ( -
- - {activeNode.name} -
- )} - {/* Floating Action Bar */} -
+
+ {/* Permanent Toolbar */} +
+ {activeNode?.type === 'remote' && ( +
+ + {activeNode.name} +
+ )} +
- + - + {allStacks.map(stack => ( - - +
{devMode && (
● LIVE
)} + + +
- {loading && logs.length === 0 && ( -
- -
- )} - -
+
+ {loading && logs.length === 0 && ( +
+ +
+ )} {filteredLogs.length > 0 ? ( <> {filteredLogs.length > MAX_DISPLAY_ROWS && ( -
+
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
)} {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => ( -
- [{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] - [{log.containerName}] - {log.level}: - {log.message} +
+ [{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] + [{log.containerName}] + {log.level}: + {log.message}
))}
) : ( -
+
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
)} diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 3a495ca6..c241031b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -634,7 +634,7 @@ export default function ResourcesView() { defaultValue="images" className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150" > -
+
{(['images', 'volumes', 'networks'] as const).map(tab => ( diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index 7517fbfc..773d0ac1 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -1,9 +1,12 @@ -import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User } from 'lucide-react'; +import { useState } from 'react'; +import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; 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 { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; import { TierBadge } from './TierBadge'; type Theme = 'light' | 'dark' | 'auto'; @@ -17,6 +20,24 @@ interface UserProfileDropdownProps { export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { const { logout, user, isAdmin } = useAuth(); const { license } = useLicense(); + const [billingLoading, setBillingLoading] = useState(false); + + const openBillingPortal = async () => { + setBillingLoading(true); + try { + const res = await apiFetch('/license/billing-portal', { localOnly: true }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.url) { + window.open(data.url, '_blank'); + return; + } + toast.error(data?.error || data?.message || data?.data?.error || 'Something went wrong.'); + } catch { + toast.error('Failed to open billing portal.'); + } finally { + setBillingLoading(false); + } + }; return ( @@ -57,15 +78,18 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro Settings {license?.status === 'active' && ( - - + {billingLoading ? ( + + ) : ( + + )} Billing - + )}
diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index e4faddce..62022406 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -8,14 +8,33 @@ import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from '@/components/TierBadge'; import { Crown, CheckCircle, Check, XCircle, Clock, ExternalLink, - CreditCard, RefreshCw, Zap, Compass, ShipWheel, + CreditCard, RefreshCw, Zap, Compass, ShipWheel, Loader2, } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; export function LicenseSection() { const { license, activate, deactivate } = useLicense(); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); + const [billingLoading, setBillingLoading] = useState(false); + + const openBillingPortal = async () => { + setBillingLoading(true); + try { + const res = await apiFetch('/license/billing-portal', { localOnly: true }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.url) { + window.open(data.url, '_blank'); + return; + } + toast.error(data?.error || data?.message || data?.data?.error || 'Something went wrong.'); + } catch { + toast.error('Failed to open billing portal.'); + } finally { + setBillingLoading(false); + } + }; return (
@@ -94,17 +113,20 @@ export function LicenseSection() { {/* Manage Subscription (active Pro) */} {license?.status === 'active' && (
- {license.portalUrl && ( - - )} + )} + Manage Subscription + +

Deactivating will revert to Community features. diff --git a/frontend/src/components/ui/multi-select-combobox.tsx b/frontend/src/components/ui/multi-select-combobox.tsx new file mode 100644 index 00000000..9b281cac --- /dev/null +++ b/frontend/src/components/ui/multi-select-combobox.tsx @@ -0,0 +1,159 @@ +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" +import { cn } from "@/lib/utils" + +export interface MultiSelectOption { + value: string + label: string + color?: string +} + +interface MultiSelectComboboxProps { + options: MultiSelectOption[] + selected: Set + onSelectionChange: (selected: Set) => void + placeholder?: string + searchPlaceholder?: string + emptyText?: string + disabled?: boolean + className?: string + renderOption?: (option: MultiSelectOption, isSelected: boolean) => React.ReactNode +} + +export function MultiSelectCombobox({ + options, + selected, + onSelectionChange, + placeholder = "Select...", + searchPlaceholder = "Search...", + emptyText = "No results found.", + disabled = false, + className, + renderOption, +}: MultiSelectComboboxProps) { + const [open, setOpen] = React.useState(false) + const [search, setSearch] = React.useState("") + const wrapperRef = React.useRef(null) + + const filtered = search + ? options.filter((o) => + o.label.toLowerCase().includes(search.toLowerCase()) + ) + : options + + React.useEffect(() => { + if (!open) return + const onMouseDown = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false) + setSearch("") + } + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation() + setOpen(false) + setSearch("") + } + } + document.addEventListener("mousedown", onMouseDown) + document.addEventListener("keydown", onKeyDown, true) + return () => { + document.removeEventListener("mousedown", onMouseDown) + document.removeEventListener("keydown", onKeyDown, true) + } + }, [open]) + + const handleToggle = (option: MultiSelectOption) => { + const next = new Set(selected) + if (next.has(option.value)) { + next.delete(option.value) + } else { + next.add(option.value) + } + onSelectionChange(next) + } + + const triggerLabel = selected.size > 0 + ? `${selected.size} tag${selected.size !== 1 ? 's' : ''}` + : placeholder + + return ( +

+ + + {open && ( +
+ {options.length > 5 && ( +
+ setSearch(e.target.value)} + placeholder={searchPlaceholder} + className="h-7 w-full bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + autoFocus + /> +
+ )} +
+ {filtered.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + filtered.map((option) => { + const isSelected = selected.has(option.value) + return ( + + ) + }) + )} +
+ {selected.size > 0 && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx index c9223d45..811f577d 100644 --- a/frontend/src/components/ui/toast.tsx +++ b/frontend/src/components/ui/toast.tsx @@ -54,24 +54,24 @@ const notificationConfig: Record = { info: { - iconColor: 'text-blue-500 dark:text-blue-400', + iconColor: 'text-info', icon: , - gradient: 'from-blue-100/60 to-transparent dark:from-blue-900/20 dark:to-transparent', + gradient: 'from-info-muted to-transparent', }, success: { - iconColor: 'text-green-500 dark:text-green-400', + iconColor: 'text-success', icon: , - gradient: 'from-green-100/60 to-transparent dark:from-green-900/20 dark:to-transparent', + gradient: 'from-success-muted to-transparent', }, warning: { - iconColor: 'text-yellow-500 dark:text-yellow-400', + iconColor: 'text-warning', icon: , - gradient: 'from-yellow-100/60 to-transparent dark:from-yellow-900/20 dark:to-transparent', + gradient: 'from-warning-muted to-transparent', }, error: { - iconColor: 'text-red-500 dark:text-red-400', + iconColor: 'text-destructive', icon: , - gradient: 'from-red-100/60 to-transparent dark:from-red-900/20 dark:to-transparent', + gradient: 'from-destructive-muted to-transparent', }, }; @@ -113,7 +113,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 100 }} transition={{ duration: 0.3 }} - className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]" + className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-card/80 border border-glass-border overflow-hidden ring-1 ring-glass-border drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]" onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -126,11 +126,11 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message {config.icon}
-

{message}

+

{message}

{/* Progress bar — Sera UI style with Framer Motion */} -
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 3d201b8f..701c6ec5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -48,6 +48,7 @@ --info: oklch(0.62 0.14 250); --info-foreground: oklch(1 0 0); --info-muted: oklch(0.62 0.14 250 / 0.12); + --destructive-muted: oklch(0.63 0.19 23 / 0.12); /* Charts */ --chart-1: oklch(0.8100 0.1700 75.3500); @@ -187,6 +188,7 @@ --info: oklch(0.70 0.14 250); --info-foreground: oklch(0.10 0 0); --info-muted: oklch(0.70 0.14 250 / 0.15); + --destructive-muted: oklch(0.69 0.20 24 / 0.15); /* Charts — monochrome blue/teal */ --chart-1: oklch(0.72 0.08 220); @@ -336,6 +338,7 @@ --color-info: var(--info); --color-info-foreground: var(--info-foreground); --color-info-muted: var(--info-muted); + --color-destructive-muted: var(--destructive-muted); --font-sans: var(--font-sans); --font-mono: var(--font-mono);