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:
parent
71f2627138
commit
274b726df4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()}`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue