feat(ui): glassmorphism redesign with settings decomposition (#274)

* feat(ui): add glassmorphism design tokens and utility classes

Introduce glass design system foundation: translucent oklch color
variables for both light and dark themes, glass/glass-border/glass-highlight
tokens, semantic status colors (success/warning/info), .glass and
.glass-strong utility classes with backdrop-filter, reduced shadow
values, and standardized spring animation presets in lib/motion.ts.

* feat(ui): apply glass treatment to core components

Update card, dialog, input, button, popover, sheet, tooltip,
dropdown-menu, context-menu, select, alert-dialog, and tabs
components with glassmorphism styling: translucent backgrounds via
new CSS variables, backdrop-blur layers, glass-border luminous
edges, and glass-highlight hover states.

* refactor(settings): decompose Settings Modal into section components

Extract 10 inline sections from the 1,987-line SettingsModal into
dedicated files under components/settings/. Introduce section
registry pattern replacing 14 conditional blocks. Add shared types,
sidebar navigation grouping with separators, glass treatment on
sidebar and nav buttons, and responsive modal height. SettingsModal
shell shrinks to ~380 lines.

* refactor(ui): unify all tabs to animate-ui TabsHighlight with glass styling

Migrate 4 tab instances (EditorLayout, FleetView, ResourcesView,
NotificationsSection) from inconsistent patterns (manual layoutId,
underline border-b-2, default fade) to the shared TabsHighlight
primitive with glass-highlight indicator and springs.snappy
transition. Standardize EditorLayout nav highlight spring config,
apply glass-highlight to sidebar stack list hover/active states,
and update mobile nav styling.

* refactor(ui): migrate hardcoded colors to semantic CSS variables

Replace hardcoded Tailwind color classes across ~19 component files
with semantic CSS variable classes: emerald/green to success,
orange/amber to warning, blue to info. Preserves brand/decorative
colors (Crown amber, Admiral blue). Enables consistent theming of
status indicators across the entire application.

* refactor(ui): Linear dark precision aesthetic — solid surfaces, depth cues, text hierarchy

Replace glassmorphism with Linear.app-inspired design: solid surface tokens
(card #111111, sidebar #0d0d0d, root #0a0a0a), backdrop-blur restricted to
floating overlays only (blur(10px) saturate(1.15)), desaturated teal accent,
font-weight 500 everywhere, monochrome chart palette, and three depth cues:
root ambient glow, luminous card top-edge, steep text brightness ramp.

* refactor(ui): precision polish — fix muddy dark, snowblind light, add design anchors

- Replace 34 hardcoded rgba values with theme-aware stat-* CSS tokens
- Fix light theme: solid white cards, off-white background, readable text
- Add card-border tokens with sharper directional lighting (top edge 2x)
- Add chart-grid/chart-tick tokens for theme-aware axis rendering
- Upgrade body glow: teal-tinted (dark), warm amber (light)
- Terminal-inspired sidebar: monospaced UP/DN status codes, Geist Mono
- Add tabular-nums to stat values to prevent layout jitter
- Light mode cards get shadow-sm for depth against off-white background

* refactor(ui): Linear materiality pass — ghosted nav, translucent sidebar, font unity

- De-escalate Delete button from solid destructive to ghost with hover fill
- Make sidebar translucent (80% opacity + backdrop-blur) so body glow bleeds through
- Bump dark nav accent to 0.07 for ghosted backlit selection
- Unify all terminal/editor fonts to Geist Mono (was JetBrains/Consolas mix)
- Add Monaco editor fontFamily for YAML/env editing consistency
- Add threshold-based color to Host RAM and Host Disk stat values (warn/crit)

* refactor(ui): material simulation — inherent depth, layer separation, recessed terminal

- Bump dark background 0.065→0.08, card surfaces 0.10→0.12 for 4% layer separation
- Add card-bevel token (inset top shimmer) for permanent structural depth
- Add button-inner-glow token for physical key feel on outline buttons
- Recess terminal with inset shadow and dimmed label
- Reduce action icon strokeWidth to 1.5 for refined industrial feel
- Add teal LED backlight bar on active nav item via blur pseudo-element

* fix(ui): parse usagePercent string to number for getValueColor

usagePercent is typed as string in SystemStats but getValueColor
expects number, causing TS2345 in CI builds.
This commit is contained in:
Anso
2026-03-30 12:48:42 -04:00
committed by GitHub
parent cd4aa746ab
commit 7637091e84
46 changed files with 2483 additions and 2053 deletions
@@ -0,0 +1,51 @@
import { Badge } from '@/components/ui/badge';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
export function AboutSection() {
const { license } = useLicense();
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">About Sencho</h3>
<p className="text-sm text-muted-foreground">Version and instance information.</p>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<Badge variant="secondary" className="font-mono">v{__APP_VERSION__}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tier</span>
<div><TierBadge /></div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">License Status</span>
<Badge variant="outline" className="capitalize">{license?.status ?? 'community'}</Badge>
</div>
{license?.instanceId && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Instance ID</span>
<code className="text-xs font-mono bg-muted px-2 py-1 rounded">{license.instanceId.slice(0, 8)}</code>
</div>
)}
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Links</h4>
<div className="flex flex-col gap-1.5">
<a
href="https://github.com/AnsoCode/Sencho/blob/main/CHANGELOG.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog &rarr;
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RefreshCw } from 'lucide-react';
interface AccountSectionProps {
authData: { oldPassword: string; newPassword: string; confirmPassword: string };
onAuthDataChange: (data: { oldPassword: string; newPassword: string; confirmPassword: string }) => void;
onPasswordChange: () => Promise<void>;
isSaving: boolean;
}
export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">Account & Security</h3>
<p className="text-sm text-muted-foreground">Manage your credentials and authentication.</p>
</div>
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<Label>Current Password</Label>
<Input
type="password"
value={authData.oldPassword}
onChange={(e) => onAuthDataChange({ ...authData, oldPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>New Password</Label>
<Input
type="password"
value={authData.newPassword}
onChange={(e) => onAuthDataChange({ ...authData, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Confirm New Password</Label>
<Input
type="password"
value={authData.confirmPassword}
onChange={(e) => onAuthDataChange({ ...authData, confirmPassword: e.target.value })}
/>
</div>
<Button onClick={onPasswordChange} disabled={isSaving} className="w-full">
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
: 'Update Password'
}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { RefreshCw } from 'lucide-react';
import type { PatchableSettings } from './types';
interface AppStoreSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
isLoading: boolean;
/** Called after a successful save so the parent can update serverSettingsRef */
onSaved: (key: keyof PatchableSettings, value: string) => void;
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
);
}
export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved }: AppStoreSectionProps) {
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
const saveRegistrySettings = async () => {
setIsSavingRegistry(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
return;
}
onSaved('template_registry_url', settings.template_registry_url ?? '');
await apiFetch('/templates/refresh-cache', { method: 'POST' });
toast.success('Registry saved. App Store will reload from the new source.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to save registry settings.');
} finally {
setIsSavingRegistry(false);
}
};
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">App Store Registry</h3>
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
<div className="space-y-3 pt-4 border-t border-glass-border">
<div className="space-y-1">
<Label className="text-base">Custom Registry URL</Label>
<p className="text-xs text-muted-foreground">
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
</p>
</div>
<Input
placeholder="https://example.com/templates.json"
value={settings.template_registry_url ?? ''}
onChange={(e) => onSettingChange('template_registry_url', e.target.value)}
/>
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
</div>
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => onSettingChange('template_registry_url', '')}
disabled={isSavingRegistry || !settings.template_registry_url}
>
Reset to Default
</Button>
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
{isSavingRegistry
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save & Refresh'
}
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,180 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useLicense } from '@/context/LicenseContext';
import { RefreshCw, Database, Info } from 'lucide-react';
import type { PatchableSettings } from './types';
interface DeveloperSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
isSaving: boolean;
isLoading: boolean;
isRemote: boolean;
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
);
}
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
const { isPro, license } = useLicense();
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">Developer</h3>
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and data retention.</p>
</div>
{isRemote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5 cursor-help">
<Info className="w-3 h-3 mr-1" />
Always Local
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
These settings control this Sencho instance's UI behaviour and are never synced to remote nodes.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
</div>
<Switch
id="developer_mode"
checked={settings.developer_mode === '1'}
onCheckedChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
<div className="space-y-2 pt-4 border-t border-glass-border">
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>
Standard Log Polling Rate
</Label>
<Select
value={settings.global_logs_refresh}
onValueChange={(val) => onSettingChange('global_logs_refresh', val as '1' | '3' | '5' | '10')}
disabled={settings.developer_mode === '1'}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="Select rate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 second</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
<SelectItem value="10">10 seconds</SelectItem>
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-warning">SSE streaming is active - polling rate is overridden.</p>
)}
</div>
</div>
{/* Data Retention (Observability) */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Data Retention</span>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-base">Container Metrics Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep per-container CPU/RAM/network history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">hrs</span>
</div>
</div>
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Notification Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep alert and notification history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
{isPro && license?.variant === 'team' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep audit trail entries.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save Developer Settings'
}
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import {
Crown, CheckCircle, Check, XCircle, Clock, ExternalLink,
CreditCard, RefreshCw, Zap, Compass, ShipWheel,
} from 'lucide-react';
export function LicenseSection() {
const { license, activate, deactivate } = useLicense();
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [isActivating, setIsActivating] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">License</h3>
<p className="text-sm text-muted-foreground">Manage your Sencho Pro license.</p>
</div>
{/* Current Tier Display */}
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{license?.tier === 'pro' ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<Crown className="w-5 h-5 text-muted-foreground" />
)}
<span className="font-medium text-base">
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
</span>
</div>
<TierBadge />
</div>
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining</span>
</div>
)}
{license?.status === 'active' && (
<div className="space-y-2 text-sm">
{license.customerName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Customer</span>
<span>{license.customerName}</span>
</div>
)}
{license.productName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span>{license.productName}</span>
</div>
)}
{license.maskedKey && (
<div className="flex justify-between">
<span className="text-muted-foreground">License Key</span>
<span className="font-mono text-xs">{license.maskedKey}</span>
</div>
)}
{license.validUntil && (
<div className="flex justify-between">
<span className="text-muted-foreground">Renews</span>
<span>{new Date(license.validUntil).toLocaleDateString()}</span>
</div>
)}
</div>
)}
{license?.status === 'expired' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your Pro license has expired. Renew to restore Pro features.</span>
</div>
)}
{license?.status === 'disabled' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your license has been disabled. Contact support for assistance.</span>
</div>
)}
</div>
{/* 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')}
>
<CreditCard className="w-4 h-4 mr-2" />
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.
</p>
<Button
variant="outline"
size="sm"
onClick={async () => {
setIsDeactivating(true);
const result = await deactivate();
if (result.success) {
toast.success('License deactivated.');
} else {
toast.error(result.error || 'Deactivation failed');
}
setIsDeactivating(false);
}}
disabled={isDeactivating}
>
{isDeactivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Deactivating...</>
: 'Deactivate License'
}
</Button>
</div>
</div>
)}
{/* Upgrade Cards */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users */}
{license?.tier !== 'pro' && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<Compass className="w-4 h-4 text-amber-500" />
<span className="font-medium text-sm">Skipper</span>
<Badge variant="secondary" className="text-[10px] font-medium uppercase px-1.5 py-0">Popular</Badge>
</div>
<p className="text-xs text-muted-foreground">Professional tools for solo operators.</p>
<ul className="space-y-1.5">
{['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Skipper
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
{/* Admiral Card */}
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Admiral</span>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(license?.variant === 'personal' ? ['Everything in Skipper'] : ['Everything in Community']),
'Unlimited admin accounts',
'Unlimited viewer accounts',
...(license?.variant !== 'personal' ? ['Fleet View & webhooks', 'Atomic deployment & backups'] : []),
'Team onboarding assistance',
].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Admiral
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
</div>
</div>
)}
{/* License key activation */}
{license?.status !== 'active' && (
<div className="border-t border-glass-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
<div className="flex gap-2">
<Input
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
/>
<Button
variant="outline"
onClick={async () => {
if (!licenseKeyInput.trim()) return;
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
}
setIsActivating(false);
}}
disabled={isActivating || !licenseKeyInput.trim()}
>
{isActivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Activating...</>
: 'Activate'
}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { RefreshCw, Info } from 'lucide-react';
import type { Agent } from './types';
export function NotificationsSection() {
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord');
const [agents, setAgents] = useState<Record<string, Agent>>({
discord: { type: 'discord', url: '', enabled: false },
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
});
const [isSavingAgent, setIsSavingAgent] = useState<Record<string, boolean>>({});
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
const fetchAgents = async () => {
try {
const res = await apiFetch('/agents');
if (res.ok) {
const data: Agent[] = await res.json();
setAgents(prev => {
const next = { ...prev };
data.forEach(a => { next[a.type] = a; });
return next;
});
}
} catch (e) {
console.error('Failed to fetch agents', e);
}
};
useEffect(() => { fetchAgents(); }, [activeNode?.id]);
const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => {
setAgents(prev => ({
...prev,
[type]: { ...prev[type], [field]: value },
}));
};
const saveAgent = async (type: string) => {
setIsSavingAgent(prev => ({ ...prev, [type]: true }));
try {
const res = await apiFetch('/agents', {
method: 'POST',
body: JSON.stringify(agents[type]),
});
if (res.ok) {
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`);
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Something went wrong.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setIsSavingAgent(prev => ({ ...prev, [type]: false }));
}
};
const testAgent = async (type: string) => {
if (!agents[type].url) {
toast.error('Please enter a webhook URL first.');
return;
}
setIsTestingAgent(prev => ({ ...prev, [type]: true }));
try {
const res = await apiFetch('/notifications/test', {
method: 'POST',
body: JSON.stringify({ type, url: agents[type].url }),
});
if (res.ok) {
toast.success('Test notification sent!');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.details || err?.error || 'Test failed.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setIsTestingAgent(prev => ({ ...prev, [type]: false }));
}
};
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${type}-enabled`} className="font-medium">Enable {title}</Label>
<Switch
id={`${type}-enabled`}
checked={agents[type].enabled}
onCheckedChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-url`}>Webhook URL</Label>
<Input
id={`${type}-url`}
placeholder="https://..."
value={agents[type].url}
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
/>
</div>
<div className="flex space-x-2 justify-end pt-4">
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
{isTestingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Testing...</> : 'Test'}
</Button>
<Button onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
{isSavingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</> : 'Save'}
</Button>
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">Notifications & Alerts</h3>
<p className="text-sm text-muted-foreground">
{isRemote
? <>Configuring notification channels on <span className="font-medium text-foreground">{activeNode!.name}</span>. Alerts from this remote node will dispatch via these channels.</>
: 'Configure external integrations for crash alerts.'
}
</p>
</div>
{isRemote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5 cursor-help">
<Info className="w-3 h-3 mr-1" />
Remote
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-center">
These channels are saved on the remote Sencho instance and used when it dispatches alerts.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="slack">
<TabsTrigger value="slack">Slack</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="webhook">
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
export function SupportSection() {
const { isPro, license } = useLicense();
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">Help & Support</h3>
<p className="text-sm text-muted-foreground">Get help with Sencho based on your plan.</p>
</div>
{/* Self-serve channels (all tiers) */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Resources</h4>
<div className="grid gap-3">
<a href="https://docs.sencho.io" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Book className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documentation</p>
<p className="text-xs text-muted-foreground">Guides, reference, and tutorials</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
<a href="https://github.com/AnsoCode/Sencho/issues" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Bug className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">GitHub Issues</p>
<p className="text-xs text-muted-foreground">Report bugs and request features</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
</div>
</div>
{/* Pro support channels */}
{isPro && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
Pro Support <TierBadge />
</h4>
<div className="grid gap-3">
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mail className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{license?.variant === 'team' ? 'Priority Email Support' : 'Email Support'}
</p>
<p className="text-xs text-muted-foreground">
{license?.variant === 'team'
? 'Direct support with responses within 24 hours'
: 'Reach our support team directly'}
</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
</div>
</div>
)}
{/* Upsell for Community */}
{!isPro && (
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
<div className="flex items-start gap-3">
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Need faster support?</p>
<p className="text-xs text-muted-foreground mt-1">
Upgrade to Pro for direct email support and priority issue handling.
</p>
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
Upgrade to Pro
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,131 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Info } from 'lucide-react';
import type { PatchableSettings } from './types';
interface SystemSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
isSaving: boolean;
isLoading: boolean;
isRemote: boolean;
activeNodeName?: string;
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
);
}
export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote, activeNodeName }: SystemSectionProps) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">System Limits & Watchdog</h3>
<p className="text-sm text-muted-foreground">Configure alert thresholds and crash detection.</p>
</div>
{isRemote && (
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
<Info className="w-3 h-3 mr-1" />
Configuring: {activeNodeName}
</Badge>
)}
</div>
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label className="text-base">Host CPU Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_cpu_limit || '90')]}
onValueChange={(v) => onSettingChange('host_cpu_limit', v[0].toString())}
/>
</div>
<div className="space-y-4 pt-2 border-t border-glass-border">
<div className="flex justify-between items-center">
<Label className="text-base">Host RAM Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_ram_limit || '90')]}
onValueChange={(v) => onSettingChange('host_ram_limit', v[0].toString())}
/>
</div>
<div className="space-y-4 pt-2 border-t border-glass-border">
<div className="flex justify-between items-center">
<Label className="text-base">Host Disk Alert Threshold</Label>
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
</div>
<Slider
min={1} max={100} step={1}
value={[parseInt(settings.host_disk_limit || '90')]}
onValueChange={(v) => onSettingChange('host_disk_limit', v[0].toString())}
/>
</div>
<div className="space-y-2 pt-2 border-t border-glass-border">
<Label className="text-base">Docker Janitor Storage Threshold</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
step={0.5}
value={settings.docker_janitor_gb}
onChange={(e) => onSettingChange('docker_janitor_gb', e.target.value)}
className="max-w-[150px]"
/>
<span className="text-sm text-muted-foreground">GB reclaimable</span>
</div>
<p className="text-xs text-muted-foreground">Alert when unused Docker data exceeds this size.</p>
</div>
<div className="flex items-center justify-between pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
<p className="text-xs text-muted-foreground">Watch all containers for unexpected exits</p>
</div>
<Switch
id="global_crash"
checked={settings.global_crash === '1'}
onCheckedChange={(c) => onSettingChange('global_crash', c ? '1' : '0')}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save Limits'
}
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,457 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from '@/components/ProGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
interface UserItem {
id: number;
username: string;
role: UserRole;
created_at: number;
}
interface RoleAssignmentItem {
id: number;
user_id: number;
role: UserRole;
resource_type: 'stack' | 'node';
resource_id: string;
created_at: number;
}
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPro, license } = useLicense();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formUsername, setFormUsername] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formConfirmPassword, setFormConfirmPassword] = useState('');
const [formRole, setFormRole] = useState<UserRole>('viewer');
const fetchUsers = async () => {
try {
const res = await apiFetch('/users', { localOnly: true });
if (res.ok) setUsers(await res.json());
} catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { fetchUsers(); }, []);
const resetForm = () => {
setFormUsername('');
setFormPassword('');
setFormConfirmPassword('');
setFormRole('viewer');
setEditingUser(null);
setShowForm(false);
};
const handleSave = async () => {
if (!formUsername || formUsername.length < 3) {
toast.error('Username must be at least 3 characters.');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) {
toast.error('Username can only contain letters, numbers, underscores, and hyphens.');
return;
}
if (!editingUser && !formPassword) {
toast.error('Password is required for new users.');
return;
}
if (formPassword && formPassword.length < 6) {
toast.error('Password must be at least 6 characters.');
return;
}
if (formPassword && formPassword !== formConfirmPassword) {
toast.error('Passwords do not match.');
return;
}
setSaving(true);
try {
if (editingUser) {
const body: Record<string, string> = { username: formUsername, role: formRole };
if (formPassword) body.password = formPassword;
const res = await apiFetch(`/users/${editingUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to update user.');
return;
}
toast.success('User updated.');
} else {
const res = await apiFetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to create user.');
return;
}
toast.success('User created.');
}
resetForm();
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleDelete = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to delete user.');
return;
}
toast.success('User deleted.');
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
const startEdit = (u: UserItem) => {
setEditingUser(u);
setFormUsername(u.username);
setFormRole(u.role);
setFormPassword('');
setFormConfirmPassword('');
setShowForm(true);
fetchRoleAssignments(u.id);
fetchScopeResources();
};
// --- Scoped Role Assignments ---
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
const [scopeResourceId, setScopeResourceId] = useState('');
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
const [addingScope, setAddingScope] = useState(false);
const fetchRoleAssignments = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}/roles`, { localOnly: true });
if (res.ok) setRoleAssignments(await res.json());
else setRoleAssignments([]);
} catch { setRoleAssignments([]); }
};
const fetchScopeResources = async () => {
try {
const [stacksRes, nodesRes] = await Promise.all([
apiFetch('/stacks', { localOnly: true }),
apiFetch('/nodes', { localOnly: true }),
]);
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
}
if (nodesRes.ok) {
const data = await nodesRes.json();
setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []);
}
} catch { /* ignore */ }
};
const addRoleAssignment = async () => {
if (!editingUser || !scopeResourceId) return;
setAddingScope(true);
try {
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to add scope.');
return;
}
toast.success('Scope added.');
setScopeResourceId('');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally { setAddingScope(false); }
};
const removeRoleAssignment = async (assignId: number) => {
if (!editingUser) return;
try {
const res = await apiFetch(`/users/${editingUser.id}/roles/${assignId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to remove scope.');
return;
}
toast.success('Scope removed.');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
return (
<ProGate featureName="User management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight">User Management</h3>
<p className="text-sm text-muted-foreground">Create and manage user accounts with role-based access control.</p>
</div>
{!showForm && (
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1" />Add User
</Button>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<h4 className="text-sm font-medium">{editingUser ? 'Edit User' : 'New User'}</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Username</Label>
<Input
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={formRole} onValueChange={(v) => setFormRole(v as UserRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPro && license?.variant === 'team' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'}
/>
</div>
<div className="space-y-2">
<Label>Confirm Password</Label>
<Input
type="password"
value={formConfirmPassword}
onChange={(e) => setFormConfirmPassword(e.target.value)}
placeholder="Confirm password"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
</Button>
</div>
{/* Scoped Permissions (Admiral, editing only) */}
{editingUser && isPro && license?.variant === 'team' && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
Grant additional permissions on specific stacks or nodes. These supplement the user's global role.
</p>
{roleAssignments.length > 0 && (
<div className="space-y-1">
{roleAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm bg-muted/50 rounded px-3 py-1.5">
<span>
<Badge variant="outline" className="text-xs mr-2 capitalize">{a.role}</Badge>
on <span className="font-medium capitalize">{a.resource_type}</span>: <span className="font-mono text-xs">{a.resource_id}</span>
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeRoleAssignment(a.id)}>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="space-y-1">
<Label className="text-xs">Role</Label>
<Select value={scopeRole} onValueChange={(v) => setScopeRole(v as UserRole)}>
<SelectTrigger className="h-8 text-xs w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Resource Type</Label>
<Select value={scopeResourceType} onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stack">Stack</SelectItem>
<SelectItem value="node">Node</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex-1">
<Label className="text-xs">Resource</Label>
<Select value={scopeResourceId} onValueChange={setScopeResourceId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{scopeResourceType === 'stack' ? (
availableStacks.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))
) : (
availableNodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
</div>
</div>
)}
</div>
)}
{/* Users Table */}
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">No users found.</div>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={isSelf}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The user will lose access immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</ProGate>
);
}
@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { TierBadge } from '@/components/TierBadge';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
} from 'lucide-react';
interface WebhookItem {
id: number;
name: string;
stack_name: string;
action: string;
secret: string;
enabled: boolean;
created_at: number;
updated_at: number;
}
interface WebhookExecution {
id: number;
webhook_id: number;
action: string;
status: 'success' | 'failure';
trigger_source: string | null;
duration_ms: number | null;
error: string | null;
executed_at: number;
}
export function WebhooksSection({ isPro }: { isPro: boolean }) {
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [showForm, setShowForm] = useState(false);
const [newSecret, setNewSecret] = useState<{ id: number; secret: string } | null>(null);
const [expandedHistory, setExpandedHistory] = useState<number | null>(null);
const [history, setHistory] = useState<Record<number, WebhookExecution[]>>({});
const [loadingHistory, setLoadingHistory] = useState<number | null>(null);
// Form state
const [formName, setFormName] = useState('');
const [formStack, setFormStack] = useState('');
const [formAction, setFormAction] = useState<string>('deploy');
const [stacks, setStacks] = useState<string[]>([]);
const fetchWebhooks = async () => {
try {
const res = await apiFetch('/webhooks', { localOnly: true });
if (res.ok) setWebhooks(await res.json());
} catch { /* ignore */ } finally { setLoading(false); }
};
const fetchStacks = async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) setStacks(await res.json());
} catch { /* ignore */ }
};
useEffect(() => { fetchWebhooks(); fetchStacks(); }, []);
const handleCreate = async () => {
if (!formName || !formStack || !formAction) {
toast.error('All fields are required.');
return;
}
setCreating(true);
try {
const res = await apiFetch('/webhooks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }),
});
if (res.ok) {
const data = await res.json();
setNewSecret({ id: data.id, secret: data.secret });
setShowForm(false);
setFormName(''); setFormStack(''); setFormAction('deploy');
fetchWebhooks();
toast.success('Webhook created.');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to create webhook.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally { setCreating(false); }
};
const handleDelete = async (id: number) => {
try {
const res = await apiFetch(`/webhooks/${id}`, { method: 'DELETE', localOnly: true });
if (res.ok) { toast.success('Webhook deleted.'); fetchWebhooks(); }
else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete.'); }
} catch { toast.error('Network error.'); }
};
const handleToggle = async (id: number, enabled: boolean) => {
try {
const res = await apiFetch(`/webhooks/${id}`, {
method: 'PUT', localOnly: true,
body: JSON.stringify({ enabled }),
});
if (res.ok) fetchWebhooks();
} catch { /* ignore */ }
};
const fetchHistory = async (webhookId: number) => {
if (expandedHistory === webhookId) { setExpandedHistory(null); return; }
setExpandedHistory(webhookId);
setLoadingHistory(webhookId);
try {
const res = await apiFetch(`/webhooks/${webhookId}/history`, { localOnly: true });
if (res.ok) {
const data = await res.json();
setHistory(prev => ({ ...prev, [webhookId]: data }));
}
} catch { /* ignore */ } finally { setLoadingHistory(null); }
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard.`);
};
if (!isPro) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<ProGate featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</ProGate>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" /> Create Webhook
</Button>
</div>
{/* Create Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Stack</Label>
<Select value={formStack} onValueChange={setFormStack}>
<SelectTrigger><SelectValue placeholder="Select a stack..." /></SelectTrigger>
<SelectContent>
{stacks.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select value={formAction} onValueChange={setFormAction}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deploy">Deploy (down + up)</SelectItem>
<SelectItem value="restart">Restart</SelectItem>
<SelectItem value="stop">Stop</SelectItem>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="pull">Pull & Update</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
</Button>
</div>
</div>
)}
{/* Secret reveal (shown once after creation) */}
{newSecret && (
<div className="bg-success-muted border border-success/30 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-success">
<CheckCircle className="w-4 h-4" /> Webhook created - copy your secret now
</div>
<p className="text-xs text-muted-foreground">This secret will not be shown again. Store it securely.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all">{newSecret.secret}</code>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(newSecret.secret, 'Secret')}>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => setNewSecret(null)}>Dismiss</Button>
</div>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{/* Empty state */}
{!loading && webhooks.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Webhook className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No webhooks configured yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to trigger stack actions from CI/CD.</p>
</div>
)}
{/* Webhook list */}
{!loading && webhooks.map(wh => {
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
const isExpanded = expandedHistory === wh.id;
return (
<div key={wh.id} className="border border-glass-border rounded-lg overflow-hidden">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Webhook className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{wh.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{wh.action}</Badge>
<Badge variant="secondary" className="text-[10px] shrink-0">{wh.stack_name}</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={wh.enabled} onCheckedChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
</div>
{/* Trigger URL */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Trigger URL</Label>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-muted px-2.5 py-1.5 rounded-md truncate">{triggerUrl}</code>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => copyToClipboard(triggerUrl, 'URL')}>
<Copy className="w-3 h-3" />
</Button>
</div>
</div>
{/* Secret (masked) */}
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Secret:</span>
<code className="font-mono text-muted-foreground">{wh.secret}</code>
</div>
{/* History toggle */}
<button
onClick={() => fetchHistory(wh.id!)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<History className="w-3 h-3" />
Recent executions
</button>
</div>
{/* Execution history */}
{isExpanded && (
<div className="border-t bg-muted/20 px-4 py-3">
{loadingHistory === wh.id ? (
<Skeleton className="h-8 w-full" />
) : (history[wh.id!] ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No executions yet.</p>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(history[wh.id!] ?? []).map(ex => (
<div key={ex.id} className="flex items-center gap-2 text-xs">
{ex.status === 'success'
? <CheckCircle className="w-3 h-3 text-success shrink-0" />
: <XCircle className="w-3 h-3 text-red-500 shrink-0" />}
<span className="font-medium">{ex.action}</span>
<span className="text-muted-foreground">
{new Date(ex.executed_at).toLocaleString()}
</span>
{ex.duration_ms !== null && (
<span className="text-muted-foreground">{(ex.duration_ms / 1000).toFixed(1)}s</span>
)}
{ex.error && (
<span className="text-red-500 truncate" title={ex.error}>{ex.error}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { AccountSection } from './AccountSection';
export { LicenseSection } from './LicenseSection';
export { UsersSection } from './UsersSection';
export { SystemSection } from './SystemSection';
export { NotificationsSection } from './NotificationsSection';
export { WebhooksSection } from './WebhooksSection';
export { DeveloperSection } from './DeveloperSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
export { DEFAULT_SETTINGS } from './types';
export type { PatchableSettings, SectionId, Agent } from './types';
+49
View File
@@ -0,0 +1,49 @@
export interface PatchableSettings {
host_cpu_limit?: string;
host_ram_limit?: string;
host_disk_limit?: string;
docker_janitor_gb?: string;
global_crash?: '0' | '1';
global_logs_refresh?: '1' | '3' | '5' | '10';
developer_mode?: '0' | '1';
template_registry_url?: string;
metrics_retention_hours?: string;
log_retention_days?: string;
audit_retention_days?: string;
}
export const DEFAULT_SETTINGS: PatchableSettings = {
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5',
global_logs_refresh: '5',
developer_mode: '0',
template_registry_url: '',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
};
export type SectionId =
| 'account'
| 'license'
| 'users'
| 'sso'
| 'api-tokens'
| 'registries'
| 'system'
| 'notifications'
| 'webhooks'
| 'developer'
| 'nodes'
| 'appstore'
| 'support'
| 'about';
export interface Agent {
type: 'discord' | 'slack' | 'webhook';
url: string;
enabled: boolean;
}