Restyle to light content theme with Lucide icons
- index.css: light body (#f4f5f7), white cards, sidebar stays dark navy - AppIcon.tsx: Lucide icon lookup component for app tiles and nav - Sidebar.tsx: Lucide nav icons (LayoutGrid, Users, Activity, LogOut) - Dashboard.tsx: white cards with shadows, coloured Lucide app icons - AdminMonitor.tsx: light theme throughout, Lucide tab icons, better status colours for light backgrounds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ead0313561
commit
092c4fda31
7 changed files with 6487 additions and 132 deletions
6272
package-lock.json
generated
Normal file
6272
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.1"
|
"react-router-dom": "^6.28.1"
|
||||||
|
|
|
||||||
27
src/components/AppIcon.tsx
Normal file
27
src/components/AppIcon.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {
|
||||||
|
ClipboardList, ChefHat, Banknote, BedDouble, TrendingUp, Tag,
|
||||||
|
LayoutGrid, Users, Activity, Home, ScrollText, RefreshCw,
|
||||||
|
ArrowUpCircle, Wifi, Terminal, Settings, Building2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideProps } from 'lucide-react'
|
||||||
|
|
||||||
|
type IconComponent = React.ComponentType<LucideProps>
|
||||||
|
|
||||||
|
const iconMap: Record<string, IconComponent> = {
|
||||||
|
ClipboardList, ChefHat, Banknote, BedDouble, TrendingUp, Tag,
|
||||||
|
LayoutGrid, Users, Activity, Home, ScrollText, RefreshCw,
|
||||||
|
ArrowUpCircle, Wifi, Terminal, Settings, Building2,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
color?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppIcon({ name, size = 20, color, strokeWidth = 1.75 }: Props) {
|
||||||
|
const Icon = iconMap[name]
|
||||||
|
if (!Icon) return null
|
||||||
|
return <Icon size={size} color={color} strokeWidth={strokeWidth} />
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { LayoutGrid, Users, Activity, LogOut } from 'lucide-react'
|
||||||
|
import { AppIcon } from './AppIcon'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
interface Props { user: User; activeSlug?: string }
|
interface Props { user: User; activeSlug?: string }
|
||||||
|
|
@ -19,22 +21,23 @@ export function Sidebar({ user, activeSlug }: Props) {
|
||||||
position: 'sticky', top: 0,
|
position: 'sticky', top: 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '1.25rem 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
|
<div style={{ padding: '1.25rem 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
|
||||||
<div style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '0.9rem', letterSpacing: '0.05em' }}>
|
<div style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '0.9rem', letterSpacing: '0.06em' }}>
|
||||||
MANAGE
|
MANAGE
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: '0.2rem' }}>
|
||||||
{import.meta.env.VITE_HOTEL_NAME}
|
{import.meta.env.VITE_HOTEL_NAME}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
|
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
|
||||||
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
|
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
|
||||||
🏠 Dashboard
|
<LayoutGrid size={15} strokeWidth={1.75} />
|
||||||
|
Dashboard
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{user.apps.length > 0 && (
|
{user.apps.length > 0 && (
|
||||||
<div style={{ padding: '0.5rem 1rem 0.25rem', fontSize: '0.65rem',
|
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
|
||||||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
Apps
|
Apps
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -42,35 +45,39 @@ export function Sidebar({ user, activeSlug }: Props) {
|
||||||
{user.apps.map(app => (
|
{user.apps.map(app => (
|
||||||
<NavLink key={app.slug} to={`/app/${app.slug}`}
|
<NavLink key={app.slug} to={`/app/${app.slug}`}
|
||||||
style={({ isActive }) => navItem(isActive)}>
|
style={({ isActive }) => navItem(isActive)}>
|
||||||
<span style={{ fontSize: '1rem' }}>{app.icon}</span>
|
<AppIcon name={app.icon} size={15} strokeWidth={1.75} />
|
||||||
<span style={{ marginLeft: '0.5rem' }}>{app.name}</span>
|
<span>{app.name}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: '0.5rem 1rem 0.25rem', fontSize: '0.65rem',
|
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
|
||||||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
marginTop: '0.5rem' }}>
|
marginTop: '0.5rem' }}>
|
||||||
Admin
|
Admin
|
||||||
</div>
|
</div>
|
||||||
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
|
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
|
||||||
👥 Users
|
<Users size={15} strokeWidth={1.75} />
|
||||||
|
Users
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
|
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
|
||||||
📡 Monitor
|
<Activity size={15} strokeWidth={1.75} />
|
||||||
|
Monitor
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ padding: '0.875rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
<div style={{ padding: '0.875rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
||||||
<div style={{ fontSize: '0.8rem', fontWeight: 600, marginBottom: '0.1rem' }}>{user.name}</div>
|
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text)', marginBottom: '0.1rem' }}>{user.name}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.6rem' }}>{user.email}</div>
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.6rem' }}>{user.email}</div>
|
||||||
<button onClick={logout} style={{
|
<button onClick={logout} style={{
|
||||||
background: 'none', border: '1px solid var(--surface-2)', borderRadius: '5px',
|
background: 'none', border: '1px solid var(--surface-2)', borderRadius: '5px',
|
||||||
color: 'var(--text-muted)', padding: '0.3rem 0.75rem', fontSize: '0.75rem', width: '100%',
|
color: 'var(--text-muted)', padding: '0.3rem 0.75rem', fontSize: '0.75rem', width: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||||
}}>
|
}}>
|
||||||
|
<LogOut size={13} strokeWidth={1.75} />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,10 +87,11 @@ export function Sidebar({ user, activeSlug }: Props) {
|
||||||
|
|
||||||
function navItem(active: boolean): React.CSSProperties {
|
function navItem(active: boolean): React.CSSProperties {
|
||||||
return {
|
return {
|
||||||
display: 'flex', alignItems: 'center', padding: '0.55rem 1rem',
|
display: 'flex', alignItems: 'center', gap: '0.6rem',
|
||||||
fontSize: '0.875rem', color: active ? 'var(--gold)' : 'var(--text-muted)',
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.85rem', color: active ? 'var(--gold)' : 'var(--text-muted)',
|
||||||
background: active ? 'var(--surface)' : 'transparent',
|
background: active ? 'var(--surface)' : 'transparent',
|
||||||
borderLeft: active ? '3px solid var(--gold)' : '3px solid transparent',
|
borderLeft: active ? '2px solid var(--gold)' : '2px solid transparent',
|
||||||
transition: 'color 0.1s, background 0.1s',
|
transition: 'color 0.1s, background 0.1s',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,37 @@
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--navy: #1e3a5f;
|
/* ── Sidebar / shell chrome (stays dark) ── */
|
||||||
--navy-dark: #0f1f35;
|
--navy: #1a1a2e;
|
||||||
|
--navy-dark: #0f0f20;
|
||||||
--gold: #c9a84c;
|
--gold: #c9a84c;
|
||||||
--gold-light: #e8c96d;
|
--gold-light: #e8c96d;
|
||||||
--surface: #1a2d47;
|
--surface: rgba(255,255,255,0.07);
|
||||||
--surface-2: #243d5c;
|
--surface-2: rgba(255,255,255,0.08);
|
||||||
--text: #e8edf2;
|
--text: rgba(255,255,255,0.88);
|
||||||
--text-muted: #8ba3bc;
|
--text-muted: rgba(255,255,255,0.48);
|
||||||
--danger: #e05252;
|
|
||||||
|
/* ── Content area (light) ── */
|
||||||
|
--body-bg: #f4f5f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #e4e8ee;
|
||||||
|
--text-dark: #1e293b;
|
||||||
|
--text-mid: #64748b;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
|
||||||
|
/* ── Shared ── */
|
||||||
|
--danger: #dc2626;
|
||||||
--sidebar-w: 220px;
|
--sidebar-w: 220px;
|
||||||
|
--radius: 10px;
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--navy-dark);
|
background: var(--body-bg);
|
||||||
color: var(--text);
|
color: var(--text-dark);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ArrowUpCircle, Activity, ScrollText, RefreshCw } from 'lucide-react'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
|
@ -72,7 +73,7 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/deploy/deploy/${repo}`, { method: 'POST', credentials: 'include' })
|
await fetch(`/deploy/deploy/${repo}`, { method: 'POST', credentials: 'include' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchStatus()
|
fetchStatus(true)
|
||||||
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -92,77 +93,87 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
|
|
||||||
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'updates' as const, label: 'Updates', icon: <ArrowUpCircle size={14} strokeWidth={1.75} /> },
|
||||||
|
{ key: 'uptime' as const, label: 'Uptime', icon: <Activity size={14} strokeWidth={1.75} /> },
|
||||||
|
{ key: 'deploys' as const, label: 'Deploy Log', icon: <ScrollText size={14} strokeWidth={1.75} /> },
|
||||||
|
]
|
||||||
|
|
||||||
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' }}>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', borderBottom: '1px solid var(--surface-2)',
|
display: 'flex', borderBottom: '1px solid var(--card-border)',
|
||||||
background: 'var(--navy)', flexShrink: 0,
|
background: 'var(--card-bg)', flexShrink: 0, padding: '0 0.5rem',
|
||||||
}}>
|
}}>
|
||||||
{(['updates', 'uptime', 'deploys'] as const).map(t => (
|
{tabs.map(({ key, label, icon }) => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{
|
<button key={key} onClick={() => setTab(key)} style={{
|
||||||
background: tab === t ? 'var(--surface)' : 'transparent',
|
background: 'transparent', border: 'none',
|
||||||
border: 'none', borderBottom: tab === t ? '2px solid var(--gold)' : '2px solid transparent',
|
borderBottom: tab === key ? '2px solid var(--gold)' : '2px solid transparent',
|
||||||
color: tab === t ? 'var(--gold)' : 'var(--text-muted)',
|
color: tab === key ? 'var(--text-dark)' : 'var(--text-mid)',
|
||||||
padding: '0.75rem 1.5rem', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
padding: '0.75rem 1.25rem', fontSize: '0.82rem', fontWeight: tab === key ? 600 : 400,
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{t === 'updates' && '⬆ Updates'}
|
{icon}{label}
|
||||||
{t === 'updates' && updatesAvailable > 0 && (
|
{key === 'updates' && updatesAvailable > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', top: '0.4rem', right: '0.4rem',
|
background: 'var(--gold)', color: 'var(--navy)',
|
||||||
background: 'var(--gold)', color: 'var(--navy-dark)',
|
|
||||||
borderRadius: '50%', width: '16px', height: '16px',
|
borderRadius: '50%', width: '16px', height: '16px',
|
||||||
fontSize: '0.65rem', fontWeight: 700,
|
fontSize: '0.6rem', fontWeight: 700,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginLeft: '0.15rem',
|
||||||
}}>{updatesAvailable}</span>
|
}}>{updatesAvailable}</span>
|
||||||
)}
|
)}
|
||||||
{t === 'uptime' && '📡 Uptime'}
|
|
||||||
{t === 'deploys' && '📋 Deploy Log'}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Updates tab */}
|
||||||
{tab === 'updates' && (
|
{tab === 'updates' && (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>App Updates</h2>
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)' }}>App Updates</h2>
|
||||||
<button onClick={() => fetchStatus(true)} disabled={loading} style={{
|
<button onClick={() => fetchStatus(true)} disabled={loading} style={{
|
||||||
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||||
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
|
color: 'var(--text-mid)', padding: '0.35rem 0.9rem', fontSize: '0.8rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
}}>
|
}}>
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
{loading ? 'Checking…' : 'Refresh'}
|
{loading ? 'Checking…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statuses.length === 0 && !loading && (
|
{statuses.length === 0 && !loading && (
|
||||||
<p style={{ color: 'var(--text-muted)' }}>No apps in deploy map yet.</p>
|
<p style={{ color: 'var(--text-mid)' }}>No apps in deploy map yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
{statuses.map(s => (
|
{statuses.map(s => (
|
||||||
<div key={s.repo} style={{
|
<div key={s.repo} style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
background: 'var(--card-bg)',
|
||||||
borderLeft: `3px solid ${s.updateAvailable ? 'var(--gold)' : s.deployed ? '#2d6a4f' : 'var(--surface-2)'}`,
|
border: '1px solid var(--card-border)',
|
||||||
borderRadius: '8px', padding: '0.85rem 1rem',
|
borderLeft: `3px solid ${s.updateAvailable ? 'var(--gold)' : s.deployed ? '#16a34a' : 'var(--card-border)'}`,
|
||||||
|
borderRadius: 'var(--radius)', padding: '0.85rem 1rem',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: '0.9rem', marginBottom: '0.25rem' }}>
|
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)', marginBottom: '0.2rem' }}>
|
||||||
{s.repo}
|
{s.repo}
|
||||||
{!s.deployed && (
|
{!s.deployed && (
|
||||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: 'var(--text-mid)', fontWeight: 400 }}>
|
||||||
not deployed
|
not deployed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontFamily: 'monospace' }}>
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
|
||||||
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit}
|
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit}
|
||||||
{s.latestCommit !== 'unknown' && (
|
{s.latestCommit !== 'unknown' && (
|
||||||
<span style={{ marginLeft: '0.75rem' }}>
|
<span style={{ marginLeft: '0.75rem' }}>latest: {s.latestCommit}</span>
|
||||||
latest: {s.latestCommit}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,17 +183,16 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
onClick={() => triggerDeploy(s.repo)}
|
onClick={() => triggerDeploy(s.repo)}
|
||||||
disabled={deploying.has(s.repo)}
|
disabled={deploying.has(s.repo)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--gold)', color: 'var(--navy-dark)',
|
background: 'var(--gold)', color: 'var(--navy)',
|
||||||
border: 'none', borderRadius: '6px',
|
border: 'none', borderRadius: '6px',
|
||||||
padding: '0.4rem 1rem', fontSize: '0.8rem', fontWeight: 700,
|
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,
|
opacity: deploying.has(s.repo) ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{deploying.has(s.repo) ? 'Updating…' : 'Update'}
|
{deploying.has(s.repo) ? 'Updating…' : 'Update'}
|
||||||
</button>
|
</button>
|
||||||
) : s.deployed ? (
|
) : s.deployed ? (
|
||||||
<span style={{ fontSize: '0.75rem', color: '#52b788' }}>✓ Up to date</span>
|
<span style={{ fontSize: '0.75rem', color: '#16a34a', fontWeight: 500 }}>✓ Up to date</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,40 +201,41 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Uptime tab */}
|
||||||
{tab === 'uptime' && (
|
{tab === 'uptime' && (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>Service Health</h2>
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)' }}>Service Health</h2>
|
||||||
<button onClick={fetchHealth} disabled={loading} style={{
|
<button onClick={fetchHealth} disabled={loading} style={{
|
||||||
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||||
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
|
color: 'var(--text-mid)', padding: '0.35rem 0.9rem', fontSize: '0.8rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
}}>
|
}}>
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
{loading ? 'Checking…' : 'Refresh'}
|
{loading ? 'Checking…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{health.length === 0 && !loading && (
|
{health.length === 0 && !loading && (
|
||||||
<p style={{ color: 'var(--text-muted)' }}>Checking services…</p>
|
<p style={{ color: 'var(--text-mid)' }}>Checking services…</p>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{health.map(h => (
|
{health.map(h => (
|
||||||
<div key={h.name} style={{
|
<div key={h.name} style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
||||||
borderLeft: `3px solid ${h.up ? '#2d6a4f' : 'var(--danger, #ff4d4d)'}`,
|
borderLeft: `3px solid ${h.up ? '#16a34a' : 'var(--danger)'}`,
|
||||||
borderRadius: '8px', padding: '0.75rem 1rem',
|
borderRadius: 'var(--radius)', padding: '0.7rem 1rem',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0,
|
width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0,
|
||||||
background: h.up ? '#52b788' : 'var(--danger, #ff4d4d)',
|
background: h.up ? '#16a34a' : 'var(--danger)',
|
||||||
boxShadow: h.up ? '0 0 6px #52b78866' : undefined,
|
boxShadow: h.up ? '0 0 5px #16a34a66' : undefined,
|
||||||
}} />
|
}} />
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{h.name}</span>
|
<span style={{ fontWeight: 500, fontSize: '0.88rem', color: 'var(--text-dark)' }}>{h.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{ fontSize: '0.75rem', fontFamily: 'monospace', color: h.up ? '#16a34a' : 'var(--text-mid)' }}>
|
||||||
fontSize: '0.75rem', fontFamily: 'monospace',
|
|
||||||
color: h.up ? '#52b788' : 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{h.up ? `${h.ms}ms` : 'unreachable'}
|
{h.up ? `${h.ms}ms` : 'unreachable'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -233,49 +244,58 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deploy log tab */}
|
||||||
{tab === 'deploys' && (
|
{tab === 'deploys' && (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>Deploy History</h2>
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)' }}>Deploy History</h2>
|
||||||
<button onClick={fetchDeploys} disabled={loading} style={{
|
<button onClick={fetchDeploys} disabled={loading} style={{
|
||||||
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||||
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
|
color: 'var(--text-mid)', padding: '0.35rem 0.9rem', fontSize: '0.8rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
}}>
|
}}>
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
{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' }}>No deploys recorded yet.</p>
|
<p style={{ color: 'var(--text-mid)', fontSize: '0.9rem' }}>No deploys recorded yet.</p>
|
||||||
)}
|
)}
|
||||||
{deploys.map((d, i) => (
|
{deploys.map((d, i) => {
|
||||||
|
const isSuccess = d.status === 'success'
|
||||||
|
const isFailed = d.status === 'failed'
|
||||||
|
return (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--surface-2)',
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
||||||
borderLeft: `3px solid ${d.status === 'success' ? '#2d6a4f' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)'}`,
|
borderLeft: `3px solid ${isSuccess ? '#16a34a' : isFailed ? 'var(--danger)' : '#d97706'}`,
|
||||||
borderRadius: '8px', padding: '1rem', marginBottom: '0.75rem',
|
borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '0.6rem',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.4rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{d.repo}</span>
|
<span style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)' }}>{d.repo}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.75rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
|
fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
|
||||||
background: d.status === 'success' ? '#2d6a4f33' : d.status === 'failed' ? '#ff4d4d22' : '#f5a62322',
|
background: isSuccess ? '#dcfce7' : isFailed ? '#fee2e2' : '#fef9c3',
|
||||||
color: d.status === 'success' ? '#52b788' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)',
|
color: isSuccess ? '#16a34a' : isFailed ? 'var(--danger)' : '#ca8a04',
|
||||||
}}>{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-mid)', marginBottom: '0.35rem' }}>
|
||||||
{d.host} · {new Date(d.started).toLocaleString()}
|
{d.host} · {new Date(d.started).toLocaleString()}
|
||||||
{d.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: '#1e293b', borderRadius: '6px', padding: '0.6rem 0.75rem',
|
||||||
fontSize: '0.7rem', color: 'var(--text-muted)', overflowX: 'auto',
|
fontSize: '0.7rem', color: '#94a3b8', 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>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Download } from 'lucide-react'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
|
import { AppIcon } from '../components/AppIcon'
|
||||||
import type { User, App } from '../types'
|
import type { User, App } from '../types'
|
||||||
|
|
||||||
export function Dashboard({ user }: { user: User }) {
|
export function Dashboard({ user }: { user: User }) {
|
||||||
|
|
@ -8,19 +10,20 @@ export function Dashboard({ user }: { user: User }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh' }}>
|
<div style={{ display: 'flex', height: '100dvh' }}>
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
<main style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
|
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem' }}>
|
||||||
<h1 style={{ fontSize: '1.1rem', color: 'var(--text-muted)', fontWeight: 400, marginBottom: '1.5rem' }}>
|
<p style={{ fontSize: '1rem', color: 'var(--text-mid)', marginBottom: '2rem' }}>
|
||||||
Good {greeting()}, <span style={{ color: 'var(--text)', fontWeight: 600 }}>{user.name}</span>
|
Good {greeting()},{' '}
|
||||||
</h1>
|
<span style={{ color: 'var(--text-dark)', fontWeight: 600 }}>{user.name}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
{user.apps.length === 0 ? (
|
{user.apps.length === 0 ? (
|
||||||
<div style={{ color: 'var(--text-muted)', padding: '3rem 0', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text-mid)', padding: '3rem 0', textAlign: 'center' }}>
|
||||||
No apps assigned yet. Contact an administrator.
|
No apps assigned yet. Contact an administrator.
|
||||||
</div>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid', gap: '1rem',
|
display: 'grid', gap: '1rem',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||||
}}>
|
}}>
|
||||||
{user.apps.map(app => (
|
{user.apps.map(app => (
|
||||||
<AppTile key={app.slug} app={app} onOpen={() => navigate(`/app/${app.slug}`)} />
|
<AppTile key={app.slug} app={app} onOpen={() => navigate(`/app/${app.slug}`)} />
|
||||||
|
|
@ -35,32 +38,42 @@ export function Dashboard({ user }: { user: User }) {
|
||||||
function AppTile({ app, onOpen }: { app: App; onOpen: () => void }) {
|
function AppTile({ app, onOpen }: { app: App; onOpen: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--surface)', borderRadius: '12px',
|
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
|
||||||
border: '1px solid var(--surface-2)', overflow: 'hidden',
|
border: '1px solid var(--card-border)', overflow: 'hidden',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
transition: 'border-color 0.15s',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
transition: 'box-shadow 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
e.currentTarget.style.borderColor = app.theme_color
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--card-border)'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.borderColor = app.theme_color)}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--surface-2)')}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{ height: '4px', background: app.theme_color }} />
|
||||||
height: '6px',
|
|
||||||
background: app.theme_color,
|
|
||||||
}} />
|
|
||||||
<div style={{ padding: '1.25rem', flex: 1 }}>
|
<div style={{ padding: '1.25rem', flex: 1 }}>
|
||||||
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{app.icon}</div>
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', marginBottom: '0.3rem' }}>{app.name}</h2>
|
<AppIcon name={app.icon} size={28} color={app.theme_color} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.3rem' }}>
|
||||||
|
{app.name}
|
||||||
|
</h2>
|
||||||
{app.description && (
|
{app.description && (
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-mid)', lineHeight: 1.45 }}>
|
||||||
{app.description}
|
{app.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '0.75rem 1.25rem', display: 'flex', gap: '0.5rem',
|
<div style={{
|
||||||
borderTop: '1px solid var(--surface-2)' }}>
|
padding: '0.75rem 1.25rem', display: 'flex', gap: '0.5rem',
|
||||||
|
borderTop: '1px solid var(--card-border)',
|
||||||
|
}}>
|
||||||
<button onClick={onOpen} style={{
|
<button onClick={onOpen} style={{
|
||||||
flex: 1, background: app.theme_color, color: '#fff', border: 'none',
|
flex: 1, background: app.theme_color, color: '#fff', border: 'none',
|
||||||
borderRadius: '6px', padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600,
|
borderRadius: '6px', padding: '0.45rem', fontSize: '0.82rem', fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -68,10 +81,11 @@ function AppTile({ app, onOpen }: { app: App; onOpen: () => void }) {
|
||||||
onClick={() => window.open(`${app.base_path}/?install=1`, '_blank')}
|
onClick={() => window.open(`${app.base_path}/?install=1`, '_blank')}
|
||||||
title="Install as PWA"
|
title="Install as PWA"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
|
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||||
padding: '0.5rem 0.6rem', fontSize: '0.85rem', color: 'var(--text-muted)',
|
borderRadius: '6px', padding: '0.45rem 0.6rem',
|
||||||
|
color: 'var(--text-mid)', display: 'flex', alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
⊕
|
<Download size={14} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue