Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The remaining codes are specific to a route and are listed with it. Most are upp

Three answers are not JSON:

- A route the server did not register (a feature that is not wired, such as the outbound heartbeats in demo mode) and a wrong method on a known path answer the plain-text `404` and `405` of the Go router.
- A route the server did not register (a feature that is not wired) and a wrong method on a known path answer the plain-text `404` and `405` of the Go router.
- A request still running after 10 seconds is cut with `503` and the plain-text body `request timeout`. Streams are exempt. The work the request started can still finish, for example a synchronous certificate scan.
- The OAuth routes answer with the RFC 6749 shape (`{"error": "...", "error_description": "..."}`).

Expand Down Expand Up @@ -90,7 +90,7 @@ The API refuses a browser write that comes from another origin, unless that orig

### Demo mode

A demo build of maintenant is read-only. Every request other than `GET`, `HEAD` and `OPTIONS` answers `403 DEMO_MODE` (only `POST /api/v1/escalation-policies/overlap-probe`, which changes nothing, is let through). `/ping/`, `/mcp`, `/oauth/` and `/.well-known/oauth-*` answer `403 DEMO_MODE` for every method, and the outbound heartbeat routes are not registered. A request carrying the `X-Maintenant-Demo-Token` header with the value of `MAINTENANT_DEMO_TOKEN` bypasses the guard. `GET /api/v1/edition` reports `"demo": true`.
A demo build of maintenant is read-only. Every request other than `GET`, `HEAD` and `OPTIONS` answers `403 DEMO_MODE` (only `POST /api/v1/escalation-policies/overlap-probe`, which changes nothing, is let through). `/ping/`, `/mcp`, `/oauth/` and `/.well-known/oauth-*` answer `403 DEMO_MODE` for every method. Outbound heartbeats are never sent. A request carrying the `X-Maintenant-Demo-Token` header with the value of `MAINTENANT_DEMO_TOKEN` bypasses the guard. `GET /api/v1/edition` reports `"demo": true`.

---

Expand Down Expand Up @@ -271,7 +271,7 @@ These routes need no credentials: the UUID of the heartbeat is the secret. They

### Outbound heartbeats

Outbound heartbeats make this instance ping an external URL so another system notices when it stops. The routes are not registered in demo mode. All are open in every edition. See [Outbound Heartbeats](../features/heartbeats.md#outbound-heartbeats).
Outbound heartbeats make this instance ping an external URL so another system notices when it stops. In demo mode they are read-only like every other route and no ping is sent. All are open in every edition. See [Outbound Heartbeats](../features/heartbeats.md#outbound-heartbeats).

| Method | Endpoint | Description |
|--------|----------|-------------|
Expand Down
2 changes: 1 addition & 1 deletion docs/features/heartbeats.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ The first send happens within seconds of creating the target. Two instances can
- A 2xx response counts as a success. Any other status, or a network error, is recorded as the target's last error and logged as a warning.
- A disabled target keeps its settings but is no longer called.
- An extra root CA set with `MAINTENANT_CA_CERT` applies, so the other instance may use an internal PKI.
- The page is not available in demo mode.
- In demo mode the page is read-only and no ping is sent.

!!! note "HTTPS and public addresses only"
Targets must use `https://` and resolve to a public address. Loopback, private,
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/layouts/DefaultLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ const router = useRouter()
const { version } = useAppVersion()
const {
isCommunity,
isDemo,
editionName,
hasFeature,
licenseMessage,
Expand Down Expand Up @@ -164,15 +163,14 @@ const allNav: NavItem[] = [
{ type: 'separator' },
{ type: 'item', to: '/status-admin', label: 'Status Pages', icon: Activity },
{ type: 'item', to: '/agents', label: 'Agents', icon: MonitorDot },
{ type: 'item', to: '/outbound-heartbeats', label: 'Outbound heartbeats', icon: RadioTower, hideInDemo: true },
{ type: 'item', to: '/outbound-heartbeats', label: 'Outbound heartbeats', icon: RadioTower },
]

const mainNav = computed(() =>
allNav.filter((item) =>
isNavItemVisible(item, {
hasFeature,
availableRuntimes: availableRuntimes.value,
isDemo: isDemo.value,
}),
),
)
Expand Down
36 changes: 11 additions & 25 deletions frontend/src/pages/OutboundHeartbeatsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,10 @@

<script setup lang="ts">
import { ref } from 'vue'
import { RadioTower } from 'lucide-vue-next'
import { useEdition } from '@/composables/useEdition'
import { docUrl } from '@/utils/docs'
import OutboundHeartbeatsPanel from '@/components/heartbeats/OutboundHeartbeatsPanel.vue'
import UiButton from '@/components/ui/UiButton.vue'
import FeatureHint from '@/components/ui/FeatureHint.vue'
import EmptyState from '@/components/ui/EmptyState.vue'

const { isDemo } = useEdition()

const showCreateForm = ref(false)
</script>
Expand All @@ -28,31 +23,22 @@ const showCreateForm = ref(false)
Ping other Maintenant instances so they alert if this one goes down
</p>
</div>
<UiButton v-if="!isDemo" variant="primary" @click="showCreateForm = !showCreateForm">
<UiButton variant="primary" @click="showCreateForm = !showCreateForm">
{{ showCreateForm ? 'Cancel' : 'New target' }}
</UiButton>
</div>

<template v-if="!isDemo">
<FeatureHint
storage-key="outbound-heartbeats"
title="Let another instance watch this one"
:doc-href="docUrl('features/heartbeats/#outbound-heartbeats')"
>
Create a heartbeat monitor on another Maintenant instance, then paste its ping URL
(<code class="rounded-md px-1.5 py-0.5 text-xs font-mono" style="background: var(--mnt-bg-elevated); color: var(--mnt-text-secondary)">/ping/{uuid}</code>)
as a target here. This instance pings it on the interval you choose &mdash; if this instance goes down, the other one raises the alert.
</FeatureHint>

<OutboundHeartbeatsPanel v-model:show-create-form="showCreateForm" />
</template>
<FeatureHint
storage-key="outbound-heartbeats"
title="Let another instance watch this one"
:doc-href="docUrl('features/heartbeats/#outbound-heartbeats')"
>
Create a heartbeat monitor on another Maintenant instance, then paste its ping URL
(<code class="rounded-md px-1.5 py-0.5 text-xs font-mono" style="background: var(--mnt-bg-elevated); color: var(--mnt-text-secondary)">/ping/{uuid}</code>)
as a target here. This instance pings it on the interval you choose &mdash; if this instance goes down, the other one raises the alert.
</FeatureHint>

<EmptyState
v-else
:icon="RadioTower"
title="Unavailable in demo mode"
description="Outbound heartbeats ping real URLs on other instances, which this read-only demo does not allow."
/>
<OutboundHeartbeatsPanel v-model:show-create-form="showCreateForm" />
</div>
</div>
</template>
26 changes: 3 additions & 23 deletions frontend/src/pages/__tests__/OutboundHeartbeatsPage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,10 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import OutboundHeartbeatsPage from '@/pages/OutboundHeartbeatsPage.vue'

const isDemo = ref(false)

vi.mock('@/composables/useEdition', () => ({
useEdition: () => ({ isDemo }),
}))

function mountPage() {
return shallowMount(OutboundHeartbeatsPage)
}

describe('OutboundHeartbeatsPage', () => {
it('mounts the outbound heartbeats panel outside demo mode', () => {
isDemo.value = false
const wrapper = mountPage()
it('mounts the outbound heartbeats panel', () => {
const wrapper = shallowMount(OutboundHeartbeatsPage)
expect(wrapper.findComponent({ name: 'OutboundHeartbeatsPanel' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'EmptyState' }).exists()).toBe(false)
})

it('does not mount the panel in demo mode and shows the unavailable state instead', () => {
isDemo.value = true
const wrapper = mountPage()
expect(wrapper.findComponent({ name: 'OutboundHeartbeatsPanel' }).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'EmptyState' }).exists()).toBe(true)
})
})
11 changes: 0 additions & 11 deletions frontend/src/utils/__tests__/navVisibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ function ctx(overrides: Partial<Parameters<typeof isNavItemVisible>[1]> = {}) {
return {
hasFeature: () => true,
availableRuntimes: ['docker'],
isDemo: false,
...overrides,
}
}
Expand All @@ -33,14 +32,4 @@ describe('isNavItemVisible', () => {
const item: NavItem = { type: 'item', to: '/pods', label: 'Pods', runtime: ['kubernetes'] }
expect(isNavItemVisible(item, ctx({ availableRuntimes: ['docker'] }))).toBe(false)
})

it('hides a hideInDemo item in demo mode', () => {
const item: NavItem = { type: 'item', to: '/outbound-heartbeats', label: 'Outbound heartbeats', hideInDemo: true }
expect(isNavItemVisible(item, ctx({ isDemo: true }))).toBe(false)
})

it('shows a hideInDemo item outside demo mode', () => {
const item: NavItem = { type: 'item', to: '/outbound-heartbeats', label: 'Outbound heartbeats', hideInDemo: true }
expect(isNavItemVisible(item, ctx({ isDemo: false }))).toBe(true)
})
})
3 changes: 0 additions & 3 deletions frontend/src/utils/navVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,17 @@ export interface NavItem {
icon?: Component
feature?: string
runtime?: string[]
hideInDemo?: boolean
}

export interface NavVisibilityContext {
hasFeature: (feature: string) => boolean
availableRuntimes: string[]
isDemo: boolean
}

export function isNavItemVisible(item: NavItem, ctx: NavVisibilityContext): boolean {
if (item.feature && !ctx.hasFeature(item.feature)) return false
if (item.runtime && !item.runtime.some((rt) => ctx.availableRuntimes.includes(rt))) {
return false
}
if (item.hideInDemo && ctx.isDemo) return false
return true
}
8 changes: 2 additions & 6 deletions internal/api/v1/edition_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func TestNewRouter_WiresDemoModeIntoTheEditionEndpoint(t *testing.T) {
assert.Equal(t, true, body["demo"])
}

func TestNewRouter_DemoModeDropsOutboundHeartbeats(t *testing.T) {
func TestNewRouter_DemoModeKeepsOutboundHeartbeats(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := outbound.NewService(outbound.Deps{Store: &memOutboundStore{items: map[string]outbound.OutboundHeartbeat{}}})

Expand All @@ -210,11 +210,7 @@ func TestNewRouter_DemoModeDropsOutboundHeartbeats(t *testing.T) {
r := NewRouter(HandlerDeps{Logger: logger, DemoMode: demo, Outbound: svc})
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/outbound-heartbeats", nil))
if demo {
assert.Equal(t, http.StatusNotFound, rec.Code)
} else {
assert.Equal(t, http.StatusOK, rec.Code)
}
assert.Equal(t, http.StatusOK, rec.Code)
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/v1/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ func NewRouter(d HandlerDeps) *Router {
r.mux.HandleFunc("POST /ping/{uuid}", ph.HandlePing)
}

if d.Outbound != nil && !d.DemoMode {
if d.Outbound != nil {
oh := NewOutboundHeartbeatHandler(d.Outbound)
r.mux.HandleFunc("GET /api/v1/outbound-heartbeats", oh.HandleList)
r.mux.HandleFunc("POST /api/v1/outbound-heartbeats", oh.HandleCreate)
Expand Down