Compare commits
3 Commits
d89a58c0ab
...
7c04aceef9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c04aceef9 | |||
| 47030d7d36 | |||
| 081c728d12 |
|
|
@ -438,9 +438,11 @@ app.get('/job/:app/:name', async c => {
|
||||||
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
|
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
|
||||||
<BackLink href={backUrl}>← Back</BackLink>
|
<BackLink href={backUrl}>← Back</BackLink>
|
||||||
<DetailHeader>
|
<DetailHeader>
|
||||||
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||||
<DetailTitle>{job.app}/{job.name}</DetailTitle>
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
||||||
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
|
<DetailTitle>{job.app}/{job.name}</DetailTitle>
|
||||||
|
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
|
||||||
|
</div>
|
||||||
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
|
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
|
||||||
<RunButton type="submit" disabled={job.state === 'running'}>
|
<RunButton type="submit" disabled={job.state === 'running'}>
|
||||||
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
||||||
|
|
@ -449,8 +451,8 @@ app.get('/job/:app/:name', async c => {
|
||||||
</DetailHeader>
|
</DetailHeader>
|
||||||
<DetailMeta>
|
<DetailMeta>
|
||||||
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
|
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
|
||||||
<MetaItem><MetaLabel>Last run</MetaLabel> {formatRelative(job.lastRun)}</MetaItem>
|
<MetaItem><MetaLabel>Last run</MetaLabel> {job.state === 'running' ? 'now' : formatRelative(job.lastRun)}</MetaItem>
|
||||||
<MetaItem><MetaLabel>Duration</MetaLabel> {formatDuration(job.lastDuration)}</MetaItem>
|
<MetaItem><MetaLabel>Duration</MetaLabel> {job.state === 'running' ? formatDuration(Date.now() - job.lastRun!) : formatDuration(job.lastDuration)}</MetaItem>
|
||||||
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
|
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
|
||||||
</DetailMeta>
|
</DetailMeta>
|
||||||
{job.lastError && (
|
{job.lastError && (
|
||||||
|
|
@ -459,17 +461,21 @@ app.get('/job/:app/:name', async c => {
|
||||||
<ErrorBlock>{job.lastError}</ErrorBlock>
|
<ErrorBlock>{job.lastError}</ErrorBlock>
|
||||||
</OutputSection>
|
</OutputSection>
|
||||||
)}
|
)}
|
||||||
{job.lastOutput && (
|
{job.lastOutput ? (
|
||||||
<OutputSection>
|
<OutputSection>
|
||||||
<OutputLabel>Output</OutputLabel>
|
<OutputLabel>Output</OutputLabel>
|
||||||
<OutputBlock>{job.lastOutput}</OutputBlock>
|
<OutputBlock>{job.lastOutput}</OutputBlock>
|
||||||
</OutputSection>
|
</OutputSection>
|
||||||
)}
|
) : job.state === 'running' ? (
|
||||||
{!job.lastError && !job.lastOutput && job.lastRun && (
|
<OutputSection>
|
||||||
|
<OutputLabel>Output</OutputLabel>
|
||||||
|
<OutputBlock style={{ color: theme('colors-textMuted') }}>Waiting for output...</OutputBlock>
|
||||||
|
</OutputSection>
|
||||||
|
) : job.lastRun && !job.lastError ? (
|
||||||
<OutputSection>
|
<OutputSection>
|
||||||
<EmptyState>No output</EmptyState>
|
<EmptyState>No output</EmptyState>
|
||||||
</OutputSection>
|
</OutputSection>
|
||||||
)}
|
) : null}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { loadAppEnv } from '@because/toes/tools'
|
||||||
import type { CronJob } from './schedules'
|
import type { CronJob } from './schedules'
|
||||||
import { getNextRun } from './scheduler'
|
import { getNextRun } from './scheduler'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const TOES_DIR = process.env.TOES_DIR!
|
||||||
const RUNNER = join(import.meta.dir, 'runner.ts')
|
const RUNNER = join(import.meta.dir, 'runner.ts')
|
||||||
|
|
||||||
|
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
append(decoder.decode(value, { stream: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
||||||
if (job.state === 'disabled') return
|
if (job.state === 'disabled') return
|
||||||
|
|
||||||
job.state = 'running'
|
job.state = 'running'
|
||||||
job.lastRun = Date.now()
|
job.lastRun = Date.now()
|
||||||
|
job.lastOutput = undefined
|
||||||
|
job.lastError = undefined
|
||||||
|
job.lastExitCode = undefined
|
||||||
|
job.lastDuration = undefined
|
||||||
onUpdate()
|
onUpdate()
|
||||||
|
|
||||||
const cwd = join(APPS_DIR, job.app, 'current')
|
const cwd = join(APPS_DIR, job.app, 'current')
|
||||||
|
|
@ -17,29 +33,35 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env },
|
env: { ...process.env, ...loadAppEnv(job.app), DATA_DIR: join(TOES_DIR, job.app) },
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
||||||
const [stdout, stderr] = await Promise.all([
|
// Stream output incrementally into job fields
|
||||||
new Response(proc.stdout).text(),
|
await Promise.all([
|
||||||
new Response(proc.stderr).text(),
|
readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
|
||||||
|
job.lastOutput = (job.lastOutput || '') + text
|
||||||
|
}),
|
||||||
|
readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
|
||||||
|
job.lastError = (job.lastError || '') + text
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const code = await proc.exited
|
const code = await proc.exited
|
||||||
|
|
||||||
job.lastDuration = Date.now() - job.lastRun
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
job.lastExitCode = code
|
job.lastExitCode = code
|
||||||
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
if (!job.lastError && code !== 0) job.lastError = 'Non-zero exit'
|
||||||
job.lastOutput = stdout || undefined
|
if (code === 0) job.lastError = undefined
|
||||||
|
if (!job.lastOutput) job.lastOutput = undefined
|
||||||
job.state = 'idle'
|
job.state = 'idle'
|
||||||
job.nextRun = getNextRun(job.id)
|
job.nextRun = getNextRun(job.id)
|
||||||
|
|
||||||
// Log result
|
// Log result
|
||||||
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
|
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
|
||||||
if (stdout) console.log(stdout)
|
if (job.lastOutput) console.log(job.lastOutput)
|
||||||
if (stderr) console.error(stderr)
|
if (job.lastError) console.error(job.lastError)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
job.lastDuration = Date.now() - job.lastRun
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
job.lastExitCode = 1
|
job.lastExitCode = 1
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@ source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# SSH to target and update
|
# SSH to target: pull, build, sync apps, restart
|
||||||
ssh "$HOST" "cd $DEST && git pull origin main && bun run build"
|
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$DEST" && git pull origin main && bun run build
|
||||||
|
|
||||||
# Sync default apps/tools from repo to APPS_DIR
|
|
||||||
echo "=> Syncing default apps..."
|
echo "=> Syncing default apps..."
|
||||||
ssh "$HOST" bash -s "$DEST" "$APPS_DIR" <<'SCRIPT'
|
|
||||||
DEST="$1"
|
|
||||||
APPS_DIR="$2"
|
|
||||||
for app_dir in "$DEST"/apps/*/; do
|
for app_dir in "$DEST"/apps/*/; do
|
||||||
app=$(basename "$app_dir")
|
app=$(basename "$app_dir")
|
||||||
for version_dir in "$app_dir"*/; do
|
for version_dir in "$app_dir"*/; do
|
||||||
|
|
@ -32,9 +31,9 @@ for app_dir in "$DEST"/apps/*/; do
|
||||||
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
|
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
SCRIPT
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl restart toes.service"
|
sudo systemctl restart toes.service
|
||||||
|
SCRIPT
|
||||||
|
|
||||||
echo "=> Deployed to $HOST"
|
echo "=> Deployed to $HOST"
|
||||||
echo "=> Visit $URL"
|
echo "=> Visit $URL"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
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 { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
|
import { loadAppEnv } from '../tools/env'
|
||||||
import { appLog, hostLog, setApps } from './tui'
|
import { appLog, hostLog, setApps } from './tui'
|
||||||
|
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
@ -495,34 +496,6 @@ function loadApp(dir: string): LoadResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAppEnv(appName: string): Record<string, string> {
|
|
||||||
const envDir = join(TOES_DIR, 'env')
|
|
||||||
const env: Record<string, string> = {}
|
|
||||||
|
|
||||||
const parseEnvFile = (path: string) => {
|
|
||||||
if (!existsSync(path)) return
|
|
||||||
const content = readFileSync(path, 'utf-8')
|
|
||||||
for (const line of content.split('\n')) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue
|
|
||||||
const eqIndex = trimmed.indexOf('=')
|
|
||||||
if (eqIndex === -1) continue
|
|
||||||
const key = trimmed.slice(0, eqIndex).trim()
|
|
||||||
let value = trimmed.slice(eqIndex + 1).trim()
|
|
||||||
// Remove surrounding quotes
|
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
||||||
(value.startsWith("'") && value.endsWith("'"))) {
|
|
||||||
value = value.slice(1, -1)
|
|
||||||
}
|
|
||||||
if (key) env[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parseEnvFile(join(envDir, '_global.env'))
|
|
||||||
parseEnvFile(join(envDir, `${appName}.env`))
|
|
||||||
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeResetBackoff(app: App) {
|
function maybeResetBackoff(app: App) {
|
||||||
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
|
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
|
||||||
|
|
@ -599,7 +572,7 @@ async function runApp(dir: string, port: number) {
|
||||||
info(app, `Starting on port ${port}...`)
|
info(app, `Starting on port ${port}...`)
|
||||||
|
|
||||||
// Load env vars from TOES_DIR/env/
|
// Load env vars from TOES_DIR/env/
|
||||||
const appEnv = loadAppEnv(dir)
|
const appEnv = loadAppEnv(dir, TOES_DIR)
|
||||||
|
|
||||||
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
||||||
cwd,
|
cwd,
|
||||||
|
|
|
||||||
30
src/tools/env.ts
Normal file
30
src/tools/env.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function loadAppEnv(appName: string, toesDir?: string): Record<string, string> {
|
||||||
|
const envDir = join(toesDir || process.env.TOES_DIR!, 'env')
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
|
||||||
|
const parseEnvFile = (path: string) => {
|
||||||
|
if (!existsSync(path)) return
|
||||||
|
const content = readFileSync(path, 'utf-8')
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const eqIndex = trimmed.indexOf('=')
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
const key = trimmed.slice(0, eqIndex).trim()
|
||||||
|
let value = trimmed.slice(eqIndex + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (key) env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseEnvFile(join(envDir, '_global.env'))
|
||||||
|
parseEnvFile(join(envDir, `${appName}.env`))
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { theme } from '../client/themes'
|
export { theme } from '../client/themes'
|
||||||
|
export { loadAppEnv } from './env'
|
||||||
export { baseStyles, ToolScript } from './scripts.tsx'
|
export { baseStyles, ToolScript } from './scripts.tsx'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user