Monitor: Updates tab with git version check and one-click deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba8b9bd5a3
commit
b1bf0e3af1
1 changed files with 127 additions and 25 deletions
|
|
@ -2,6 +2,16 @@ import { useEffect, useState } from 'react'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
interface AppStatus {
|
||||||
|
repo: string
|
||||||
|
host: string
|
||||||
|
currentCommit: string
|
||||||
|
latestCommit: string
|
||||||
|
updateAvailable: boolean
|
||||||
|
deployed: boolean
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
interface DeployEntry {
|
interface DeployEntry {
|
||||||
repo: string
|
repo: string
|
||||||
host: string
|
host: string
|
||||||
|
|
@ -13,9 +23,21 @@ interface DeployEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminMonitor({ user }: { user: User }) {
|
export function AdminMonitor({ user }: { user: User }) {
|
||||||
const [tab, setTab] = useState<'kuma' | 'deploys'>('kuma')
|
const [tab, setTab] = useState<'updates' | 'kuma' | 'deploys'>('updates')
|
||||||
|
const [statuses, setStatuses] = useState<AppStatus[]>([])
|
||||||
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [deploying, setDeploying] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/deploy/status', { credentials: 'include' })
|
||||||
|
if (res.ok) setStatuses(await res.json())
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDeploys() {
|
async function fetchDeploys() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -27,36 +49,127 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerDeploy(repo: string) {
|
||||||
|
setDeploying(prev => new Set(prev).add(repo))
|
||||||
|
try {
|
||||||
|
await fetch(`/deploy/deploy/${repo}`, { method: 'POST', credentials: 'include' })
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchStatus()
|
||||||
|
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
||||||
|
}, 3000)
|
||||||
|
} catch {
|
||||||
|
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (tab === 'updates') fetchStatus()
|
||||||
if (tab === 'deploys') fetchDeploys()
|
if (tab === 'deploys') fetchDeploys()
|
||||||
}, [tab])
|
}, [tab])
|
||||||
|
|
||||||
|
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh' }}>
|
<div style={{ display: 'flex', height: '100dvh' }}>
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', gap: '0', borderBottom: '1px solid var(--surface-2)',
|
display: 'flex', borderBottom: '1px solid var(--surface-2)',
|
||||||
background: 'var(--navy)', flexShrink: 0,
|
background: 'var(--navy)', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{(['kuma', 'deploys'] as const).map(t => (
|
{(['updates', 'kuma', 'deploys'] as const).map(t => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{
|
<button key={t} onClick={() => setTab(t)} style={{
|
||||||
background: tab === t ? 'var(--surface)' : 'transparent',
|
background: tab === t ? 'var(--surface)' : 'transparent',
|
||||||
border: 'none', borderBottom: tab === t ? '2px solid var(--gold)' : '2px solid transparent',
|
border: 'none', borderBottom: tab === t ? '2px solid var(--gold)' : '2px solid transparent',
|
||||||
color: tab === t ? 'var(--gold)' : 'var(--text-muted)',
|
color: tab === t ? 'var(--gold)' : 'var(--text-muted)',
|
||||||
padding: '0.75rem 1.5rem', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
padding: '0.75rem 1.5rem', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{t === 'kuma' ? '📡 Uptime Monitor' : '🚀 Deploy Log'}
|
{t === 'updates' && '⬆ Updates'}
|
||||||
|
{t === 'updates' && updatesAvailable > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: '0.4rem', right: '0.4rem',
|
||||||
|
background: 'var(--gold)', color: 'var(--navy-dark)',
|
||||||
|
borderRadius: '50%', width: '16px', height: '16px',
|
||||||
|
fontSize: '0.65rem', fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>{updatesAvailable}</span>
|
||||||
|
)}
|
||||||
|
{t === 'kuma' && '📡 Uptime'}
|
||||||
|
{t === 'deploys' && '📋 Deploy Log'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === 'updates' && (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>App Updates</h2>
|
||||||
|
<button onClick={fetchStatus} disabled={loading} style={{
|
||||||
|
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
|
||||||
|
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{loading ? 'Checking…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statuses.length === 0 && !loading && (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No apps in deploy map yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{statuses.map(s => (
|
||||||
|
<div key={s.repo} style={{
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
||||||
|
borderLeft: `3px solid ${s.updateAvailable ? 'var(--gold)' : s.deployed ? '#2d6a4f' : 'var(--surface-2)'}`,
|
||||||
|
borderRadius: '8px', padding: '0.85rem 1rem',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9rem', marginBottom: '0.25rem' }}>
|
||||||
|
{s.repo}
|
||||||
|
{!s.deployed && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
||||||
|
not deployed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontFamily: 'monospace' }}>
|
||||||
|
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit}
|
||||||
|
{s.latestCommit !== 'unknown' && (
|
||||||
|
<span style={{ marginLeft: '0.75rem' }}>
|
||||||
|
latest: {s.latestCommit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
{s.updateAvailable ? (
|
||||||
|
<button
|
||||||
|
onClick={() => triggerDeploy(s.repo)}
|
||||||
|
disabled={deploying.has(s.repo)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--gold)', color: 'var(--navy-dark)',
|
||||||
|
border: 'none', borderRadius: '6px',
|
||||||
|
padding: '0.4rem 1rem', fontSize: '0.8rem', fontWeight: 700,
|
||||||
|
cursor: deploying.has(s.repo) ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: deploying.has(s.repo) ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deploying.has(s.repo) ? 'Updating…' : 'Update'}
|
||||||
|
</button>
|
||||||
|
) : s.deployed ? (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#52b788' }}>✓ Up to date</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'kuma' && (
|
{tab === 'kuma' && (
|
||||||
<iframe
|
<iframe src="/monitor/" title="Uptime Kuma" style={{ flex: 1, border: 'none', width: '100%' }} />
|
||||||
src="/monitor/"
|
|
||||||
title="Uptime Kuma"
|
|
||||||
style={{ flex: 1, border: 'none', width: '100%' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'deploys' && (
|
{tab === 'deploys' && (
|
||||||
|
|
@ -70,16 +183,9 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
{loading ? 'Loading…' : 'Refresh'}
|
{loading ? 'Loading…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deploys.length === 0 && !loading && (
|
{deploys.length === 0 && !loading && (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>No deploys recorded yet.</p>
|
||||||
No deploys recorded yet. Configure Forgejo webhooks to point to{' '}
|
|
||||||
<code style={{ background: 'var(--surface-2)', padding: '0.1rem 0.3rem', borderRadius: '3px' }}>
|
|
||||||
https://manage.hotelnumberfour.com/deploy/webhook
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deploys.map((d, i) => (
|
{deploys.map((d, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
||||||
|
|
@ -92,22 +198,18 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
fontSize: '0.75rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
|
fontSize: '0.75rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
|
||||||
background: d.status === 'success' ? '#2d6a4f33' : d.status === 'failed' ? '#ff4d4d22' : '#f5a62322',
|
background: d.status === 'success' ? '#2d6a4f33' : d.status === 'failed' ? '#ff4d4d22' : '#f5a62322',
|
||||||
color: d.status === 'success' ? '#52b788' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)',
|
color: d.status === 'success' ? '#52b788' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)',
|
||||||
}}>
|
}}>{d.status}</span>
|
||||||
{d.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
|
||||||
{d.host} · started {new Date(d.started).toLocaleString()}
|
{d.host} · {new Date(d.started).toLocaleString()}
|
||||||
{d.finished && ` · finished ${new Date(d.finished).toLocaleString()}`}
|
{d.finished && ` → ${new Date(d.finished).toLocaleString()}`}
|
||||||
</div>
|
</div>
|
||||||
{(d.output || d.error) && (
|
{(d.output || d.error) && (
|
||||||
<pre style={{
|
<pre style={{
|
||||||
background: 'var(--navy-dark)', borderRadius: '4px', padding: '0.5rem',
|
background: 'var(--navy-dark)', borderRadius: '4px', padding: '0.5rem',
|
||||||
fontSize: '0.7rem', color: 'var(--text-muted)', overflowX: 'auto',
|
fontSize: '0.7rem', color: 'var(--text-muted)', overflowX: 'auto',
|
||||||
margin: '0.5rem 0 0', maxHeight: '8rem', overflowY: 'auto',
|
margin: '0.5rem 0 0', maxHeight: '8rem', overflowY: 'auto',
|
||||||
}}>
|
}}>{d.output || d.error}</pre>
|
||||||
{d.output || d.error}
|
|
||||||
</pre>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue