mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 13:21:22 +00:00
Extract app runtime owners
This commit is contained in:
@@ -44,16 +44,18 @@ agreement, and cloud-specific enforcement rules.
|
||||
22. `internal/cloudcp/stripe/provisioner.go`
|
||||
23. `internal/hosted/provisioner.go`
|
||||
24. `frontend-modern/src/App.tsx`
|
||||
25. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`
|
||||
26. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`
|
||||
27. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`
|
||||
28. `frontend-modern/src/components/Settings/ProLicensePanel.tsx`
|
||||
29. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx`
|
||||
30. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`
|
||||
31. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`
|
||||
32. `frontend-modern/src/pages/CloudPricing.tsx`
|
||||
33. `frontend-modern/src/utils/apiClient.ts`
|
||||
34. `frontend-modern/src/utils/commercialBillingModel.ts`
|
||||
25. `frontend-modern/src/AppLayout.tsx`
|
||||
26. `frontend-modern/src/useAppRuntimeState.ts`
|
||||
27. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`
|
||||
28. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`
|
||||
29. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`
|
||||
30. `frontend-modern/src/components/Settings/ProLicensePanel.tsx`
|
||||
31. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx`
|
||||
32. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`
|
||||
33. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`
|
||||
34. `frontend-modern/src/pages/CloudPricing.tsx`
|
||||
35. `frontend-modern/src/utils/apiClient.ts`
|
||||
36. `frontend-modern/src/utils/commercialBillingModel.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -81,7 +83,7 @@ agreement, and cloud-specific enforcement rules.
|
||||
15. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` and `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`
|
||||
16. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx`
|
||||
17. Add contract tests where runtime and pricing need to stay aligned
|
||||
18. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx` and `frontend-modern/src/utils/apiClient.ts`
|
||||
18. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts`
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
@@ -214,6 +216,14 @@ simply because hosted tenants do not expose self-hosted multi-tenant admin
|
||||
capabilities. Hosted runtime entry must therefore treat tenant org context as
|
||||
infrastructure state, not a paid UI toggle that can be discarded during app
|
||||
bootstrap.
|
||||
That hosted browser bootstrap owner is now intentionally split by role:
|
||||
`frontend-modern/src/App.tsx` is the route/provider entry shell,
|
||||
`frontend-modern/src/useAppRuntimeState.ts` owns authentication, org bootstrap,
|
||||
theme synchronization, and authenticated runtime startup, and
|
||||
`frontend-modern/src/AppLayout.tsx` owns authenticated cloud-aware chrome such
|
||||
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
|
||||
work must extend that split rather than pulling org bootstrap and app chrome
|
||||
back into one monolithic route component.
|
||||
The Pro license settings surface now follows the same rule as well. Changes to
|
||||
`frontend-modern/src/components/Settings/ProLicensePanel.tsx` must carry this
|
||||
contract and the dedicated Pro-license proof file instead of remaining an
|
||||
|
||||
@@ -1012,6 +1012,7 @@
|
||||
],
|
||||
"owned_files": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx",
|
||||
"frontend-modern/src/components/Settings/BillingAdminPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx",
|
||||
@@ -1019,6 +1020,7 @@
|
||||
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx",
|
||||
"frontend-modern/src/pages/CloudPricing.tsx",
|
||||
"frontend-modern/src/pages/HostedSignup.tsx",
|
||||
"frontend-modern/src/useAppRuntimeState.ts",
|
||||
"frontend-modern/src/utils/apiClient.ts",
|
||||
"internal/api/public_signup_handlers.go",
|
||||
"internal/cloudcp/account/tenant_handlers.go",
|
||||
@@ -1478,11 +1480,14 @@
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/useAppRuntimeState.ts",
|
||||
"frontend-modern/src/utils/apiClient.ts"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/__tests__/App.architecture.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/apiClient.org.test.ts"
|
||||
]
|
||||
},
|
||||
|
||||
+34
-1585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,813 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
} from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import BoxesIcon from 'lucide-solid/icons/boxes';
|
||||
import LayoutDashboardIcon from 'lucide-solid/icons/layout-dashboard';
|
||||
import ServerIcon from 'lucide-solid/icons/server';
|
||||
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
|
||||
import ArchiveIcon from 'lucide-solid/icons/archive';
|
||||
import BellIcon from 'lucide-solid/icons/bell';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
|
||||
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
|
||||
import ActivityIcon from 'lucide-solid/icons/activity';
|
||||
import { MobileNavBar } from '@/components/shared/MobileNavBar';
|
||||
import { OrgSwitcher } from '@/components/OrgSwitcher';
|
||||
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
|
||||
import { MONITORING_READ_SCOPE } from '@/constants/apiScopes';
|
||||
import type { Organization } from '@/api/orgs';
|
||||
import type { VersionInfo } from '@/api/updates';
|
||||
import type { Alert, State } from '@/types/api';
|
||||
import { useKioskMode } from '@/hooks/useKioskMode';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import { getActiveTabForPath } from '@/routing/navigation';
|
||||
import {
|
||||
buildInfrastructurePath,
|
||||
buildWorkloadsPath,
|
||||
DASHBOARD_PATH,
|
||||
} from '@/routing/resourceLinks';
|
||||
import { buildStorageRecoveryTabSpecs } from '@/routing/platformTabs';
|
||||
import { getKioskModePreference, setKioskMode } from '@/utils/url';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { isMultiTenantEnabled, isPro } from '@/stores/license';
|
||||
|
||||
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
|
||||
const ROOT_WORKLOADS_PATH = buildWorkloadsPath();
|
||||
|
||||
type PlatformTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
route: string;
|
||||
settingsRoute: string;
|
||||
tooltip: string;
|
||||
enabled: boolean;
|
||||
live: boolean;
|
||||
icon: JSX.Element;
|
||||
alwaysShow: boolean;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
type UtilityTab = {
|
||||
id: 'alerts' | 'ai' | 'operations' | 'settings';
|
||||
label: string;
|
||||
route: string;
|
||||
tooltip: string;
|
||||
badge: 'update' | 'pro' | null;
|
||||
count: number | undefined;
|
||||
breakdown: { warning: number; critical: number } | undefined;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
|
||||
export interface AppLayoutProps {
|
||||
connected: () => boolean;
|
||||
reconnecting: () => boolean;
|
||||
dataUpdated: () => boolean;
|
||||
lastUpdateText: () => string;
|
||||
versionInfo: () => VersionInfo | null;
|
||||
hasAuth: () => boolean;
|
||||
needsAuth: () => boolean;
|
||||
proxyAuthInfo: () => { username?: string; logoutURL?: string } | null;
|
||||
handleLogout: () => void;
|
||||
state: () => State;
|
||||
tokenScopes: () => string[] | undefined;
|
||||
organizations: () => Organization[];
|
||||
activeOrgID: () => string;
|
||||
orgsLoading: () => boolean;
|
||||
onSwitchOrg: (orgID: string) => void;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
function ConnectionStatusBadge(props: {
|
||||
connected: () => boolean;
|
||||
reconnecting: () => boolean;
|
||||
class?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
|
||||
props.connected()
|
||||
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
|
||||
: props.reconnecting()
|
||||
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
|
||||
: 'disconnected bg-surface-hover text-base-content min-w-6 h-6 group-hover:px-3'
|
||||
} ${props.class ?? ''}`}
|
||||
>
|
||||
<Show when={props.reconnecting()}>
|
||||
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={props.connected()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400 flex-shrink-0" />
|
||||
</Show>
|
||||
<Show when={!props.connected() && !props.reconnecting()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-slate-600 flex-shrink-0" />
|
||||
</Show>
|
||||
<span
|
||||
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
|
||||
props.connected() || (!props.connected() && !props.reconnecting())
|
||||
? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
|
||||
: 'max-w-[100px] ml-1 opacity-100'
|
||||
}`}
|
||||
>
|
||||
{props.connected()
|
||||
? 'Connected'
|
||||
: props.reconnecting()
|
||||
? 'Reconnecting...'
|
||||
: 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppLayout(props: AppLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const kioskMode = useKioskMode();
|
||||
|
||||
const [headerVisible, setHeaderVisible] = createSignal(true);
|
||||
let headerEl: HTMLDivElement | undefined;
|
||||
let headerHideTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const clearHeaderHideTimeout = () => {
|
||||
if (headerHideTimeout !== undefined) {
|
||||
clearTimeout(headerHideTimeout);
|
||||
headerHideTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const showHeader = () => {
|
||||
clearHeaderHideTimeout();
|
||||
setHeaderVisible(true);
|
||||
};
|
||||
|
||||
const scheduleHideHeader = (delayMs: number) => {
|
||||
clearHeaderHideTimeout();
|
||||
headerHideTimeout = setTimeout(() => {
|
||||
setHeaderVisible(false);
|
||||
headerHideTimeout = undefined;
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const scopes = props.tokenScopes();
|
||||
if (scopes && scopes.length === 1 && scopes[0] === MONITORING_READ_SCOPE) {
|
||||
const preference = getKioskModePreference();
|
||||
if (preference === null) {
|
||||
setKioskMode(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const toggleKioskMode = () => {
|
||||
setKioskMode(!kioskMode());
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (kioskMode()) {
|
||||
setHeaderVisible(true);
|
||||
scheduleHideHeader(1500);
|
||||
} else {
|
||||
clearHeaderHideTimeout();
|
||||
setHeaderVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!kioskMode()) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
toggleKioskMode();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
onCleanup(() => window.removeEventListener('keydown', onKeyDown));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!kioskMode()) return;
|
||||
const normalizedPath = location.pathname.replace(/\/+$/, '') || '/';
|
||||
const blockedPrefixes = ['/settings', '/operations', '/ai'];
|
||||
const isBlocked = blockedPrefixes.some(
|
||||
(prefix) => normalizedPath === prefix || normalizedPath.startsWith(prefix + '/'),
|
||||
);
|
||||
|
||||
const isAlertsPath = normalizedPath === '/alerts' || normalizedPath.startsWith('/alerts/');
|
||||
const isAlertConfigTab =
|
||||
isAlertsPath &&
|
||||
normalizedPath !== '/alerts' &&
|
||||
normalizedPath !== '/alerts/overview' &&
|
||||
normalizedPath !== '/alerts/history' &&
|
||||
!normalizedPath.startsWith('/alerts/overview/') &&
|
||||
!normalizedPath.startsWith('/alerts/history/');
|
||||
|
||||
if ((isBlocked || isAlertConfigTab) && normalizedPath !== DASHBOARD_PATH) {
|
||||
navigate(DASHBOARD_PATH, { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!kioskMode()) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
if (event.clientY > 60) return;
|
||||
showHeader();
|
||||
scheduleHideHeader(3000);
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', onPointerDown, { passive: true, capture: true });
|
||||
onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true));
|
||||
|
||||
if (typeof (window as { PointerEvent?: unknown }).PointerEvent === 'undefined') {
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches?.[0];
|
||||
if (!touch || touch.clientY > 60) return;
|
||||
showHeader();
|
||||
scheduleHideHeader(3000);
|
||||
};
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true, capture: true });
|
||||
onCleanup(() => window.removeEventListener('touchstart', onTouchStart, true));
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearHeaderHideTimeout();
|
||||
});
|
||||
|
||||
const getActiveTabDesktop = () => getActiveTabForPath(location.pathname);
|
||||
const getActiveTabMobile = () => getActiveTabForPath(location.pathname);
|
||||
|
||||
const platformTabsDesktop = createMemo(() => {
|
||||
const allPlatforms: PlatformTab[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
route: DASHBOARD_PATH,
|
||||
settingsRoute: '/settings',
|
||||
tooltip: 'Environment overview and command center',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <LayoutDashboardIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'infrastructure',
|
||||
label: 'Infrastructure',
|
||||
route: ROOT_INFRASTRUCTURE_PATH,
|
||||
settingsRoute: '/settings',
|
||||
tooltip: 'All agents and nodes across platforms',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <ServerIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'workloads',
|
||||
label: 'Workloads',
|
||||
route: ROOT_WORKLOADS_PATH,
|
||||
settingsRoute: '/settings/workloads/docker',
|
||||
tooltip: 'VMs, containers, and Kubernetes workloads',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <BoxesIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
...buildStorageRecoveryTabSpecs().map((tab) => ({
|
||||
...tab,
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: tab.id.startsWith('storage') ? (
|
||||
<HardDriveIcon class="w-4 h-4 shrink-0" />
|
||||
) : (
|
||||
<ArchiveIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
alwaysShow: true,
|
||||
})),
|
||||
];
|
||||
|
||||
return allPlatforms.filter((platform) => platform.alwaysShow || platform.enabled);
|
||||
});
|
||||
|
||||
const platformTabsMobile = createMemo(() => {
|
||||
const allPlatforms: PlatformTab[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
route: DASHBOARD_PATH,
|
||||
settingsRoute: '/settings',
|
||||
tooltip: 'Environment overview and command center',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <LayoutDashboardIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'infrastructure',
|
||||
label: 'Infrastructure',
|
||||
route: ROOT_INFRASTRUCTURE_PATH,
|
||||
settingsRoute: '/settings',
|
||||
tooltip: 'All agents and nodes across platforms',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <ServerIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'workloads',
|
||||
label: 'Workloads',
|
||||
route: ROOT_WORKLOADS_PATH,
|
||||
settingsRoute: '/settings/workloads/docker',
|
||||
tooltip: 'VMs, containers, and Kubernetes workloads',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: <BoxesIcon class="w-4 h-4 shrink-0" />,
|
||||
alwaysShow: true,
|
||||
},
|
||||
...buildStorageRecoveryTabSpecs().map((tab) => ({
|
||||
...tab,
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: tab.id.startsWith('storage') ? (
|
||||
<HardDriveIcon class="w-4 h-4 shrink-0" />
|
||||
) : (
|
||||
<ArchiveIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
alwaysShow: true,
|
||||
})),
|
||||
];
|
||||
|
||||
return allPlatforms.filter((platform) => platform.alwaysShow || platform.enabled);
|
||||
});
|
||||
|
||||
const utilityTabs = createMemo(() => {
|
||||
const allAlerts = props.state().activeAlerts || [];
|
||||
const breakdown = allAlerts.reduce(
|
||||
(accumulator, alert: Alert) => {
|
||||
if (alert?.acknowledged) return accumulator;
|
||||
const level = String(alert?.level || '').toLowerCase();
|
||||
if (level === 'critical') {
|
||||
accumulator.critical += 1;
|
||||
} else {
|
||||
accumulator.warning += 1;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{ warning: 0, critical: 0 },
|
||||
);
|
||||
const activeAlertCount = breakdown.warning + breakdown.critical;
|
||||
|
||||
const scopes = props.tokenScopes();
|
||||
const hasSettingsAccess =
|
||||
!scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read');
|
||||
|
||||
const tabs: UtilityTab[] = [
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Review active alerts and automation rules',
|
||||
badge: null,
|
||||
count: activeAlertCount,
|
||||
breakdown,
|
||||
icon: <BellIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
route: '/ai',
|
||||
tooltip: 'Pulse Patrol monitoring and analysis',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: <PulsePatrolLogo class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations',
|
||||
route: '/operations',
|
||||
tooltip: 'System operations, diagnostics, and reporting',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: <ActivityIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasSettingsAccess) {
|
||||
tabs.push({
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
tooltip: 'Configure Pulse preferences and integrations',
|
||||
badge: updateStore.isUpdateVisible() ? 'update' : null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: <SettingsIcon class="w-4 h-4 shrink-0" />,
|
||||
});
|
||||
}
|
||||
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const handlePlatformClick = (platform: PlatformTab) => {
|
||||
if (platform.enabled) {
|
||||
navigate(platform.route);
|
||||
} else {
|
||||
navigate(platform.settingsRoute);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUtilityClick = (tab: UtilityTab) => {
|
||||
navigate(tab.route);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''} ${!kioskMode() ? 'pb-safe-or-20 md:pb-0' : ''}`}
|
||||
>
|
||||
<Show when={kioskMode()}>
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-40 h-4 bg-transparent"
|
||||
aria-hidden="true"
|
||||
onMouseEnter={() => {
|
||||
if (!kioskMode()) return;
|
||||
showHeader();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!kioskMode()) return;
|
||||
scheduleHideHeader(500);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class={`header mb-3 flex items-center gap-2 ${
|
||||
kioskMode()
|
||||
? 'fixed top-0 left-0 right-0 z-50 justify-end bg-surface shadow-sm'
|
||||
: 'justify-between sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0'
|
||||
}`}
|
||||
style={
|
||||
kioskMode()
|
||||
? {
|
||||
transform: headerVisible() ? 'translateY(0)' : 'translateY(-100%)',
|
||||
opacity: headerVisible() ? 1 : 0,
|
||||
transition: `transform ${headerVisible() ? 200 : 300}ms ease, opacity ${headerVisible() ? 200 : 300}ms ease`,
|
||||
'pointer-events': headerVisible() ? 'auto' : 'none',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
ref={(element) => {
|
||||
headerEl = element;
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!kioskMode()) return;
|
||||
showHeader();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!kioskMode()) return;
|
||||
scheduleHideHeader(500);
|
||||
}}
|
||||
onFocusIn={() => {
|
||||
if (!kioskMode()) return;
|
||||
showHeader();
|
||||
}}
|
||||
onFocusOut={(event) => {
|
||||
if (!kioskMode()) return;
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (next && headerEl?.contains(next)) return;
|
||||
scheduleHideHeader(500);
|
||||
}}
|
||||
>
|
||||
<Show when={!kioskMode()}>
|
||||
<div class="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={`pulse-logo ${props.connected() && props.dataUpdated() ? 'animate-pulse-logo' : ''}`}
|
||||
>
|
||||
<title>Pulse Logo</title>
|
||||
<circle class="pulse-bg fill-blue-600 dark:fill-blue-500" cx="128" cy="128" r="122" />
|
||||
<circle
|
||||
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="84"
|
||||
/>
|
||||
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26" />
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-base-content">Pulse</span>
|
||||
<Show when={props.versionInfo()?.channel === 'rc'}>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
||||
RC
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={`header-controls flex items-center gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
|
||||
>
|
||||
<Show when={props.hasAuth() && !props.needsAuth()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isMultiTenantEnabled()}>
|
||||
<OrgSwitcher
|
||||
orgs={props.organizations()}
|
||||
selectedOrgId={props.activeOrgID()}
|
||||
loading={props.orgsLoading()}
|
||||
onChange={props.onSwitchOrg}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleKioskMode}
|
||||
class={`group relative flex h-11 w-11 items-center justify-center rounded-full text-xs transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-10 sm:w-10 ${
|
||||
kioskMode()
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800'
|
||||
: 'bg-surface-hover text-base-content hover:bg-border'
|
||||
}`}
|
||||
title={
|
||||
kioskMode()
|
||||
? 'Exit kiosk mode (show navigation)'
|
||||
: 'Enter kiosk mode (hide navigation)'
|
||||
}
|
||||
aria-label={kioskMode() ? 'Exit kiosk mode' : 'Enter kiosk mode'}
|
||||
aria-pressed={kioskMode()}
|
||||
>
|
||||
<Show
|
||||
when={kioskMode()}
|
||||
fallback={<Maximize2Icon class="h-4 w-4 flex-shrink-0" />}
|
||||
>
|
||||
<Minimize2Icon class="h-4 w-4 flex-shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.proxyAuthInfo()?.username}>
|
||||
<span class="text-xs px-2 py-1 text-muted">{props.proxyAuthInfo()?.username}</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.handleLogout}
|
||||
class="group relative flex h-11 w-11 items-center justify-center rounded-full bg-surface-hover text-xs text-base-content transition hover:bg-border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-10 sm:w-10"
|
||||
title="Logout"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<ConnectionStatusBadge
|
||||
connected={props.connected}
|
||||
reconnecting={props.reconnecting}
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!kioskMode()}>
|
||||
<div
|
||||
class="tabs mb-2 hidden md:flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-border scrollbar-hide"
|
||||
role="tablist"
|
||||
aria-label="Primary navigation"
|
||||
>
|
||||
<div class="flex items-end gap-1" role="group" aria-label="Infrastructure">
|
||||
<For each={platformTabsDesktop()}>
|
||||
{(platform) => {
|
||||
const isActive = () => getActiveTabDesktop() === platform.id;
|
||||
const disabled = () => !platform.enabled;
|
||||
const baseClasses =
|
||||
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
|
||||
|
||||
const className = () => {
|
||||
if (isActive()) {
|
||||
return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-sm font-semibold`;
|
||||
}
|
||||
if (disabled()) {
|
||||
return `${baseClasses} cursor-not-allowed text-muted opacity-70 bg-base`;
|
||||
}
|
||||
return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`;
|
||||
};
|
||||
|
||||
const title = () =>
|
||||
disabled()
|
||||
? `${platform.label} is not configured yet. Click to open settings.`
|
||||
: platform.tooltip;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={className()}
|
||||
role="tab"
|
||||
aria-disabled={disabled()}
|
||||
onClick={() => handlePlatformClick(platform)}
|
||||
title={title()}
|
||||
>
|
||||
{platform.icon}
|
||||
<span class="hidden xs:inline-flex items-center gap-1">
|
||||
<span>{platform.label}</span>
|
||||
<Show when={platform.badge}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted bg-surface-hover rounded">
|
||||
{platform.badge}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="xs:hidden">{platform.label.charAt(0)}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex items-end gap-1 ml-auto" role="group" aria-label="System">
|
||||
<div class="flex items-end gap-1 pl-1 sm:pl-4">
|
||||
<For each={utilityTabs()}>
|
||||
{(tab) => {
|
||||
const isActive = () => getActiveTabDesktop() === tab.id;
|
||||
const baseClasses =
|
||||
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
|
||||
|
||||
const className = () => {
|
||||
if (isActive()) {
|
||||
return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-sm font-semibold`;
|
||||
}
|
||||
return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={className()}
|
||||
role="tab"
|
||||
aria-disabled={false}
|
||||
onClick={() => handleUtilityClick(tab)}
|
||||
title={tab.tooltip}
|
||||
>
|
||||
{tab.icon}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="hidden xs:inline">{tab.label}</span>
|
||||
<span class="xs:hidden">{tab.label.charAt(0)}</span>
|
||||
{tab.id === 'alerts' &&
|
||||
(() => {
|
||||
const total = tab.count ?? 0;
|
||||
if (total <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{tab.breakdown && tab.breakdown.critical > 0 && (
|
||||
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-600 dark:bg-red-500 rounded-full">
|
||||
{tab.breakdown.critical}
|
||||
</span>
|
||||
)}
|
||||
{tab.breakdown && tab.breakdown.warning > 0 && (
|
||||
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
|
||||
{tab.breakdown.warning}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
<Show when={tab.badge === 'update'}>
|
||||
<span class="ml-1 flex items-center">
|
||||
<span class="sr-only">Update available</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tab.badge === 'pro'}>
|
||||
<span class="ml-1.5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 rounded">
|
||||
Pro
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<main
|
||||
id="main"
|
||||
class="tab-content block bg-surface rounded-b rounded-tr rounded-tl shadow mb-2"
|
||||
>
|
||||
<div class="pulse-panel">
|
||||
<Suspense fallback={<div class="p-6 text-sm text-muted">Loading view...</div>}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Show when={!kioskMode()}>
|
||||
<MobileNavBar
|
||||
activeTab={getActiveTabMobile}
|
||||
platformTabs={platformTabsMobile}
|
||||
utilityTabs={utilityTabs}
|
||||
onPlatformClick={handlePlatformClick}
|
||||
onUtilityClick={handleUtilityClick}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!kioskMode()}>
|
||||
<footer class="px-4 py-4 text-xs leading-relaxed text-muted">
|
||||
<div class="text-center">
|
||||
<span>Pulse | Version: </span>
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center break-all rounded px-1 py-1 text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{props.versionInfo()?.version || 'loading...'}
|
||||
</a>
|
||||
{props.versionInfo()?.isDevelopment && ' (Development)'}
|
||||
{props.versionInfo()?.isDocker && ' - Docker'}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center">
|
||||
<Show when={props.lastUpdateText()}>
|
||||
<>
|
||||
<span>Last refresh: {props.lastUpdateText()}</span>
|
||||
<Show when={isPro()}>
|
||||
<span aria-hidden="true">|</span>
|
||||
</Show>
|
||||
</>
|
||||
</Show>
|
||||
<Show when={isPro()}>
|
||||
<a
|
||||
href={`mailto:support@pulserelay.pro?subject=${encodeURIComponent(`Support Request - Pulse ${props.versionInfo()?.version || ''}`)}`}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded px-1 py-1 text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Get Support
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</footer>
|
||||
</Show>
|
||||
|
||||
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aiChatStore.toggle()}
|
||||
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex min-h-10 sm:min-h-9 min-w-10 items-center justify-center px-2.5 py-2.5 rounded-l-xl bg-blue-600 text-white shadow-sm hover:bg-blue-700 transition-colors duration-200 group sm:top-1/2 sm:translate-y-[-50%] top-auto bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] translate-y-0"
|
||||
title={
|
||||
aiChatStore.context.context?.name
|
||||
? `Pulse Assistant - ${aiChatStore.context.context.name}`
|
||||
: 'Pulse Assistant (⌘K)'
|
||||
}
|
||||
aria-label="Expand Pulse Assistant"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import appSource from '@/App.tsx?raw';
|
||||
import appLayoutSource from '@/AppLayout.tsx?raw';
|
||||
import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
|
||||
|
||||
describe('App architecture', () => {
|
||||
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
|
||||
expect(appSource).toContain("import { AppLayout } from '@/AppLayout';");
|
||||
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
|
||||
expect(appSource).toContain('const runtime = useAppRuntimeState();');
|
||||
expect(appSource).not.toContain('function ConnectionStatusBadge(');
|
||||
expect(appSource).not.toContain('function AppLayout(');
|
||||
expect(appSource).not.toContain('const [organizations, setOrganizations] = createSignal(');
|
||||
expect(appSource).not.toContain('const [themePreference, setThemePreference] =');
|
||||
expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal(');
|
||||
});
|
||||
|
||||
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
|
||||
expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)');
|
||||
expect(appLayoutSource).toContain('<OrgSwitcher');
|
||||
expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>');
|
||||
expect(appRuntimeStateSource).toContain('export const useAppRuntimeState = () =>');
|
||||
expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>');
|
||||
expect(appRuntimeStateSource).toContain('const loadOrganizations = async () =>');
|
||||
expect(appRuntimeStateSource).toContain('const handleOrgSwitch = (nextOrgID: string) =>');
|
||||
expect(appRuntimeStateSource).toContain(
|
||||
'const [activeOrgID, setActiveOrgID] = createSignal(',
|
||||
);
|
||||
expect(appRuntimeStateSource).not.toContain('function AppLayout(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
onMount,
|
||||
runWithOwner,
|
||||
} from 'solid-js';
|
||||
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import type { TimeRange } from '@/api/charts';
|
||||
import type { VersionInfo } from '@/api/updates';
|
||||
import type { Organization } from '@/api/orgs';
|
||||
import { OrgsAPI } from '@/api/orgs';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
import type { State } from '@/types/api';
|
||||
import { SettingsAPI } from '@/api/settings';
|
||||
import {
|
||||
apiFetch,
|
||||
getOrgID as getSelectedOrgID,
|
||||
setOrgID as setSelectedOrgID,
|
||||
} from '@/utils/apiClient';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
import {
|
||||
fetchInfrastructureSummaryAndCache,
|
||||
hasFreshInfrastructureSummaryCache,
|
||||
} from '@/utils/infrastructureSummaryCache';
|
||||
import { buildInfrastructurePath } from '@/routing/resourceLinks';
|
||||
import { startMetricsCollector } from '@/stores/metricsCollector';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import {
|
||||
applyThemeClass,
|
||||
computeIsDark,
|
||||
getStoredThemePreference,
|
||||
hasStoredThemePreference,
|
||||
normalizeThemePreference,
|
||||
persistThemePreference,
|
||||
type ThemePreference,
|
||||
} from '@/utils/theme';
|
||||
import {
|
||||
initKioskMode,
|
||||
getPulseWebSocketUrl,
|
||||
} from '@/utils/url';
|
||||
import { syncKioskMode } from '@/hooks/useKioskMode';
|
||||
import {
|
||||
isHostedModeEnabled,
|
||||
isMultiTenantEnabled,
|
||||
licenseLoaded,
|
||||
loadLicenseStatus,
|
||||
} from '@/stores/license';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import {
|
||||
markSystemSettingsLoadedWithDefaults,
|
||||
updateSystemSettingsFromResponse,
|
||||
} from '@/stores/systemSettings';
|
||||
|
||||
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
|
||||
|
||||
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
|
||||
|
||||
export const useAppRuntimeState = () => {
|
||||
initKioskMode();
|
||||
syncKioskMode();
|
||||
|
||||
const owner = getOwner();
|
||||
const acquireWsStore = (): EnhancedStore => {
|
||||
const store = owner
|
||||
? runWithOwner(owner, () => getGlobalWebSocketStore())
|
||||
: getGlobalWebSocketStore();
|
||||
return store || getGlobalWebSocketStore();
|
||||
};
|
||||
|
||||
const alertsActivation = useAlertsActivation();
|
||||
|
||||
onMount(() => {
|
||||
startMetricsCollector();
|
||||
});
|
||||
|
||||
let hasPreloadedRoutes = false;
|
||||
let hasFetchedVersionInfo = false;
|
||||
let hasPrewarmedInfrastructureCharts = false;
|
||||
|
||||
const getInfrastructureTrendRangeForPrewarm = (): TimeRange => '1h';
|
||||
|
||||
const shouldPrewarmInfrastructure = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const connection = (
|
||||
navigator as unknown as { connection?: { saveData?: boolean; effectiveType?: string } }
|
||||
).connection;
|
||||
if (connection?.saveData) return false;
|
||||
const effectiveType = connection?.effectiveType;
|
||||
if (
|
||||
typeof effectiveType === 'string' &&
|
||||
(effectiveType === 'slow-2g' || effectiveType === '2g')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathname = window.location.pathname;
|
||||
if (!pathname) return true;
|
||||
if (pathname === ROOT_INFRASTRUCTURE_PATH) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const prewarmInfrastructureCharts = () => {
|
||||
if (hasPrewarmedInfrastructureCharts || !shouldPrewarmInfrastructure()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = getInfrastructureTrendRangeForPrewarm();
|
||||
if (hasFreshInfrastructureSummaryCache(range)) {
|
||||
hasPrewarmedInfrastructureCharts = true;
|
||||
return;
|
||||
}
|
||||
|
||||
hasPrewarmedInfrastructureCharts = true;
|
||||
void fetchInfrastructureSummaryAndCache(range, { caller: 'App prewarm' }).catch(() => {
|
||||
// Non-blocking prewarm; ignore failures.
|
||||
});
|
||||
};
|
||||
|
||||
const preloadLazyRoutes = () => {
|
||||
if (hasPreloadedRoutes || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
hasPreloadedRoutes = true;
|
||||
const loaders: Array<() => Promise<unknown>> = [
|
||||
() => import('@/pages/Alerts'),
|
||||
() => import('@/components/Settings/Settings'),
|
||||
];
|
||||
|
||||
loaders.forEach((load) => {
|
||||
void load().catch((error) => {
|
||||
logger.warn('Preloading route module failed', error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fallbackState: State = {
|
||||
connectedInfrastructure: [],
|
||||
metrics: [],
|
||||
performance: {
|
||||
apiCallDuration: {},
|
||||
lastPollDuration: 0,
|
||||
pollingStartTime: '',
|
||||
totalApiCalls: 0,
|
||||
failedApiCalls: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
},
|
||||
connectionHealth: {},
|
||||
stats: {
|
||||
startTime: new Date().toISOString(),
|
||||
uptime: 0,
|
||||
pollingCycles: 0,
|
||||
webSocketClients: 0,
|
||||
version: '0.0.0',
|
||||
},
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
lastUpdate: 0,
|
||||
resources: [],
|
||||
};
|
||||
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [needsAuth, setNeedsAuth] = createSignal(false);
|
||||
const [hasAuth, setHasAuth] = createSignal(false);
|
||||
const [organizations, setOrganizations] = createSignal<Organization[]>([
|
||||
{ id: 'default', displayName: 'Default Organization' },
|
||||
]);
|
||||
const [activeOrgID, setActiveOrgID] = createSignal(getSelectedOrgID() || 'default');
|
||||
const [orgsLoading, setOrgsLoading] = createSignal(false);
|
||||
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
|
||||
const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{
|
||||
username?: string;
|
||||
logoutURL?: string;
|
||||
} | null>(null);
|
||||
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(null);
|
||||
const state = (): State => wsStore()?.state || fallbackState;
|
||||
const connected = () => wsStore()?.connected() || false;
|
||||
const reconnecting = () => wsStore()?.reconnecting() || false;
|
||||
const [dataUpdated, setDataUpdated] = createSignal(false);
|
||||
const [lastUpdateText, setLastUpdateText] = createSignal('');
|
||||
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
|
||||
|
||||
const initialThemePreference = getStoredThemePreference();
|
||||
const [themePreference, setThemePreference] =
|
||||
createSignal<ThemePreference>(initialThemePreference);
|
||||
const [, setHasLoadedServerTheme] = createSignal(false);
|
||||
const [darkMode, setDarkMode] = createSignal(computeIsDark(initialThemePreference));
|
||||
applyThemeClass(darkMode());
|
||||
|
||||
const applyThemePreferenceLocally = (preference: ThemePreference) => {
|
||||
setThemePreference(preference);
|
||||
persistThemePreference(preference);
|
||||
const isDark = computeIsDark(preference);
|
||||
setDarkMode(isDark);
|
||||
applyThemeClass(isDark);
|
||||
};
|
||||
|
||||
const applyServerThemeIfAllowed = (theme?: string) => {
|
||||
if (!hasStoredThemePreference() && theme && theme !== '') {
|
||||
applyThemePreferenceLocally(normalizeThemePreference(theme));
|
||||
}
|
||||
};
|
||||
|
||||
const loadSystemSettingsAndLayout = async () => {
|
||||
try {
|
||||
const systemSettings = await SettingsAPI.getSystemSettings();
|
||||
updateSystemSettingsFromResponse(systemSettings);
|
||||
applyServerThemeIfAllowed(systemSettings.theme);
|
||||
setHasLoadedServerTheme(true);
|
||||
layoutStore.loadFromServer();
|
||||
} catch (error) {
|
||||
logger.error('Failed to load system settings from server', error);
|
||||
markSystemSettingsLoadedWithDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const beginAuthenticatedRuntime = async () => {
|
||||
setNeedsAuth(false);
|
||||
await loadOrganizations();
|
||||
setWsStore(acquireWsStore());
|
||||
await loadSystemSettingsAndLayout();
|
||||
};
|
||||
|
||||
const loadOrganizations = async () => {
|
||||
setOrgsLoading(true);
|
||||
try {
|
||||
if (!licenseLoaded()) {
|
||||
await loadLicenseStatus();
|
||||
}
|
||||
|
||||
if (!isMultiTenantEnabled()) {
|
||||
const storedOrgID = getSelectedOrgID();
|
||||
const hostedOrgID =
|
||||
isHostedModeEnabled() && storedOrgID && storedOrgID !== 'default'
|
||||
? storedOrgID
|
||||
: 'default';
|
||||
setOrganizations([
|
||||
{
|
||||
id: hostedOrgID,
|
||||
displayName: hostedOrgID === 'default' ? 'Default Organization' : hostedOrgID,
|
||||
},
|
||||
]);
|
||||
setSelectedOrgID(hostedOrgID);
|
||||
setActiveOrgID(hostedOrgID);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetched = await OrgsAPI.list();
|
||||
const organizationList =
|
||||
fetched.length > 0 ? fetched : [{ id: 'default', displayName: 'Default Organization' }];
|
||||
setOrganizations(organizationList);
|
||||
|
||||
const storedOrgID = getSelectedOrgID();
|
||||
const selectedOrg =
|
||||
(storedOrgID && organizationList.some((org) => org.id === storedOrgID)
|
||||
? storedOrgID
|
||||
: null) ||
|
||||
organizationList[0]?.id ||
|
||||
'default';
|
||||
|
||||
setSelectedOrgID(selectedOrg);
|
||||
setActiveOrgID(selectedOrg);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load organizations, falling back to default org', error);
|
||||
showToast('error', 'Failed to load organizations. Using default.');
|
||||
const fallback = [{ id: 'default', displayName: 'Default Organization' }];
|
||||
setOrganizations(fallback);
|
||||
setSelectedOrgID('default');
|
||||
setActiveOrgID('default');
|
||||
} finally {
|
||||
setOrgsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrgSwitch = (nextOrgID: string) => {
|
||||
const target = nextOrgID?.trim() || 'default';
|
||||
if (target === activeOrgID()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target !== 'default' && !organizations().some((org) => org.id === target)) {
|
||||
showToast('error', 'Organization no longer exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOrgID(target);
|
||||
setActiveOrgID(target);
|
||||
eventBus.emit('org_switched', target);
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.GUEST_METADATA);
|
||||
localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA);
|
||||
localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts');
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
|
||||
try {
|
||||
const store = wsStore();
|
||||
if (store && typeof store.switchUrl === 'function') {
|
||||
store.switchUrl(getPulseWebSocketUrl());
|
||||
} else {
|
||||
store?.reconnect();
|
||||
}
|
||||
showToast('success', 'Organization switched');
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch organization', error);
|
||||
showToast('error', 'Failed to switch organization');
|
||||
}
|
||||
};
|
||||
|
||||
const formatLastUpdate = (timestamp: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
let updateTimeout: number | undefined;
|
||||
createEffect(() => {
|
||||
const updateTime = state().lastUpdate;
|
||||
if (updateTime > 0) {
|
||||
setDataUpdated(true);
|
||||
setLastUpdateText(formatLastUpdate(updateTime));
|
||||
if (updateTimeout !== undefined) {
|
||||
window.clearTimeout(updateTimeout);
|
||||
}
|
||||
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (updateTimeout !== undefined) {
|
||||
window.clearTimeout(updateTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!isLoading() && !needsAuth()) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!hasPreloadedRoutes) {
|
||||
window.setTimeout(preloadLazyRoutes, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (isLoading() || needsAuth() || hasPrewarmedInfrastructureCharts) {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const id = window.requestIdleCallback(() => {
|
||||
prewarmInfrastructureCharts();
|
||||
}, { timeout: 2_000 });
|
||||
onCleanup(() => {
|
||||
window.cancelIdleCallback(id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
prewarmInfrastructureCharts();
|
||||
}, 500);
|
||||
onCleanup(() => {
|
||||
window.clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
|
||||
const syncVersionInfoFromUpdateStore = async () => {
|
||||
const cachedVersion = updateStore.versionInfo();
|
||||
if (cachedVersion) {
|
||||
setVersionInfo(cachedVersion);
|
||||
}
|
||||
|
||||
await updateStore.checkForUpdates();
|
||||
|
||||
const resolvedVersion = updateStore.versionInfo();
|
||||
if (resolvedVersion) {
|
||||
setVersionInfo(resolvedVersion);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (isLoading() || needsAuth() || hasFetchedVersionInfo) {
|
||||
return;
|
||||
}
|
||||
hasFetchedVersionInfo = true;
|
||||
void syncVersionInfoFromUpdateStore();
|
||||
});
|
||||
|
||||
let alertsInitialized = false;
|
||||
createEffect(() => {
|
||||
const ready = !isLoading() && !needsAuth();
|
||||
if (ready && !alertsInitialized) {
|
||||
alertsInitialized = true;
|
||||
void alertsActivation.refreshConfig();
|
||||
void alertsActivation.refreshActiveAlerts();
|
||||
}
|
||||
if (!ready) {
|
||||
alertsInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleThemeChange = async (newPreference: ThemePreference) => {
|
||||
applyThemePreferenceLocally(newPreference);
|
||||
logger.info('Theme changed', { pref: newPreference, active: computeIsDark(newPreference) ? 'dark' : 'light' });
|
||||
|
||||
if (!needsAuth()) {
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({ theme: newPreference });
|
||||
logger.info('Theme preference saved to server');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save theme preference to server', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const systemThemeListener = (event: MediaQueryListEvent) => {
|
||||
if (themePreference() === 'system') {
|
||||
const isDark = event.matches;
|
||||
setDarkMode(isDark);
|
||||
applyThemeClass(isDark);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener('change', systemThemeListener);
|
||||
|
||||
const handleRemoteThemeChange = (theme?: string) => {
|
||||
if (!theme) return;
|
||||
logger.info('Received theme change from another browser instance', { theme });
|
||||
applyThemePreferenceLocally(normalizeThemePreference(theme));
|
||||
};
|
||||
|
||||
const handleWebSocketReconnected = () => {
|
||||
logger.info('WebSocket reconnected, refreshing alert configuration');
|
||||
void alertsActivation.refreshConfig();
|
||||
void alertsActivation.refreshActiveAlerts();
|
||||
};
|
||||
|
||||
eventBus.on('theme_changed', handleRemoteThemeChange);
|
||||
eventBus.on('websocket_reconnected', handleWebSocketReconnected);
|
||||
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeEventListener('change', systemThemeListener);
|
||||
eventBus.off('theme_changed', handleRemoteThemeChange);
|
||||
eventBus.off('websocket_reconnected', handleWebSocketReconnected);
|
||||
});
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
logger.debug('[App] Starting auth check...');
|
||||
const justLoggedOut = localStorage.getItem('just_logged_out');
|
||||
|
||||
try {
|
||||
const securityResponse = await apiFetch('/api/security/status');
|
||||
|
||||
if (securityResponse.status === 401) {
|
||||
logger.warn(
|
||||
'[App] Security status request returned 401. Clearing stored credentials and showing login.',
|
||||
);
|
||||
try {
|
||||
const { clearAuth } = await import('@/utils/apiClient');
|
||||
clearAuth();
|
||||
} catch (clearError) {
|
||||
logger.warn('[App] Failed to clear stored auth after 401', clearError);
|
||||
}
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (justLoggedOut) {
|
||||
localStorage.removeItem('just_logged_out');
|
||||
logger.debug('[App] User logged out, showing login page');
|
||||
if (securityResponse.ok) {
|
||||
setSecurityStatus((await securityResponse.json()) as SecurityStatus);
|
||||
}
|
||||
setHasAuth(true);
|
||||
setNeedsAuth(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!securityResponse.ok) {
|
||||
throw new Error(`Security status request failed with status ${securityResponse.status}`);
|
||||
}
|
||||
|
||||
const securityData = (await securityResponse.json()) as SecurityStatus & {
|
||||
hasAuthentication?: boolean;
|
||||
hasProxyAuth?: boolean;
|
||||
proxyAuthUsername?: string;
|
||||
proxyAuthLogoutURL?: string;
|
||||
ssoEnabled?: boolean;
|
||||
ssoSessionUsername?: string;
|
||||
ssoLogoutURL?: string;
|
||||
};
|
||||
logger.debug('[App] Security status fetched', securityData);
|
||||
setSecurityStatus(securityData);
|
||||
|
||||
const authConfigured = securityData.hasAuthentication || false;
|
||||
setHasAuth(authConfigured);
|
||||
|
||||
if (securityData.hasProxyAuth && securityData.proxyAuthUsername) {
|
||||
logger.info('[App] Proxy auth detected', { user: securityData.proxyAuthUsername });
|
||||
setProxyAuthInfo({
|
||||
username: securityData.proxyAuthUsername,
|
||||
logoutURL: securityData.proxyAuthLogoutURL,
|
||||
});
|
||||
await beginAuthenticatedRuntime();
|
||||
void syncVersionInfoFromUpdateStore();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (securityData.ssoEnabled && securityData.ssoSessionUsername) {
|
||||
logger.info('[App] SSO session detected', { user: securityData.ssoSessionUsername });
|
||||
setHasAuth(true);
|
||||
setProxyAuthInfo({
|
||||
username: securityData.ssoSessionUsername,
|
||||
logoutURL: securityData.ssoLogoutURL,
|
||||
});
|
||||
await beginAuthenticatedRuntime();
|
||||
void syncVersionInfoFromUpdateStore();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authConfigured) {
|
||||
logger.info('[App] No auth configured, showing Login/FirstRunSetup');
|
||||
setNeedsAuth(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stateResponse = await apiFetch('/api/state', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (stateResponse.status === 401) {
|
||||
setNeedsAuth(true);
|
||||
} else {
|
||||
await beginAuthenticatedRuntime();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Auth check error', error);
|
||||
try {
|
||||
const { clearAuth } = await import('@/utils/apiClient');
|
||||
clearAuth();
|
||||
} catch (clearError) {
|
||||
logger.warn('[App] Failed to clear stored auth after auth check error', clearError);
|
||||
}
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogin = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const proxyAuth = proxyAuthInfo();
|
||||
if (proxyAuth?.logoutURL) {
|
||||
window.location.href = proxyAuth.logoutURL;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiFetch: runtimeApiFetch, clearAuth } = await import('@/utils/apiClient');
|
||||
const response = await runtimeApiFetch('/api/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Logout failed', { status: response.status });
|
||||
}
|
||||
clearAuth();
|
||||
} catch (error) {
|
||||
logger.error('Logout error', error);
|
||||
}
|
||||
|
||||
const keysToRemove = [
|
||||
STORAGE_KEYS.AUTH,
|
||||
STORAGE_KEYS.GUEST_METADATA,
|
||||
STORAGE_KEYS.DOCKER_METADATA,
|
||||
STORAGE_KEYS.DOCKER_METADATA + '_hosts',
|
||||
];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('just_logged_out', 'true');
|
||||
|
||||
if (wsStore()) {
|
||||
setWsStore(null);
|
||||
}
|
||||
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const enhancedStore = () => wsStore();
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
needsAuth,
|
||||
hasAuth,
|
||||
organizations,
|
||||
activeOrgID,
|
||||
orgsLoading,
|
||||
securityStatus,
|
||||
proxyAuthInfo,
|
||||
state,
|
||||
connected,
|
||||
reconnecting,
|
||||
dataUpdated,
|
||||
lastUpdateText,
|
||||
versionInfo,
|
||||
themePreference,
|
||||
darkMode,
|
||||
handleThemeChange,
|
||||
handleOrgSwitch,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
enhancedStore,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user