904 lines
23 KiB
TypeScript
904 lines
23 KiB
TypeScript
import { render as renderApp } from 'hono/jsx/dom'
|
|
import { define, Styles } from '@because/forge'
|
|
import type { App, AppState } from '../shared/types'
|
|
import { generateTemplates } from '../shared/templates'
|
|
import { theme } from './themes'
|
|
import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal'
|
|
import { initUpdate } from './update'
|
|
import { openEmojiPicker } from './tags/emoji-picker'
|
|
|
|
// UI state (survives re-renders)
|
|
let selectedApp: string | null = localStorage.getItem('selectedApp')
|
|
let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
|
|
|
// Server state (from SSE)
|
|
let apps: App[] = []
|
|
|
|
// Layout
|
|
const Layout = define('Layout', {
|
|
display: 'flex',
|
|
height: '100vh',
|
|
fontFamily: theme('fonts-sans'),
|
|
background: theme('colors-bg'),
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const Sidebar = define('Sidebar', {
|
|
width: 220,
|
|
borderRight: `1px solid ${theme('colors-border')}`,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
flexShrink: 0,
|
|
})
|
|
|
|
const Logo = define('Logo', {
|
|
height: 64,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '0 16px',
|
|
fontSize: 20,
|
|
fontWeight: 'bold',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const HamburgerButton = define('HamburgerButton', {
|
|
base: 'button',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: 4,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
gap: 4,
|
|
selectors: {
|
|
'&:hover span': { background: theme('colors-text') },
|
|
},
|
|
})
|
|
|
|
const HamburgerLine = define('HamburgerLine', {
|
|
width: 18,
|
|
height: 2,
|
|
background: theme('colors-textMuted'),
|
|
borderRadius: 1,
|
|
transition: 'background 0.15s',
|
|
})
|
|
|
|
const SectionLabel = define('SectionLabel', {
|
|
padding: '16px 16px 8px',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: theme('colors-textFaint'),
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
})
|
|
|
|
const AppList = define('AppList', {
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
})
|
|
|
|
const AppItem = define('AppItem', {
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '8px 16px',
|
|
color: theme('colors-textMuted'),
|
|
textDecoration: 'none',
|
|
fontSize: 14,
|
|
cursor: 'pointer',
|
|
selectors: {
|
|
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
|
},
|
|
variants: {
|
|
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
|
|
},
|
|
})
|
|
|
|
const StatusDot = define('StatusDot', {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: '50%',
|
|
flexShrink: 0,
|
|
variants: {
|
|
state: {
|
|
invalid: { background: theme('colors-statusInvalid') },
|
|
stopped: { background: theme('colors-statusStopped') },
|
|
starting: { background: theme('colors-statusStarting') },
|
|
running: { background: theme('colors-statusRunning') },
|
|
stopping: { background: theme('colors-statusStarting') },
|
|
},
|
|
inline: {
|
|
display: 'inline'
|
|
}
|
|
},
|
|
})
|
|
|
|
const SidebarFooter = define('SidebarFooter', {
|
|
padding: 16,
|
|
borderTop: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const NewAppButton = define('NewAppButton', {
|
|
display: 'block',
|
|
padding: '8px 12px',
|
|
background: theme('colors-bgElement'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
color: theme('colors-textMuted'),
|
|
textDecoration: 'none',
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
cursor: 'pointer',
|
|
selectors: {
|
|
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
|
},
|
|
})
|
|
|
|
// Main pane
|
|
const Main = define('Main', {
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
})
|
|
|
|
const MainHeader = define('MainHeader', {
|
|
height: 64,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '0 24px',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const MainTitle = define('MainTitle', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
fontSize: 20,
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
})
|
|
|
|
const ClickableAppName = define('ClickableAppName', {
|
|
cursor: 'pointer',
|
|
borderRadius: theme('radius-md'),
|
|
padding: '2px 6px',
|
|
margin: '-2px -6px',
|
|
selectors: {
|
|
'&:hover': {
|
|
background: theme('colors-bgHover'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const HeaderActions = define('HeaderActions', {
|
|
display: 'flex',
|
|
gap: 8,
|
|
})
|
|
|
|
const MainContent = define('MainContent', {
|
|
flex: 1,
|
|
padding: '10px 24px',
|
|
overflow: 'auto',
|
|
})
|
|
|
|
const Section = define('Section', {
|
|
marginBottom: 32,
|
|
})
|
|
|
|
const SectionTitle = define('SectionTitle', {
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: theme('colors-textFaint'),
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
marginBottom: 12,
|
|
paddingBottom: 8,
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const InfoRow = define('InfoRow', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
marginBottom: 12,
|
|
fontSize: 14,
|
|
})
|
|
|
|
const InfoLabel = define('InfoLabel', {
|
|
color: theme('colors-textFaint'),
|
|
width: 80,
|
|
flexShrink: 0,
|
|
})
|
|
|
|
const InfoValue = define('InfoValue', {
|
|
color: theme('colors-text'),
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
})
|
|
|
|
const Link = define('Link', {
|
|
base: 'a',
|
|
color: theme('colors-link'),
|
|
textDecoration: 'none',
|
|
selectors: {
|
|
'&:hover': { textDecoration: 'underline' },
|
|
},
|
|
})
|
|
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: '6px 12px',
|
|
background: theme('colors-bgElement'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
color: theme('colors-text'),
|
|
fontSize: 13,
|
|
cursor: 'pointer',
|
|
selectors: {
|
|
'&:hover': { background: theme('colors-bgHover') },
|
|
},
|
|
variants: {
|
|
variant: {
|
|
danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') },
|
|
primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') },
|
|
},
|
|
},
|
|
})
|
|
|
|
const ActionBar = define('ActionBar', {
|
|
display: 'flex',
|
|
gap: 8,
|
|
marginTop: 24,
|
|
paddingTop: 24,
|
|
borderTop: `1px solid ${theme('colors-border')}`,
|
|
})
|
|
|
|
const EmptyState = define('EmptyState', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%',
|
|
color: theme('colors-textFaint'),
|
|
fontSize: 14,
|
|
})
|
|
|
|
const LogsContainer = define('LogsContainer', {
|
|
background: theme('colors-bgSubtle'),
|
|
borderRadius: theme('radius-md'),
|
|
padding: 12,
|
|
fontFamily: theme('fonts-mono'),
|
|
fontSize: 12,
|
|
color: theme('colors-textMuted'),
|
|
maxHeight: 200,
|
|
overflow: 'auto',
|
|
|
|
render({ props: { children }, parts: { Root } }) {
|
|
return <Root ref={(el: HTMLElement | null) => {
|
|
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
|
|
}}>{children}</Root>
|
|
}
|
|
})
|
|
|
|
const LogLine = define('LogLine', {
|
|
marginBottom: 4,
|
|
selectors: {
|
|
'&:last-child': { marginBottom: 0 },
|
|
},
|
|
})
|
|
|
|
const LogTime = define('LogTime', {
|
|
color: theme('colors-textFaintest'),
|
|
marginRight: 12,
|
|
display: 'inline',
|
|
})
|
|
|
|
let selectedTab: 'overview' | 'todo' = 'overview'
|
|
|
|
const TabContent = define('TabContent', {
|
|
display: 'none',
|
|
|
|
variants: {
|
|
active: {
|
|
display: 'block'
|
|
}
|
|
}
|
|
})
|
|
|
|
const TabBar = define('TabBar', {
|
|
display: 'flex',
|
|
gap: 24,
|
|
marginBottom: 20,
|
|
})
|
|
|
|
const Tab = define('Tab', {
|
|
base: 'button',
|
|
padding: '6px 0',
|
|
background: 'none',
|
|
border: 'none',
|
|
borderBottom: '2px solid transparent',
|
|
cursor: 'pointer',
|
|
fontSize: 14,
|
|
color: theme('colors-textMuted'),
|
|
selectors: {
|
|
'&:hover': { color: theme('colors-text') },
|
|
},
|
|
variants: {
|
|
active: {
|
|
color: theme('colors-text'),
|
|
borderBottomColor: theme('colors-primary'),
|
|
fontWeight: 500,
|
|
},
|
|
},
|
|
})
|
|
|
|
function setSelectedTab(tab: 'overview' | 'todo') {
|
|
selectedTab = tab
|
|
render()
|
|
}
|
|
|
|
const Nav = () => {
|
|
return (
|
|
<TabBar>
|
|
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => setSelectedTab('overview')}>Overview</Tab>
|
|
<Tab active={selectedTab === 'todo' ? true : undefined} onClick={() => setSelectedTab('todo')}>TODO</Tab>
|
|
</TabBar>
|
|
)
|
|
}
|
|
|
|
const stateLabels: Record<AppState, string> = {
|
|
invalid: 'Invalid',
|
|
stopped: 'Stopped',
|
|
starting: 'Starting',
|
|
running: 'Running',
|
|
stopping: 'Stopping',
|
|
}
|
|
|
|
// Form styles for modal
|
|
const Form = define('Form', {
|
|
base: 'form',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 16,
|
|
})
|
|
|
|
const FormField = define('FormField', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
})
|
|
|
|
const FormLabel = define('FormLabel', {
|
|
base: 'label',
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const FormInput = define('FormInput', {
|
|
base: 'input',
|
|
padding: '8px 12px',
|
|
background: theme('colors-bgSubtle'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
color: theme('colors-text'),
|
|
fontSize: 14,
|
|
selectors: {
|
|
'&:focus': {
|
|
outline: 'none',
|
|
borderColor: theme('colors-primary'),
|
|
},
|
|
'&::placeholder': {
|
|
color: theme('colors-textFaint'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const FormError = define('FormError', {
|
|
fontSize: 13,
|
|
color: theme('colors-error'),
|
|
})
|
|
|
|
const FormActions = define('FormActions', {
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: 8,
|
|
marginTop: 8,
|
|
})
|
|
|
|
// New App creation
|
|
let newAppError = ''
|
|
let newAppCreating = false
|
|
|
|
// Delete App confirmation
|
|
let deleteAppError = ''
|
|
let deleteAppDeleting = false
|
|
let deleteAppTarget: App | null = null
|
|
|
|
// Rename App
|
|
let renameAppError = ''
|
|
let renameAppRenaming = false
|
|
let renameAppTarget: App | null = null
|
|
|
|
async function createNewApp(input: HTMLInputElement) {
|
|
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
|
|
|
if (!name) {
|
|
newAppError = 'App name is required'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
if (apps.some(a => a.name === name)) {
|
|
newAppError = 'An app with this name already exists'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
newAppCreating = true
|
|
newAppError = ''
|
|
rerenderModal()
|
|
|
|
try {
|
|
const templates = generateTemplates(name)
|
|
|
|
for (const [filename, content] of Object.entries(templates)) {
|
|
const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, {
|
|
method: 'PUT',
|
|
body: content,
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to create ${filename}`)
|
|
}
|
|
}
|
|
|
|
// Success - close modal and select the new app
|
|
selectedApp = name
|
|
localStorage.setItem('selectedApp', name)
|
|
closeModal()
|
|
} catch (err) {
|
|
newAppError = err instanceof Error ? err.message : 'Failed to create app'
|
|
newAppCreating = false
|
|
rerenderModal()
|
|
}
|
|
}
|
|
|
|
function openNewAppModal() {
|
|
newAppError = ''
|
|
newAppCreating = false
|
|
|
|
openModal('New App', () => (
|
|
<Form onSubmit={(e: Event) => {
|
|
e.preventDefault()
|
|
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
|
createNewApp(input)
|
|
}}>
|
|
<FormField>
|
|
<FormLabel for="app-name">App Name</FormLabel>
|
|
<FormInput
|
|
id="app-name"
|
|
type="text"
|
|
placeholder="my-app"
|
|
autofocus
|
|
/>
|
|
{newAppError && <FormError>{newAppError}</FormError>}
|
|
</FormField>
|
|
<FormActions>
|
|
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={newAppCreating}>
|
|
{newAppCreating ? 'Creating...' : 'Create App'}
|
|
</Button>
|
|
</FormActions>
|
|
</Form>
|
|
))
|
|
}
|
|
|
|
// Delete App confirmation modal
|
|
async function deleteApp(input: HTMLInputElement) {
|
|
if (!deleteAppTarget) return
|
|
|
|
const expected = `sudo rm ${deleteAppTarget.name}`
|
|
const value = input.value.trim()
|
|
|
|
if (value !== expected) {
|
|
deleteAppError = `Type "${expected}" to confirm`
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
deleteAppDeleting = true
|
|
deleteAppError = ''
|
|
rerenderModal()
|
|
|
|
try {
|
|
const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, {
|
|
method: 'DELETE',
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to delete app: ${res.statusText}`)
|
|
}
|
|
|
|
// Success - close modal and clear selection
|
|
if (selectedApp === deleteAppTarget.name) {
|
|
selectedApp = null
|
|
localStorage.removeItem('selectedApp')
|
|
}
|
|
closeModal()
|
|
} catch (err) {
|
|
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
|
|
deleteAppDeleting = false
|
|
rerenderModal()
|
|
}
|
|
}
|
|
|
|
function openDeleteAppModal(app: App) {
|
|
deleteAppError = ''
|
|
deleteAppDeleting = false
|
|
deleteAppTarget = app
|
|
|
|
const expected = `sudo rm ${app.name}`
|
|
|
|
openModal('Delete App', () => (
|
|
<Form onSubmit={(e: Event) => {
|
|
e.preventDefault()
|
|
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
|
deleteApp(input)
|
|
}}>
|
|
<p style={{ margin: '0 0 16px', color: theme('colors-textMuted') }}>
|
|
This will <strong style={{ color: theme('colors-error') }}>permanently delete</strong> <strong>{app.name}</strong> from the server.
|
|
</p>
|
|
<FormField>
|
|
<FormLabel for="delete-confirm">Type "{expected}" to confirm</FormLabel>
|
|
<FormInput
|
|
id="delete-confirm"
|
|
type="text"
|
|
placeholder={expected}
|
|
autofocus
|
|
/>
|
|
{deleteAppError && <FormError>{deleteAppError}</FormError>}
|
|
</FormField>
|
|
<FormActions>
|
|
<Button type="button" onClick={closeModal} disabled={deleteAppDeleting}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" variant="danger" disabled={deleteAppDeleting}>
|
|
{deleteAppDeleting ? 'Deleting...' : 'Delete App'}
|
|
</Button>
|
|
</FormActions>
|
|
</Form>
|
|
))
|
|
}
|
|
|
|
// Rename App modal
|
|
async function doRenameApp(input: HTMLInputElement) {
|
|
if (!renameAppTarget) return
|
|
|
|
const newName = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
|
|
|
if (!newName) {
|
|
renameAppError = 'App name is required'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
if (!/^[a-z][a-z0-9-]*$/.test(newName)) {
|
|
renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
if (newName === renameAppTarget.name) {
|
|
closeModal()
|
|
return
|
|
}
|
|
|
|
if (apps.some(a => a.name === newName)) {
|
|
renameAppError = 'An app with this name already exists'
|
|
rerenderModal()
|
|
return
|
|
}
|
|
|
|
renameAppRenaming = true
|
|
renameAppError = ''
|
|
rerenderModal()
|
|
|
|
try {
|
|
const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName }),
|
|
})
|
|
|
|
const text = await res.text()
|
|
let data: { ok?: boolean, error?: string, name?: string }
|
|
try {
|
|
data = JSON.parse(text)
|
|
} catch {
|
|
throw new Error(`Server error: ${text.slice(0, 100)}`)
|
|
}
|
|
|
|
if (!res.ok || !data.ok) {
|
|
throw new Error(data.error || 'Failed to rename app')
|
|
}
|
|
|
|
// Success - update selection and close modal
|
|
selectedApp = data.name || newName
|
|
localStorage.setItem('selectedApp', data.name || newName)
|
|
closeModal()
|
|
} catch (err) {
|
|
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
|
|
renameAppRenaming = false
|
|
rerenderModal()
|
|
}
|
|
}
|
|
|
|
function openRenameAppModal(app: App) {
|
|
renameAppError = ''
|
|
renameAppRenaming = false
|
|
renameAppTarget = app
|
|
|
|
openModal('Rename App', () => (
|
|
<Form onSubmit={(e: Event) => {
|
|
e.preventDefault()
|
|
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
|
doRenameApp(input)
|
|
}}>
|
|
<FormField>
|
|
<FormLabel for="rename-app">App Name</FormLabel>
|
|
<FormInput
|
|
id="rename-app"
|
|
type="text"
|
|
value={renameAppTarget?.name ?? ''}
|
|
autofocus
|
|
/>
|
|
{renameAppError && <FormError>{renameAppError}</FormError>}
|
|
</FormField>
|
|
<FormActions>
|
|
<Button type="button" onClick={closeModal} disabled={renameAppRenaming}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={renameAppRenaming}>
|
|
{renameAppRenaming ? 'Renaming...' : 'Rename'}
|
|
</Button>
|
|
</FormActions>
|
|
</Form>
|
|
))
|
|
}
|
|
|
|
// Actions - call API then let SSE update the state
|
|
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
|
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
|
const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
|
|
|
const selectApp = (name: string) => {
|
|
selectedApp = name
|
|
localStorage.setItem('selectedApp', name)
|
|
render()
|
|
}
|
|
|
|
const toggleSidebar = () => {
|
|
sidebarCollapsed = !sidebarCollapsed
|
|
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed))
|
|
render()
|
|
}
|
|
|
|
const OpenEmojiPicker = define('OpenEmojiPicker', {
|
|
cursor: 'pointer',
|
|
|
|
render({ props: { app, children }, parts: { Root } }) {
|
|
return <Root onClick={() => openEmojiPicker((emoji) => {
|
|
if (!app) return
|
|
|
|
fetch(`/api/apps/${app.name}/icon?icon=${emoji}`, { method: 'POST' })
|
|
app.icon = emoji
|
|
render()
|
|
})}>{children}</Root>
|
|
}
|
|
})
|
|
|
|
const AppDetail = ({ app }: { app: App }) => (
|
|
<>
|
|
<MainHeader>
|
|
<MainTitle>
|
|
<OpenEmojiPicker app={app}>{app.icon}</OpenEmojiPicker>
|
|
|
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
|
</MainTitle>
|
|
<HeaderActions>
|
|
{/* <Button>Settings</Button> */}
|
|
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
|
</HeaderActions>
|
|
</MainHeader>
|
|
<MainContent>
|
|
<Nav />
|
|
|
|
<TabContent active={selectedTab === 'overview'}>
|
|
<Section>
|
|
<SectionTitle>Status</SectionTitle>
|
|
<InfoRow>
|
|
<InfoLabel>State</InfoLabel>
|
|
<InfoValue>
|
|
<StatusDot state={app.state} />
|
|
{stateLabels[app.state]}
|
|
</InfoValue>
|
|
</InfoRow>
|
|
{app.state === 'running' && app.port && (
|
|
<InfoRow>
|
|
<InfoLabel>URL</InfoLabel>
|
|
<InfoValue>
|
|
<Link href={`http://localhost:${app.port}`} target="_blank">
|
|
http://localhost:{app.port}
|
|
</Link>
|
|
</InfoValue>
|
|
</InfoRow>
|
|
)}
|
|
{app.state === 'running' && app.port && (
|
|
<InfoRow>
|
|
<InfoLabel>Port</InfoLabel>
|
|
<InfoValue>
|
|
{app.port}
|
|
</InfoValue>
|
|
</InfoRow>
|
|
)}
|
|
{app.started && (
|
|
<InfoRow>
|
|
<InfoLabel>Started</InfoLabel>
|
|
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
|
|
</InfoRow>
|
|
)}
|
|
{app.error && (
|
|
<InfoRow>
|
|
<InfoLabel>Error</InfoLabel>
|
|
<InfoValue style={{ color: theme('colors-error') }}>
|
|
{app.error}
|
|
</InfoValue>
|
|
</InfoRow>
|
|
)}
|
|
</Section>
|
|
|
|
<Section>
|
|
<SectionTitle>Logs</SectionTitle>
|
|
<LogsContainer>
|
|
{app.logs?.length ? (
|
|
app.logs.map((line, i) => (
|
|
<LogLine key={i}>
|
|
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
|
|
<span>{line.text}</span>
|
|
</LogLine>
|
|
))
|
|
) : (
|
|
<LogLine>
|
|
<LogTime>--:--:--</LogTime>
|
|
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
|
|
</LogLine>
|
|
)}
|
|
</LogsContainer>
|
|
</Section>
|
|
|
|
<ActionBar>
|
|
{app.state === 'stopped' && (
|
|
<Button variant="primary" onClick={() => startApp(app.name)}>
|
|
Start
|
|
</Button>
|
|
)}
|
|
{app.state === 'running' && (
|
|
<>
|
|
<Button onClick={() => restartApp(app.name)}>Restart</Button>
|
|
<Button variant="danger" onClick={() => stopApp(app.name)}>
|
|
Stop
|
|
</Button>
|
|
</>
|
|
)}
|
|
{(app.state === 'starting' || app.state === 'stopping') && (
|
|
<Button disabled>{stateLabels[app.state]}...</Button>
|
|
)}
|
|
</ActionBar>
|
|
</TabContent>
|
|
|
|
<TabContent active={selectedTab === 'todo'}>
|
|
<h1>hardy har har</h1>
|
|
</TabContent>
|
|
</MainContent>
|
|
</>
|
|
)
|
|
|
|
const Dashboard = () => {
|
|
const selected = apps.find(a => a.name === selectedApp)
|
|
|
|
return (
|
|
<Layout>
|
|
<Styles />
|
|
<Sidebar style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
|
<Logo>
|
|
{!sidebarCollapsed && <span>🐾 Toes</span>}
|
|
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
|
<HamburgerLine />
|
|
<HamburgerLine />
|
|
<HamburgerLine />
|
|
</HamburgerButton>
|
|
</Logo>
|
|
{!sidebarCollapsed && <SectionLabel>Apps</SectionLabel>}
|
|
<AppList>
|
|
{apps.map(app => (
|
|
<AppItem
|
|
key={app.name}
|
|
onClick={() => selectApp(app.name)}
|
|
selected={app.name === selectedApp ? true : undefined}
|
|
style={sidebarCollapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
|
title={sidebarCollapsed ? app.name : undefined}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
|
) : (
|
|
<>
|
|
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
|
{app.name}
|
|
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
|
|
</>
|
|
)}
|
|
</AppItem>
|
|
))}
|
|
</AppList>
|
|
{!sidebarCollapsed && (
|
|
<SidebarFooter>
|
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
|
</SidebarFooter>
|
|
)}
|
|
</Sidebar>
|
|
<Main>
|
|
{selected ? (
|
|
<AppDetail app={selected} />
|
|
) : (
|
|
<EmptyState>Select an app to view details</EmptyState>
|
|
)}
|
|
</Main>
|
|
<Modal />
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const render = () => {
|
|
renderApp(<Dashboard />, document.getElementById('app')!)
|
|
}
|
|
|
|
// Initialize render functions
|
|
initModal(render)
|
|
initUpdate(render)
|
|
|
|
// Set theme based on system preference
|
|
const setTheme = () => {
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
|
}
|
|
|
|
// Listen for system theme changes
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
setTheme()
|
|
render()
|
|
})
|
|
|
|
// Set initial theme
|
|
setTheme()
|
|
|
|
// SSE connection
|
|
const events = new EventSource('/api/apps/stream')
|
|
events.onmessage = e => {
|
|
apps = JSON.parse(e.data)
|
|
const valid = selectedApp && apps.some(a => a.name === selectedApp)
|
|
if (!valid && apps.length) selectedApp = apps[0]!.name
|
|
render()
|
|
}
|