feat: add Update Available banner with GitHub release check (#94)

* fix: migrate middleware.ts to proxy.ts for Next.js 16 (#88)

Next.js 16 deprecated the `middleware` file convention in favor of
`proxy`. The proxy runs on the Node.js runtime instead of Edge, so
safeCompare now uses crypto.timingSafeEqual instead of manual XOR.

All auth logic, CSRF validation, host matching, and security headers
are preserved unchanged.

* feat: add "Update Available" banner with GitHub release check

Add a dismissible emerald banner that appears when a newer GitHub release
exists, so self-hosting users know an update is available. The banner
dismisses per-version (reappears for new releases).

- Create src/lib/version.ts as single source of truth from package.json
- Add /api/releases/check route with 1hr caching and graceful fallback
- Add UpdateBanner component mirroring LocalModeBanner pattern
- Add update state to Zustand store with localStorage persistence
- Fix hardcoded v2.0 in header-bar.tsx and 2.0.0 in websocket.ts
This commit is contained in:
nyk 2026-03-03 17:17:15 +07:00 committed by GitHub
parent 71f2627138
commit 274b726df4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 147 additions and 15 deletions

View File

@ -107,7 +107,7 @@ Outbound webhooks with delivery history, configurable alert rules with cooldowns
```
mission-control/
├── src/
│ ├── middleware.ts # Auth gate + CSRF + network access control
│ ├── proxy.ts # Auth gate + CSRF + network access control
│ ├── app/
│ │ ├── page.tsx # SPA shell — routes all panels
│ │ ├── login/page.tsx # Login page

View File

@ -34,13 +34,14 @@ import { GitHubSyncPanel } from '@/components/panels/github-sync-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
import { UpdateBanner } from '@/components/layout/update-banner'
import { useWebSocket } from '@/lib/websocket'
import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store'
export default function Home() {
const { connect } = useWebSocket()
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, liveFeedOpen, toggleLiveFeed } = useMissionControl()
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
// Sync URL → Zustand activeTab
const pathname = usePathname()
@ -63,6 +64,20 @@ export default function Home() {
.then(data => { if (data?.user) setCurrentUser(data.user) })
.catch(() => {})
// Check for available updates
fetch('/api/releases/check')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.updateAvailable) {
setUpdateAvailable({
latestVersion: data.latestVersion,
releaseUrl: data.releaseUrl,
releaseNotes: data.releaseNotes,
})
}
})
.catch(() => {})
// Check capabilities, then conditionally connect to gateway
fetch('/api/status?action=capabilities')
.then(res => res.ok ? res.json() : null)
@ -103,7 +118,7 @@ export default function Home() {
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
})
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription])
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
if (!isClient) {
return (
@ -130,6 +145,7 @@ export default function Home() {
<div className="flex-1 flex flex-col min-w-0">
<HeaderBar />
<LocalModeBanner />
<UpdateBanner />
<main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
<div aria-live="polite">
<ErrorBoundary key={activeTab}>

View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { APP_VERSION } from '@/lib/version'
const GITHUB_RELEASES_URL =
'https://api.github.com/repos/builderz-labs/mission-control/releases/latest'
/** Simple semver compare: returns 1 if a > b, -1 if a < b, 0 if equal. */
function compareSemver(a: string, b: string): number {
const pa = a.replace(/^v/, '').split('.').map(Number)
const pb = b.replace(/^v/, '').split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] ?? 0
const nb = pb[i] ?? 0
if (na > nb) return 1
if (na < nb) return -1
}
return 0
}
export async function GET() {
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
next: { revalidate: 3600 }, // ISR cache for 1 hour
})
if (!res.ok) {
return NextResponse.json(
{ updateAvailable: false, currentVersion: APP_VERSION },
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
)
}
const release = await res.json()
const latestVersion = (release.tag_name ?? '').replace(/^v/, '')
const updateAvailable = compareSemver(latestVersion, APP_VERSION) > 0
return NextResponse.json(
{
updateAvailable,
currentVersion: APP_VERSION,
latestVersion,
releaseUrl: release.html_url ?? '',
releaseNotes: release.body ?? '',
},
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
)
} catch {
// Network error — fail gracefully
return NextResponse.json(
{ updateAvailable: false, currentVersion: APP_VERSION },
{ headers: { 'Cache-Control': 'public, max-age=600' } }
)
}
}

View File

@ -7,6 +7,7 @@ import { useWebSocket } from '@/lib/websocket'
import { useNavigateToPanel } from '@/lib/navigation'
import { ThemeToggle } from '@/components/ui/theme-toggle'
import { DigitalClock } from '@/components/ui/digital-clock'
import { APP_VERSION } from '@/lib/version'
interface SearchResult {
type: string
@ -136,7 +137,7 @@ export function HeaderBar() {
{tabLabels[activeTab] || 'Mission Control'}
</h1>
<span className="text-2xs text-muted-foreground font-mono-tight">
v2.0
v{APP_VERSION}
</span>
</div>

View File

@ -0,0 +1,39 @@
'use client'
import { useMissionControl } from '@/store'
export function UpdateBanner() {
const { updateAvailable, updateDismissedVersion, dismissUpdate } = useMissionControl()
if (!updateAvailable) return null
if (updateDismissedVersion === updateAvailable.latestVersion) return null
return (
<div className="mx-4 mt-3 mb-0 flex items-center gap-3 px-4 py-2.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<p className="flex-1 text-xs text-emerald-300">
<span className="font-medium text-emerald-200">
Update available: v{updateAvailable.latestVersion}
</span>
{' — a newer version of Mission Control is available.'}
</p>
<a
href={updateAvailable.releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-2xs font-medium text-emerald-400 hover:text-emerald-300 px-2 py-1 rounded border border-emerald-500/20 hover:border-emerald-500/40 transition-colors"
>
View Release
</a>
<button
onClick={() => dismissUpdate(updateAvailable.latestVersion)}
className="shrink-0 text-emerald-400/60 hover:text-emerald-300 transition-colors"
title="Dismiss"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
)
}

6
src/lib/version.ts Normal file
View File

@ -0,0 +1,6 @@
// Single source of truth for the application version.
// Reads from package.json at build time so every consumer
// (header, websocket handshake, API routes) stays in sync.
import pkg from '../../package.json'
export const APP_VERSION: string = pkg.version

View File

@ -9,6 +9,7 @@ import {
getCachedDeviceToken,
cacheDeviceToken,
} from '@/lib/device-identity'
import { APP_VERSION } from '@/lib/version'
// Gateway protocol version (v3 required by OpenClaw 2026.x)
const PROTOCOL_VERSION = 3
@ -172,7 +173,7 @@ export function useWebSocket() {
client: {
id: 'gateway-client',
displayName: 'Mission Control',
version: '2.0.0',
version: APP_VERSION,
platform: 'web',
mode: 'ui',
instanceId: `mc-${Date.now()}`

View File

@ -1,18 +1,14 @@
import crypto from 'node:crypto'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
/** Edge-compatible constant-time string comparison. */
/** Constant-time string comparison using Node.js crypto. */
function safeCompare(a: string, b: string): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
const encoder = new TextEncoder()
const bufA = encoder.encode(a)
const bufB = encoder.encode(b)
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
let result = 0
for (let i = 0; i < bufA.length; i++) {
result |= bufA[i] ^ bufB[i]
}
return result === 0
return crypto.timingSafeEqual(bufA, bufB)
}
function envFlag(name: string): boolean {
@ -56,7 +52,7 @@ function applySecurityHeaders(response: NextResponse): NextResponse {
return response
}
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
// Network access control.
// In production: default-deny unless explicitly allowed.
// In dev/test: allow all hosts unless overridden.

View File

@ -261,6 +261,12 @@ interface MissionControlStore {
dismissBanner: () => void
setSubscription: (sub: { type: string; rateLimitTier?: string } | null) => void
// Update availability
updateAvailable: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null
updateDismissedVersion: string | null
setUpdateAvailable: (info: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null) => void
dismissUpdate: (version: string) => void
// WebSocket & Connection
connection: ConnectionStatus
lastMessage: any
@ -408,6 +414,18 @@ export const useMissionControl = create<MissionControlStore>()(
dismissBanner: () => set({ bannerDismissed: true }),
setSubscription: (sub) => set({ subscription: sub }),
// Update availability
updateAvailable: null,
updateDismissedVersion: (() => {
if (typeof window === 'undefined') return null
try { return localStorage.getItem('mc-update-dismissed-version') } catch { return null }
})(),
setUpdateAvailable: (info) => set({ updateAvailable: info }),
dismissUpdate: (version) => {
try { localStorage.setItem('mc-update-dismissed-version', version) } catch {}
set({ updateDismissedVersion: version })
},
// Connection state
connection: {
isConnected: false,