Skip to content

Commit 165db31

Browse files
feat(status): surface major service incidents
1 parent fb8f0d6 commit 165db31

14 files changed

Lines changed: 306 additions & 2 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ BETTER_AUTH_URL=http://localhost:3000
1717

1818
# NextJS (Required)
1919
NEXT_PUBLIC_APP_URL=http://localhost:3000
20+
# NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing
2021
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
2122
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
2223
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients.

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export { SidebarFooter } from './sidebar-footer'
1414
export type { SidebarNavItemData } from './sidebar-nav-chip'
1515
export { SidebarNavChip } from './sidebar-nav-chip'
1616
export { SidebarSection } from './sidebar-section'
17+
export { StatusNotice } from './status-notice'
1718
export { WorkflowList } from './workflow-list'
1819
export { WorkspaceHeader } from './workspace-header'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { StatusNotice } from './status-notice'
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockUseStatusPage } = vi.hoisted(() => ({
10+
mockUseStatusPage: vi.fn(),
11+
}))
12+
13+
vi.mock('@/hooks/queries/status-page', () => ({
14+
useStatusPage: mockUseStatusPage,
15+
}))
16+
17+
import { StatusNotice } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice'
18+
19+
let container: HTMLDivElement
20+
let root: Root
21+
22+
beforeEach(() => {
23+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
24+
vi.clearAllMocks()
25+
mockUseStatusPage.mockReturnValue({ data: undefined, error: null })
26+
container = document.createElement('div')
27+
document.body.appendChild(container)
28+
root = createRoot(container)
29+
})
30+
31+
afterEach(() => {
32+
act(() => root.unmount())
33+
container.remove()
34+
vi.restoreAllMocks()
35+
})
36+
37+
function render() {
38+
act(() => root.render(<StatusNotice />))
39+
}
40+
41+
describe('StatusNotice', () => {
42+
it('shows the local status alert without fetching live status in preview mode', () => {
43+
act(() => root.render(<StatusNotice preview />))
44+
45+
const notice = container.querySelector('[role="alert"]')
46+
expect(notice?.textContent).toContain('Sim is having issues')
47+
expect(notice?.className).toContain('bg-[var(--terminal-status-error-bg)]')
48+
expect(notice?.className).toContain('border-[var(--terminal-status-error-border)]')
49+
expect(container.querySelector('svg')?.classList.contains('text-[var(--text-icon)]')).toBe(true)
50+
expect(mockUseStatusPage).toHaveBeenCalledWith({ enabled: false })
51+
})
52+
53+
it('stays hidden while loading and for operational or minor incidents', () => {
54+
render()
55+
expect(container.textContent).toBe('')
56+
57+
mockUseStatusPage.mockReturnValue({
58+
data: { status: { description: 'All Systems Operational', indicator: 'none' } },
59+
error: null,
60+
})
61+
render()
62+
63+
expect(container.textContent).toBe('')
64+
65+
mockUseStatusPage.mockReturnValue({
66+
data: { status: { description: 'Minor Service Outage', indicator: 'minor' } },
67+
error: null,
68+
})
69+
render()
70+
71+
expect(container.textContent).toBe('')
72+
})
73+
74+
it('shows the notice for a major incident and opens the status page', () => {
75+
mockUseStatusPage.mockReturnValue({
76+
data: { status: { description: 'Major Service Outage', indicator: 'major' } },
77+
error: null,
78+
})
79+
80+
render()
81+
82+
const notice = container.querySelector('[role="alert"]')
83+
const action = container.querySelector<HTMLAnchorElement>('a')
84+
expect(notice?.className).toContain('border-[var(--terminal-status-error-border)]')
85+
expect(notice?.className).toContain('shadow-[var(--shadow-overlay)]')
86+
expect(notice?.className).toContain(
87+
'[--surface-hover:color-mix(in_srgb,var(--text-error)_8%,transparent)]'
88+
)
89+
expect(action?.textContent).toContain('View status')
90+
expect(action?.className).not.toContain('bg-[var(--text-error)]')
91+
expect(action?.getAttribute('href')).toBe('https://status.sim.ai')
92+
expect(action?.getAttribute('target')).toBe('_blank')
93+
expect(action?.getAttribute('rel')).toBe('noopener noreferrer')
94+
})
95+
96+
it('throws status query failures instead of hiding them', () => {
97+
mockUseStatusPage.mockReturnValue({
98+
data: undefined,
99+
error: new Error('status unavailable'),
100+
})
101+
102+
expect(render).toThrow('status unavailable')
103+
})
104+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client'
2+
3+
import { ChipLink } from '@sim/emcn'
4+
import { CircleAlert } from '@sim/emcn/icons'
5+
import { STATUS_PAGE_URL } from '@/lib/status-page'
6+
import { useStatusPage } from '@/hooks/queries/status-page'
7+
8+
const PREVIEW_STATUS = {
9+
description: 'Major Service Outage',
10+
indicator: 'critical',
11+
} as const
12+
13+
interface StatusNoticeProps {
14+
preview?: boolean
15+
}
16+
17+
function StatusAlert() {
18+
return (
19+
<div
20+
role='alert'
21+
className='flex w-full flex-col gap-2 rounded-xl border border-[var(--terminal-status-error-border)] bg-[var(--terminal-status-error-bg)] p-2 shadow-[var(--shadow-overlay)] [--surface-hover:color-mix(in_srgb,var(--text-error)_8%,transparent)]'
22+
>
23+
<div className='flex min-w-0 items-center gap-1.5'>
24+
<CircleAlert className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
25+
<p className='min-w-0 text-[var(--text-body)] text-sm leading-5'>Sim is having issues</p>
26+
</div>
27+
<ChipLink
28+
fullWidth
29+
variant='border'
30+
className='justify-center'
31+
href={STATUS_PAGE_URL}
32+
target='_blank'
33+
rel='noopener noreferrer'
34+
>
35+
View status
36+
</ChipLink>
37+
</div>
38+
)
39+
}
40+
41+
export function StatusNotice({ preview = false }: StatusNoticeProps) {
42+
const { data, error } = useStatusPage({ enabled: !preview })
43+
44+
if (!preview && error) {
45+
throw error
46+
}
47+
48+
const status = preview ? PREVIEW_STATUS : data?.status
49+
50+
if (status?.indicator !== 'major' && status?.indicator !== 'critical') {
51+
return null
52+
}
53+
54+
return <StatusAlert />
55+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { usePostHog } from 'posthog-js/react'
3838
import { useSession } from '@/lib/auth/auth-client'
3939
import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts'
4040
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
41-
import { isChatEnabled } from '@/lib/core/config/env-flags'
41+
import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags'
4242
import { isMacPlatform } from '@/lib/core/utils/platform'
4343
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
4444
import { captureEvent } from '@/lib/posthog/client'
@@ -62,6 +62,7 @@ import {
6262
SidebarNavChip,
6363
type SidebarNavItemData,
6464
SidebarSection,
65+
StatusNotice,
6566
TablesRailFlyout,
6667
WorkflowList,
6768
WorkspaceHeader,
@@ -1479,7 +1480,7 @@ export const Sidebar = memo(function Sidebar({
14791480
ref={isCollapsed ? undefined : scrollContainerRef}
14801481
className={cn(
14811482
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
1482-
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t transition-colors duration-150',
1483+
'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden border-t transition-colors duration-150',
14831484
!hasOverflowTop && 'border-transparent'
14841485
)}
14851486
>
@@ -1807,6 +1808,12 @@ export const Sidebar = memo(function Sidebar({
18071808
</div>
18081809
</div>
18091810

1811+
{(isHosted || isStatusNoticePreviewEnabled) && !isCollapsed ? (
1812+
<div className='flex-shrink-0 px-2 py-2'>
1813+
<StatusNotice preview={isStatusNoticePreviewEnabled} />
1814+
</div>
1815+
) : null}
1816+
18101817
<SidebarFooter
18111818
workspaceId={workspaceId}
18121819
isCollapsed={isCollapsed}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { fetchStatusPageSummary } from '@/lib/status-page'
3+
4+
export const STATUS_PAGE_POLL_INTERVAL = 60 * 1000
5+
export const STATUS_PAGE_STALE_TIME = 30 * 1000
6+
7+
export const statusPageKeys = {
8+
all: ['status-page'] as const,
9+
summaries: () => [...statusPageKeys.all, 'summary'] as const,
10+
summary: () => [...statusPageKeys.summaries(), 'sim'] as const,
11+
}
12+
13+
interface UseStatusPageOptions {
14+
enabled?: boolean
15+
}
16+
17+
/** Polls Sim's public status while a hosted workspace is open. */
18+
export function useStatusPage({ enabled = true }: UseStatusPageOptions = {}) {
19+
return useQuery({
20+
queryKey: statusPageKeys.summary(),
21+
queryFn: ({ signal }) => fetchStatusPageSummary(signal),
22+
enabled,
23+
staleTime: STATUS_PAGE_STALE_TIME,
24+
refetchInterval: STATUS_PAGE_POLL_INTERVAL,
25+
refetchOnWindowFocus: true,
26+
retry: false,
27+
})
28+
}

apps/sim/lib/core/config/env-flags.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PRO
8585
*/
8686
export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED'))
8787

88+
/**
89+
* Forces the sidebar service-status notice into its critical preview state.
90+
* This is an explicit testing override; when unset, hosted deployments read
91+
* the live status page and other deployments do not mount the notice.
92+
*/
93+
export const isStatusNoticePreviewEnabled = isTruthy(getEnv('NEXT_PUBLIC_STATUS_NOTICE_PREVIEW'))
94+
8895
/**
8996
* Holds tools the catalog marks `requiresApproval` — shell commands, workflow
9097
* runs, sandboxed code, deployments, integration calls — behind an explicit

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,7 @@ export const env = createEnv({
710710
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
711711
NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted
712712
NEXT_PUBLIC_CHAT_DISABLED: z.boolean().optional(), // Hide the Chat module (Chat is shown when unset)
713+
NEXT_PUBLIC_STATUS_NOTICE_PREVIEW: z.boolean().optional(), // Force the sidebar service-status notice into its critical preview state
713714
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
714715
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
715716
},
@@ -754,6 +755,7 @@ export const env = createEnv({
754755
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
755756
NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED,
756757
NEXT_PUBLIC_CHAT_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED,
758+
NEXT_PUBLIC_STATUS_NOTICE_PREVIEW: process.env.NEXT_PUBLIC_STATUS_NOTICE_PREVIEW,
757759
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
758760
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
759761
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,

apps/sim/lib/core/security/csp.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ describe('buildTimeCSPDirectives', () => {
272272
expect(buildTimeCSPDirectives['font-src']).toContain('https://fonts.gstatic.com')
273273
})
274274

275+
it('allows the hosted app to read the Sim status page', () => {
276+
expect(getMainCSPPolicy()).toMatch(/connect-src[^;]*https:\/\/status\.sim\.ai/)
277+
})
278+
275279
it('should allow data: and blob: for images', () => {
276280
expect(buildTimeCSPDirectives['img-src']).toContain('data:')
277281
expect(buildTimeCSPDirectives['img-src']).toContain('blob:')

0 commit comments

Comments
 (0)