mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: UI polish sprint — 7 items + logs toolbar redesign (#365)
* feat: UI polish sprint — tag filters, toast tokens, logs toolbar, billing portal, editor UX - Fleet: replace inline tag pills with multi-select combobox dropdown - Toast: remap all notification colors to oklch design tokens - Logs: convert floating hover toolbar to permanent pinned toolbar, replace all hardcoded colors with design tokens for theme support - Audit: fix dropdown scroll-lock (modal=false), light theme button contrast - Resources: remove redundant inner border/bg on tab wrapper - Editor: add ⌘K/Ctrl+K shortcut hint and handler, standardize button heights - Billing: signed Lemon Squeezy portal URLs via sencho.io proxy for all tiers - New MultiSelectCombobox UI component * feat(editor): replace button row with split-button dropdown Replace three separate editor buttons (Discard, Save Only, Save & Deploy) with a compact split-button dropdown. Primary action is "Save & Deploy"; chevron opens dropdown with "Save Only" and "Discard Changes" options.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
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. */
|
||||
|
||||
@@ -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<string | null> {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -123,9 +123,9 @@ export function AuditLogView() {
|
||||
<CardTitle>Audit Log</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" className="border-border">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
@@ -136,7 +136,7 @@ export function AuditLogView() {
|
||||
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
|
||||
<Button variant="outline" size="sm" className="border-border" onClick={fetchLogs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -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<HTMLInputElement>('[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 */}
|
||||
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border flex-none">
|
||||
<div className="px-4 py-2 flex-none relative">
|
||||
<CommandInput
|
||||
placeholder="Search stacks..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-9"
|
||||
className="h-9 border-none"
|
||||
/>
|
||||
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
|
||||
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K
|
||||
</kbd>
|
||||
</div>
|
||||
{isPro && labels.length > 0 && (
|
||||
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
|
||||
@@ -2001,7 +2017,7 @@ export default function EditorLayout() {
|
||||
|
||||
{/* Right Column (The Editor) */}
|
||||
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
|
||||
<div className="p-4 border-b border-muted flex items-center justify-between">
|
||||
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
||||
<TabsList>
|
||||
@@ -2032,27 +2048,36 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
{can('stack:edit', 'stack', stackName) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center">
|
||||
{!isEditing ? (
|
||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={discardChanges}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Discard
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={saveFile}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Only
|
||||
</Button>
|
||||
<Button size="sm" variant="default" className="rounded-lg" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
||||
<Rocket className="w-4 h-4 mr-2" />
|
||||
<div className="flex items-center">
|
||||
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
||||
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Save & Deploy
|
||||
</Button>
|
||||
</>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={saveFile}>
|
||||
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Save Only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
|
||||
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Discard Changes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{fleetLabels.map(label => (
|
||||
<LabelPill
|
||||
key={label.id}
|
||||
label={label}
|
||||
size="sm"
|
||||
active={labelFilters.has(label.id)}
|
||||
onClick={() => {
|
||||
setLabelFilters(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label.id)) next.delete(label.id);
|
||||
else next.add(label.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
options={fleetLabels.map(l => ({ 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) => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LabelDot color={option.color as StackLabel['color'] ?? 'slate'} />
|
||||
{option.label}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -200,16 +200,16 @@ export function GlobalObservabilityView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
|
||||
{/* Node Context Indicator */}
|
||||
{activeNode?.type === 'remote' && (
|
||||
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
{activeNode.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Floating Action Bar */}
|
||||
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
|
||||
<div className="flex flex-col h-full w-full bg-background text-foreground">
|
||||
{/* Permanent Toolbar */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-border bg-card">
|
||||
{activeNode?.type === 'remote' && (
|
||||
<div className="flex items-center gap-1.5 border border-border rounded-md px-2.5 py-1 text-xs text-muted-foreground mr-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-info shrink-0" />
|
||||
{activeNode.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -220,14 +220,14 @@ export function GlobalObservabilityView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 text-sm">
|
||||
<Filter className="w-3.5 h-3.5 mr-2" />
|
||||
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{allStacks.map(stack => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={stack}
|
||||
@@ -254,46 +254,47 @@ export function GlobalObservabilityView() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
|
||||
{devMode && (
|
||||
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
|
||||
● LIVE
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && logs.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
|
||||
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4 relative bg-background" onScroll={handleScroll}>
|
||||
{loading && logs.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.length > 0 ? (
|
||||
<>
|
||||
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
||||
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
|
||||
<div className="text-muted-foreground italic text-xs text-center mb-3 py-1 border-b border-border">
|
||||
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
|
||||
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
||||
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-success'}`}>{log.level}:</span>
|
||||
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
|
||||
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-accent/50 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-info font-semibold mr-2">[{log.containerName}]</span>
|
||||
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-destructive' : log.level === 'WARN' ? 'text-warning' : 'text-success'}`}>{log.level}:</span>
|
||||
<span className={log.source === 'STDERR' ? 'text-destructive/80' : 'text-foreground/80'}>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500 italic p-4 text-center mt-10">
|
||||
<div className="text-muted-foreground italic p-4 text-center mt-10">
|
||||
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="px-4 pt-3 pb-0 border-b border-glass-border bg-glass">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
{(['images', 'volumes', 'networks'] as const).map(tab => (
|
||||
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
@@ -57,15 +78,18 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
||||
Settings
|
||||
</button>
|
||||
{license?.status === 'active' && (
|
||||
<a
|
||||
href="https://sencho.io/billing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
|
||||
<button
|
||||
onClick={openBillingPortal}
|
||||
disabled={billingLoading}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
{billingLoading ? (
|
||||
<Loader2 className="w-4 h-4 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
Billing
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -94,17 +113,20 @@ export function LicenseSection() {
|
||||
{/* Manage Subscription (active Pro) */}
|
||||
{license?.status === 'active' && (
|
||||
<div className="space-y-3">
|
||||
{license.portalUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(license.portalUrl!, '_blank')}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openBillingPortal}
|
||||
disabled={billingLoading}
|
||||
>
|
||||
{billingLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Manage Subscription
|
||||
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
Manage Subscription
|
||||
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deactivating will revert to Community features.
|
||||
|
||||
@@ -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<string>
|
||||
onSelectionChange: (selected: Set<string>) => 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<HTMLDivElement>(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 (
|
||||
<div ref={wrapperRef} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) setOpen(!open) }}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
|
||||
selected.size > 0 ? "text-foreground" : "text-muted-foreground",
|
||||
open && "ring-1 ring-ring border-ring"
|
||||
)}
|
||||
>
|
||||
<span>{triggerLabel}</span>
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-[calc(100%+4px)] z-50 min-w-[180px] rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
{options.length > 5 && (
|
||||
<div className="p-1.5 border-b border-glass-border">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-7 w-full bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-3 text-center text-xs text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option) => {
|
||||
const isSelected = selected.has(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleToggle(option)}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{renderOption ? renderOption(option, isSelected) : option.label}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{selected.size > 0 && (
|
||||
<div className="border-t border-glass-border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectionChange(new Set())}
|
||||
className="flex w-full items-center justify-center rounded-sm px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,24 +54,24 @@ const notificationConfig: Record<ToastType, {
|
||||
gradient: string;
|
||||
}> = {
|
||||
info: {
|
||||
iconColor: 'text-blue-500 dark:text-blue-400',
|
||||
iconColor: 'text-info',
|
||||
icon: <InfoIcon className="h-6 w-6" />,
|
||||
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: <SuccessIcon className="h-6 w-6" />,
|
||||
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: <WarningIcon className="h-6 w-6" />,
|
||||
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: <ErrorIcon className="h-6 w-6" />,
|
||||
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}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-normal text-gray-900 dark:text-gray-100 text-lg">{message}</p>
|
||||
<p className="font-normal text-foreground text-lg">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="flex-shrink-0 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-full hover:bg-accent"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
@@ -138,12 +138,12 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
||||
</div>
|
||||
|
||||
{/* Progress bar — Sera UI style with Framer Motion */}
|
||||
<div className="absolute bottom-0 left-0 h-1 w-full bg-gray-300/50 dark:bg-gray-600/50 rounded-b-xl overflow-hidden">
|
||||
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: hovered ? undefined : '100%' }}
|
||||
transition={{ duration: duration / 1000, ease: 'linear' }}
|
||||
className="h-full bg-gradient-to-r from-green-400 via-blue-400 to-sky-400 dark:from-green-500 dark:via-blue-500 dark:to-sky-500"
|
||||
className="h-full bg-gradient-to-r from-success via-info to-brand"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user