Compare commits
2 Commits
9c0762c882
...
caac6877d7
| Author | SHA1 | Date | |
|---|---|---|---|
| caac6877d7 | |||
| 86dacb0a74 |
|
|
@ -339,6 +339,41 @@ const EmptyState = define('EmptyState', {
|
||||||
color: theme('colors-textMuted'),
|
color: theme('colors-textMuted'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Tab = define('Tab', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabActive = define('TabActive', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: `2px solid ${theme('colors-primary')}`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabBar = define('TabBar', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
marginBottom: '15px',
|
||||||
|
})
|
||||||
|
|
||||||
const ChartsContainer = define('ChartsContainer', {
|
const ChartsContainer = define('ChartsContainer', {
|
||||||
marginTop: '24px',
|
marginTop: '24px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
|
|
@ -487,6 +522,82 @@ app.get('/', async c => {
|
||||||
|
|
||||||
// Single app view
|
// Single app view
|
||||||
if (appName) {
|
if (appName) {
|
||||||
|
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
|
||||||
|
const appUrl = `/?app=${appName}`
|
||||||
|
const globalUrl = `/?app=${appName}&tab=global`
|
||||||
|
|
||||||
|
if (tab === 'global') {
|
||||||
|
const metrics = await getAppMetrics()
|
||||||
|
|
||||||
|
metrics.sort((a, b) => {
|
||||||
|
if (a.state === 'running' && b.state !== 'running') return -1
|
||||||
|
if (a.state !== 'running' && b.state === 'running') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (metrics.length === 0) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Metrics">
|
||||||
|
<TabBar>
|
||||||
|
<Tab href={appUrl}>App</Tab>
|
||||||
|
<TabActive href={globalUrl}>Global</TabActive>
|
||||||
|
</TabBar>
|
||||||
|
<EmptyState>No apps found</EmptyState>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = metrics.filter(s => s.state === 'running')
|
||||||
|
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
|
||||||
|
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
|
||||||
|
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Metrics - Global">
|
||||||
|
<TabBar>
|
||||||
|
<Tab href={appUrl}>App</Tab>
|
||||||
|
<TabActive href={globalUrl}>Global</TabActive>
|
||||||
|
</TabBar>
|
||||||
|
<Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>State</Th>
|
||||||
|
<ThRight>PID</ThRight>
|
||||||
|
<ThRight>CPU</ThRight>
|
||||||
|
<ThRight>MEM</ThRight>
|
||||||
|
<ThRight>RSS</ThRight>
|
||||||
|
<ThRight>Data</ThRight>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{metrics.map(s => (
|
||||||
|
<Tr>
|
||||||
|
<Td>
|
||||||
|
{s.name}
|
||||||
|
{s.tool && <ToolBadge>[tool]</ToolBadge>}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
|
||||||
|
{s.state}
|
||||||
|
</StatusBadge>
|
||||||
|
</Td>
|
||||||
|
<TdRight>{s.pid ?? '-'}</TdRight>
|
||||||
|
<TdRight>{formatPercent(s.cpu)}</TdRight>
|
||||||
|
<TdRight>{formatPercent(s.memory)}</TdRight>
|
||||||
|
<TdRight>{formatRss(s.rss)}</TdRight>
|
||||||
|
<TdRight>{formatBytes(s.dataSize)}</TdRight>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
<Summary>
|
||||||
|
{running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
||||||
|
</Summary>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = await getAppMetricsByName(appName)
|
const metrics = await getAppMetricsByName(appName)
|
||||||
|
|
||||||
if (!metrics) {
|
if (!metrics) {
|
||||||
|
|
@ -499,6 +610,10 @@ app.get('/', async c => {
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Metrics">
|
<Layout title="Metrics">
|
||||||
|
<TabBar>
|
||||||
|
<TabActive href={appUrl}>App</TabActive>
|
||||||
|
<Tab href={globalUrl}>Global</Tab>
|
||||||
|
</TabBar>
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { readSyncState } from '%sync'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { del, get, getManifest, HOST, makeAppUrl, post } from '../http'
|
import { buildAppUrl } from '@urls'
|
||||||
|
import { del, get, getManifest, HOST, post } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
import { pushApp } from './sync'
|
import { pushApp } from './sync'
|
||||||
|
|
@ -57,9 +58,11 @@ export async function infoApp(arg?: string) {
|
||||||
const icon = STATE_ICONS[app.state] ?? '◯'
|
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||||
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
||||||
console.log(` State: ${app.state}`)
|
console.log(` State: ${app.state}`)
|
||||||
|
if (app.state === 'running') {
|
||||||
|
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
|
||||||
|
}
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
console.log(` Port: ${app.port}`)
|
console.log(` Port: ${app.port}`)
|
||||||
console.log(` URL: ${makeAppUrl(app.port)}`)
|
|
||||||
}
|
}
|
||||||
if (app.tunnelUrl) {
|
if (app.tunnelUrl) {
|
||||||
console.log(` Tunnel: ${app.tunnelUrl}`)
|
console.log(` Tunnel: ${app.tunnelUrl}`)
|
||||||
|
|
@ -207,7 +210,7 @@ export async function openApp(arg?: string) {
|
||||||
console.error(`App is not running: ${name}`)
|
console.error(`App is not running: ${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const url = makeAppUrl(app.port!)
|
const url = buildAppUrl(app.name, HOST)
|
||||||
console.log(`Opening ${url}`)
|
console.log(`Opening ${url}`)
|
||||||
Bun.spawn(['open', url])
|
Bun.spawn(['open', url])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,6 @@ export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeAppUrl(port: number): string {
|
|
||||||
const url = new URL(HOST)
|
|
||||||
url.port = String(port)
|
|
||||||
return url.toString().replace(/\/$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleError(error: unknown): void {
|
export function handleError(error: unknown): void {
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
||||||
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
export const disableTunnel = (name: string) =>
|
export const shareApp = (name: string) =>
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
|
||||||
|
|
||||||
export const enableTunnel = (name: string) =>
|
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const unshareApp = (name: string) =>
|
||||||
|
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||||
|
|
||||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { disableTunnel, enableTunnel, restartApp, startApp, stopApp } from '../api'
|
import { buildAppUrl } from '../../shared/urls'
|
||||||
|
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
||||||
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { apps, getSelectedTab, isNarrow } from '../state'
|
import { apps, getSelectedTab, isNarrow } from '../state'
|
||||||
import {
|
import {
|
||||||
|
|
@ -63,10 +64,10 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
{!app.tool && (
|
{!app.tool && (
|
||||||
app.tunnelUrl
|
app.tunnelUrl
|
||||||
? <Button onClick={() => { disableTunnel(app.name) }}>Unshare</Button>
|
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
||||||
: app.tunnelEnabled
|
: app.tunnelEnabled
|
||||||
? <Button disabled>Sharing...</Button>
|
? <Button disabled>Sharing...</Button>
|
||||||
: <Button disabled={app.state !== 'running'} onClick={() => { enableTunnel(app.name) }}>Share</Button>
|
: <Button disabled={app.state !== 'running'} onClick={() => { shareApp(app.name) }}>Share</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
|
|
@ -84,12 +85,12 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
{stateLabels[app.state]}
|
{stateLabels[app.state]}
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
{app.state === 'running' && app.port && (
|
{app.state === 'running' && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>URL</InfoLabel>
|
<InfoLabel>URL</InfoLabel>
|
||||||
<InfoValue>
|
<InfoValue>
|
||||||
<Link href={`${location.protocol}//${location.hostname}:${app.port}`} target="_blank">
|
<Link href={buildAppUrl(app.name, location.origin)} target="_blank">
|
||||||
{location.protocol}//{location.hostname}:{app.port}
|
{buildAppUrl(app.name, location.origin)}
|
||||||
</Link>
|
</Link>
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { update } from '../update'
|
||||||
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||||
import { Vitals, initVitals } from './Vitals'
|
import { Vitals, initVitals } from './Vitals'
|
||||||
|
|
||||||
|
let activeTooltip: string | null = null
|
||||||
|
|
||||||
export function DashboardLanding({ render }: { render: () => void }) {
|
export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initUnifiedLogs()
|
initUnifiedLogs()
|
||||||
|
|
@ -38,11 +40,22 @@ export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
|
|
||||||
<StatusDotsRow>
|
<StatusDotsRow>
|
||||||
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||||
<StatusDotLink key={app.name} data-tooltip={app.name} onClick={(e: Event) => {
|
<StatusDotLink
|
||||||
e.preventDefault()
|
key={app.name}
|
||||||
setSelectedApp(app.name)
|
data-tooltip={app.name}
|
||||||
update()
|
tooltipVisible={activeTooltip === app.name || undefined}
|
||||||
}}>
|
onClick={(e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (isNarrow && activeTooltip !== app.name) {
|
||||||
|
activeTooltip = app.name
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeTooltip = null
|
||||||
|
setSelectedApp(app.name)
|
||||||
|
update()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<StatusDot state={app.state} data-app={app.name} />
|
<StatusDot state={app.state} data-app={app.name} />
|
||||||
</StatusDotLink>
|
</StatusDotLink>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ export function LogsSection({ app }: { app: App }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, marginBottom: 0 }}>
|
||||||
<LogsHeader>
|
<LogsHeader>
|
||||||
<SectionTitle style={{ marginBottom: 0 }}>Logs</SectionTitle>
|
<SectionTitle style={{ marginBottom: 0 }}>Logs</SectionTitle>
|
||||||
<LogsControls>
|
<LogsControls>
|
||||||
|
|
|
||||||
|
|
@ -127,17 +127,18 @@ function Gauge({ value }: { value: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function VitalsContent() {
|
function VitalsContent() {
|
||||||
|
const narrow = isNarrow || undefined
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VitalCard>
|
<VitalCard narrow={narrow}>
|
||||||
<VitalLabel>CPU</VitalLabel>
|
<VitalLabel>CPU</VitalLabel>
|
||||||
<Gauge value={_metrics.cpu} />
|
<Gauge value={_metrics.cpu} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
<VitalCard>
|
<VitalCard narrow={narrow}>
|
||||||
<VitalLabel>RAM</VitalLabel>
|
<VitalLabel>RAM</VitalLabel>
|
||||||
<Gauge value={_metrics.ram.percent} />
|
<Gauge value={_metrics.ram.percent} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
<VitalCard>
|
<VitalCard narrow={narrow}>
|
||||||
<VitalLabel>Disk</VitalLabel>
|
<VitalLabel>Disk</VitalLabel>
|
||||||
<Gauge value={_metrics.disk.percent} />
|
<Gauge value={_metrics.disk.percent} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const VitalsSection = define('VitalsSection', {
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
variants: {
|
variants: {
|
||||||
narrow: {
|
narrow: {
|
||||||
gridTemplateColumns: '1fr',
|
gap: 12,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -24,6 +24,12 @@ export const VitalCard = define('VitalCard', {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
variants: {
|
||||||
|
narrow: {
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const VitalLabel = define('VitalLabel', {
|
export const VitalLabel = define('VitalLabel', {
|
||||||
|
|
@ -60,6 +66,10 @@ export const GaugeValue = define('GaugeValue', {
|
||||||
export const LogsSection = define('LogsSection', {
|
export const LogsSection = define('LogsSection', {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
background: theme('colors-bgElement'),
|
background: theme('colors-bgElement'),
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: theme('radius-md'),
|
||||||
|
|
@ -72,6 +82,7 @@ export const LogsHeader = define('LogsHeader', {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
flexShrink: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const LogsTitle = define('LogsTitle', {
|
export const LogsTitle = define('LogsTitle', {
|
||||||
|
|
@ -133,7 +144,8 @@ export const LogsTab = define('LogsTab', {
|
||||||
})
|
})
|
||||||
|
|
||||||
export const LogsBody = define('LogsBody', {
|
export const LogsBody = define('LogsBody', {
|
||||||
height: 200,
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: theme('fonts-mono'),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
|
||||||
|
|
@ -192,8 +192,10 @@ export const HeaderActions = define('HeaderActions', {
|
||||||
|
|
||||||
export const MainContent = define('MainContent', {
|
export const MainContent = define('MainContent', {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
padding: '10px 24px',
|
padding: '10px 24px',
|
||||||
overflow: 'auto',
|
overflow: 'hidden',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DashboardContainer = define('DashboardContainer', {
|
export const DashboardContainer = define('DashboardContainer', {
|
||||||
|
|
@ -201,14 +203,12 @@ export const DashboardContainer = define('DashboardContainer', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
padding: 40,
|
||||||
paddingTop: 0,
|
|
||||||
gap: 40,
|
gap: 40,
|
||||||
|
overflow: 'hidden',
|
||||||
variants: {
|
variants: {
|
||||||
narrow: {
|
narrow: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
paddingTop: 0,
|
|
||||||
gap: 24,
|
gap: 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ export const LogsContainer = define('LogsContainer', {
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: theme('fonts-mono'),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme('colors-textMuted'),
|
color: theme('colors-textMuted'),
|
||||||
maxHeight: 200,
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
variants: {
|
variants: {
|
||||||
narrow: {
|
narrow: {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,15 @@ export const StatusDotLink = define('StatusDotLink', {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
tooltipVisible: {
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const StatusDotsRow = define('StatusDotsRow', {
|
export const StatusDotsRow = define('StatusDotsRow', {
|
||||||
|
|
@ -143,10 +152,13 @@ export const Tab = define('Tab', {
|
||||||
|
|
||||||
export const TabContent = define('TabContent', {
|
export const TabContent = define('TabContent', {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
|
minHeight: 0,
|
||||||
|
|
||||||
variants: {
|
variants: {
|
||||||
active: {
|
active: {
|
||||||
display: 'block'
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function initToolIframes() {
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL with params using TOES_URL base
|
// Build URL with params using port-based routing
|
||||||
function buildToolUrl(port: number, params: Record<string, string>): string {
|
function buildToolUrl(port: number, params: Record<string, string>): string {
|
||||||
const base = new URL(window.location.origin)
|
const base = new URL(window.location.origin)
|
||||||
base.port = String(port)
|
base.port = String(port)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
|
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
|
||||||
import { disableTunnel, enableTunnel, isTunnelsAvailable } from '../tunnels'
|
import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
|
||||||
import type { App as BackendApp } from '$apps'
|
import type { App as BackendApp } from '$apps'
|
||||||
import type { App as SharedApp } from '@types'
|
import type { App as SharedApp } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
|
|
@ -398,7 +398,7 @@ router.post('/:app/tunnel', c => {
|
||||||
|
|
||||||
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400)
|
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400)
|
||||||
|
|
||||||
enableTunnel(appName, app.port ?? 0)
|
shareApp(appName, app.port ?? 0)
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -409,7 +409,7 @@ router.delete('/:app/tunnel', c => {
|
||||||
const app = allApps().find(a => a.name === appName)
|
const app = allApps().find(a => a.name === appName)
|
||||||
if (!app) return c.json({ error: 'App not found' }, 404)
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
||||||
|
|
||||||
disableTunnel(appName)
|
unshareApp(appName)
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import type { App as SharedApp, AppState } from '@types'
|
||||||
import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
|
import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
|
import { buildAppUrl } from '@urls'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
import { hostname } from 'os'
|
import { hostname } from 'os'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { loadAppEnv } from '../tools/env'
|
import { loadAppEnv } from '../tools/env'
|
||||||
import { disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels'
|
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
||||||
|
import { closeAllTunnels, closeTunnel, openTunnelIfEnabled, renameTunnelConfig, unshareApp } from './tunnels'
|
||||||
import { appLog, hostLog, setApps } from './tui'
|
import { appLog, hostLog, setApps } from './tui'
|
||||||
|
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
@ -129,7 +131,8 @@ export function removeApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app) return
|
if (!app) return
|
||||||
|
|
||||||
disableTunnel(dir)
|
unpublishApp(dir)
|
||||||
|
unshareApp(dir)
|
||||||
|
|
||||||
// Clear all timers
|
// Clear all timers
|
||||||
clearTimers(app)
|
clearTimers(app)
|
||||||
|
|
@ -420,6 +423,8 @@ function getPort(appName?: string): number {
|
||||||
async function gracefulShutdown(signal: string) {
|
async function gracefulShutdown(signal: string) {
|
||||||
if (_shuttingDown) return
|
if (_shuttingDown) return
|
||||||
_shuttingDown = true
|
_shuttingDown = true
|
||||||
|
unpublishAll()
|
||||||
|
closeAllTunnels()
|
||||||
|
|
||||||
hostLog(`Received ${signal}, shutting down gracefully...`)
|
hostLog(`Received ${signal}, shutting down gracefully...`)
|
||||||
|
|
||||||
|
|
@ -521,6 +526,7 @@ function markAsRunning(app: App, port: number, isHttpApp: boolean) {
|
||||||
app.isHttpApp = isHttpApp
|
app.isHttpApp = isHttpApp
|
||||||
update()
|
update()
|
||||||
emit({ type: 'app:start', app: app.name })
|
emit({ type: 'app:start', app: app.name })
|
||||||
|
publishApp(app.name)
|
||||||
openTunnelIfEnabled(app.name, port)
|
openTunnelIfEnabled(app.name, port)
|
||||||
|
|
||||||
if (isHttpApp) {
|
if (isHttpApp) {
|
||||||
|
|
@ -632,7 +638,7 @@ async function runApp(dir: string, port: number) {
|
||||||
|
|
||||||
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR, DATA_DIR: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL },
|
env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APP_URL: buildAppUrl(dir, TOES_URL), APPS_DIR, DATA_DIR: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL },
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
@ -736,6 +742,9 @@ async function runApp(dir: string, port: number) {
|
||||||
writeLogLine(dir, 'system', 'Stopped')
|
writeLogLine(dir, 'system', 'Stopped')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unpublishApp(dir)
|
||||||
|
closeTunnel(dir)
|
||||||
|
|
||||||
// Release port back to pool
|
// Release port back to pool
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
releasePort(app.port)
|
releasePort(app.port)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { allApps, initApps, TOES_URL } from '$apps'
|
import { allApps, initApps, TOES_URL } from '$apps'
|
||||||
|
import { buildAppUrl } from '@urls'
|
||||||
import appsRouter from './api/apps'
|
import appsRouter from './api/apps'
|
||||||
import eventsRouter from './api/events'
|
import eventsRouter from './api/events'
|
||||||
import syncRouter from './api/sync'
|
import syncRouter from './api/sync'
|
||||||
import systemRouter from './api/system'
|
import systemRouter from './api/system'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
|
import { cleanupStalePublishers } from './mdns'
|
||||||
|
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||||
|
|
||||||
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
|
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
|
||||||
|
|
||||||
|
|
@ -12,7 +15,7 @@ app.route('/api/events', eventsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
app.route('/api/system', systemRouter)
|
app.route('/api/system', systemRouter)
|
||||||
|
|
||||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
|
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
||||||
app.get('/tool/:tool', c => {
|
app.get('/tool/:tool', c => {
|
||||||
const toolName = c.req.param('tool')
|
const toolName = c.req.param('tool')
|
||||||
const tool = allApps().find(a => a.tool && a.name === toolName)
|
const tool = allApps().find(a => a.tool && a.name === toolName)
|
||||||
|
|
@ -20,9 +23,8 @@ app.get('/tool/:tool', c => {
|
||||||
return c.text(`Tool "${toolName}" not found or not running`, 404)
|
return c.text(`Tool "${toolName}" not found or not running`, 404)
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams(c.req.query()).toString()
|
const params = new URLSearchParams(c.req.query()).toString()
|
||||||
const base = new URL(TOES_URL)
|
const base = buildAppUrl(toolName, TOES_URL)
|
||||||
base.port = String(tool.port)
|
const url = params ? `${base}?${params}` : base
|
||||||
const url = params ? `${base.origin}?${params}` : base.origin
|
|
||||||
return c.redirect(url)
|
return c.redirect(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -55,9 +57,23 @@ app.all('/api/tools/:tool/:path{.+}', async c => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
cleanupStalePublishers()
|
||||||
await initApps()
|
await initApps()
|
||||||
|
|
||||||
|
const defaults = app.defaults
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...app.defaults,
|
...defaults,
|
||||||
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
||||||
|
fetch(req: Request, server: any) {
|
||||||
|
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
||||||
|
if (subdomain) {
|
||||||
|
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||||
|
return proxyWebSocket(subdomain, req, server)
|
||||||
|
}
|
||||||
|
return proxySubdomain(subdomain, req)
|
||||||
|
}
|
||||||
|
return defaults.fetch.call(app, req, server)
|
||||||
|
},
|
||||||
|
websocket,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/server/mdns.ts
Normal file
81
src/server/mdns.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { Subprocess } from 'bun'
|
||||||
|
import { networkInterfaces } from 'os'
|
||||||
|
import { hostLog } from './tui'
|
||||||
|
|
||||||
|
const _publishers = new Map<string, Subprocess>()
|
||||||
|
|
||||||
|
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
|
||||||
|
|
||||||
|
function getLocalIp(): string | null {
|
||||||
|
const interfaces = networkInterfaces()
|
||||||
|
for (const iface of Object.values(interfaces)) {
|
||||||
|
if (!iface) continue
|
||||||
|
for (const addr of iface) {
|
||||||
|
if (addr.family === 'IPv4' && !addr.internal) {
|
||||||
|
return addr.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupStalePublishers() {
|
||||||
|
if (!isEnabled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishApp(name: string) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
if (_publishers.has(name)) return
|
||||||
|
|
||||||
|
const ip = getLocalIp()
|
||||||
|
if (!ip) {
|
||||||
|
hostLog(`mDNS: no local IP found, skipping ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = `${name}.toes.local`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
_publishers.set(name, proc)
|
||||||
|
hostLog(`mDNS: published ${hostname} -> ${ip}`)
|
||||||
|
|
||||||
|
proc.exited.then(() => {
|
||||||
|
_publishers.delete(name)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
hostLog(`mDNS: failed to publish ${hostname}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpublishApp(name: string) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
|
||||||
|
const proc = _publishers.get(name)
|
||||||
|
if (!proc) return
|
||||||
|
|
||||||
|
proc.kill()
|
||||||
|
_publishers.delete(name)
|
||||||
|
hostLog(`mDNS: unpublished ${name}.toes.local`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpublishAll() {
|
||||||
|
if (!isEnabled) return
|
||||||
|
|
||||||
|
for (const [name, proc] of _publishers) {
|
||||||
|
proc.kill()
|
||||||
|
hostLog(`mDNS: unpublished ${name}.toes.local`)
|
||||||
|
}
|
||||||
|
_publishers.clear()
|
||||||
|
}
|
||||||
50
src/server/proxy.test.ts
Normal file
50
src/server/proxy.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { extractSubdomain } from './proxy'
|
||||||
|
|
||||||
|
describe('extractSubdomain', () => {
|
||||||
|
describe('*.localhost (dev)', () => {
|
||||||
|
test('should extract subdomain from clock.localhost:3000', () => {
|
||||||
|
expect(extractSubdomain('clock.localhost:3000')).toBe('clock')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should extract subdomain from clock.localhost', () => {
|
||||||
|
expect(extractSubdomain('clock.localhost')).toBe('clock')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for bare localhost:3000', () => {
|
||||||
|
expect(extractSubdomain('localhost:3000')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for bare localhost', () => {
|
||||||
|
expect(extractSubdomain('localhost')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle hyphenated app names', () => {
|
||||||
|
expect(extractSubdomain('my-app.localhost:3000')).toBe('my-app')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('*.toes.local (production)', () => {
|
||||||
|
test('should extract subdomain from clock.toes.local', () => {
|
||||||
|
expect(extractSubdomain('clock.toes.local')).toBe('clock')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for bare toes.local', () => {
|
||||||
|
expect(extractSubdomain('toes.local')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle hyphenated app names', () => {
|
||||||
|
expect(extractSubdomain('my-app.toes.local')).toBe('my-app')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('other hosts', () => {
|
||||||
|
test('should return null for plain IP', () => {
|
||||||
|
expect(extractSubdomain('192.168.1.50:3000')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for empty string', () => {
|
||||||
|
expect(extractSubdomain('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
105
src/server/proxy.ts
Normal file
105
src/server/proxy.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import type { ServerWebSocket } from 'bun'
|
||||||
|
import { getApp } from '$apps'
|
||||||
|
|
||||||
|
const upstreams = new Map<ServerWebSocket<WsData>, WebSocket>()
|
||||||
|
|
||||||
|
interface WsData {
|
||||||
|
port: number
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSubdomain(host: string): string | null {
|
||||||
|
// Strip port
|
||||||
|
const hostname = host.replace(/:\d+$/, '')
|
||||||
|
|
||||||
|
// *.localhost -> take prefix (e.g. clock.localhost -> clock)
|
||||||
|
if (hostname.endsWith('.localhost')) {
|
||||||
|
const sub = hostname.slice(0, -'.localhost'.length)
|
||||||
|
return sub || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// *.X.local -> take first segment if 3+ parts (e.g. clock.toes.local -> clock)
|
||||||
|
if (hostname.endsWith('.local')) {
|
||||||
|
const parts = hostname.split('.')
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
return parts[0]!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
|
||||||
|
const app = getApp(subdomain)
|
||||||
|
|
||||||
|
if (!app || app.state !== 'running' || !app.port) {
|
||||||
|
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const target = `http://localhost:${app.port}${url.pathname}${url.search}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
body: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Proxy error for ${subdomain}:`, e)
|
||||||
|
return new Response(`App "${subdomain}" is not responding`, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxyWebSocket(subdomain: string, req: Request, server: any): Response | undefined {
|
||||||
|
const app = getApp(subdomain)
|
||||||
|
|
||||||
|
if (!app || app.state !== 'running' || !app.port) {
|
||||||
|
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const path = url.pathname + url.search
|
||||||
|
|
||||||
|
const ok = server.upgrade(req, { data: { port: app.port, path } as WsData })
|
||||||
|
if (ok) return undefined
|
||||||
|
return new Response('WebSocket upgrade failed', { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const websocket = {
|
||||||
|
open(ws: ServerWebSocket<WsData>) {
|
||||||
|
const { port, path } = ws.data
|
||||||
|
const upstream = new WebSocket(`ws://localhost:${port}${path}`)
|
||||||
|
|
||||||
|
upstream.binaryType = 'arraybuffer'
|
||||||
|
upstreams.set(ws, upstream)
|
||||||
|
|
||||||
|
upstream.addEventListener('message', e => {
|
||||||
|
ws.send(e.data as string | ArrayBuffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.addEventListener('close', () => {
|
||||||
|
upstreams.delete(ws)
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.addEventListener('error', () => {
|
||||||
|
upstreams.delete(ws)
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws: ServerWebSocket<WsData>, msg: string | ArrayBuffer | Uint8Array) {
|
||||||
|
const upstream = upstreams.get(ws)
|
||||||
|
if (!upstream || upstream.readyState !== WebSocket.OPEN) return
|
||||||
|
upstream.send(msg)
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws: ServerWebSocket<WsData>) {
|
||||||
|
const upstream = upstreams.get(ws)
|
||||||
|
if (upstream) {
|
||||||
|
upstream.close()
|
||||||
|
upstreams.delete(ws)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ export function closeTunnel(appName: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disableTunnel(appName: string) {
|
export function unshareApp(appName: string) {
|
||||||
closeTunnel(appName)
|
closeTunnel(appName)
|
||||||
|
|
||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
|
|
@ -99,7 +99,7 @@ export function disableTunnel(appName: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enableTunnel(appName: string, port: number) {
|
export function shareApp(appName: string, port: number) {
|
||||||
if (!SNEAKER_URL) return
|
if (!SNEAKER_URL) return
|
||||||
|
|
||||||
const app = getApp(appName)
|
const app = getApp(appName)
|
||||||
|
|
@ -200,7 +200,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
|
||||||
_tunnels.delete(appName)
|
_tunnels.delete(appName)
|
||||||
_tunnelPorts.delete(appName)
|
_tunnelPorts.delete(appName)
|
||||||
|
|
||||||
// Intentional close (disableTunnel, closeAllTunnels, etc.) — don't reconnect
|
// Intentional close (unshareApp, closeAllTunnels, etc.) — don't reconnect
|
||||||
if (_closing.delete(appName)) {
|
if (_closing.delete(appName)) {
|
||||||
hostLog(`Tunnel closed: ${appName}`)
|
hostLog(`Tunnel closed: ${appName}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
5
src/shared/urls.ts
Normal file
5
src/shared/urls.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function buildAppUrl(appName: string, baseUrl: string): string {
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
url.hostname = `${appName}.${url.hostname}`
|
||||||
|
return url.origin
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user