production is a static build behind nginx, not a process
The site's own build script says it outright: "The public site is not served by Bun in production — it is a directory of files behind nginx. Output: dist/." So production has no port, nothing to restart, and nothing that can crash at three in the morning. It is built or stale, and served or not served. Showing it as a pm2 process was showing something that does not exist, and its Start button could only ever have failed. It now has Build, Serve and Stop, and the two states are reported separately — a build can succeed while nothing serves it, and a container can serve a directory built weeks ago. They fail independently and are fixed differently. The container exists because nginx could not see the files. NPM mounts only its own /data and /etc/letsencrypt, and there is no other nginx on the box. The alternatives were putting site files inside NPM's own volume, or editing NPM's compose and restarting it — which takes every site on this machine down for a moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with NO published port. NPM reaches it by name, and nothing about the site is exposed except through the proxy. Recreated rather than restarted, for the same reason pm2 is: a container keeps the mounts it was created with, so a root location changed here would be saved, displayed, and still serving the old directory. Domains follows. Production's forward target is a container NAME, not the docker gateway that reaches a host process — and matching what already points at it uses the full target rather than the port, since every other site behind this NPM on port 80 would otherwise be listed as fofos's. The compiler found the dead branches this left in startServer once production could no longer reach it. Removed rather than left to read as though the case were still possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -11,7 +11,8 @@ import { reconcile } from './derive';
|
|||||||
import { createDatabases, pushSchema, readDatabases } from './databases';
|
import { createDatabases, pushSchema, readDatabases } from './databases';
|
||||||
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
|
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
|
||||||
import { watchMailbox } from './messages';
|
import { watchMailbox } from './messages';
|
||||||
import { readServers, savePorts, startServer, stopServer } from './servers';
|
import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers';
|
||||||
|
import { buildSite, readStatic, startStatic, stopStatic } from './static-site';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||||
|
|
||||||
@@ -422,6 +423,37 @@ router.put('/_config/servers', async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production — a static build, and the nginx that serves it.
|
||||||
|
*
|
||||||
|
* Separate routes from the servers above because it is a different kind of thing: there is no process,
|
||||||
|
* no port and nothing to restart. A build either succeeded or did not, and a container is either
|
||||||
|
* serving the directory or is not.
|
||||||
|
*/
|
||||||
|
router.get('/_config/static', async (ctx) => ctx.json(await readStatic()));
|
||||||
|
|
||||||
|
router.post('/_config/static/build', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const result = await buildSite(await siteSecrets());
|
||||||
|
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
|
||||||
|
} catch (error) {
|
||||||
|
return ctx.json({ error: (error as Error).message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/_config/static/:action', async (ctx) => {
|
||||||
|
const action = ctx.req.param('action');
|
||||||
|
if (action !== 'start' && action !== 'stop') {
|
||||||
|
return ctx.json({ error: 'Action must be start or stop.' }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = action === 'start' ? await startStatic() : await stopStatic();
|
||||||
|
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
|
||||||
|
} catch (error) {
|
||||||
|
return ctx.json({ error: (error as Error).message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/_config/servers/:which/:action', async (ctx) => {
|
router.post('/_config/servers/:which/:action', async (ctx) => {
|
||||||
const which = ctx.req.param('which');
|
const which = ctx.req.param('which');
|
||||||
const action = ctx.req.param('action');
|
const action = ctx.req.param('action');
|
||||||
|
|||||||
+27
-6
@@ -7,7 +7,17 @@ import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
|||||||
import { readDatabases } from './databases';
|
import { readDatabases } from './databases';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The site's two servers — development and production — run under pm2.
|
* The site's DEVELOPMENT server, under pm2.
|
||||||
|
*
|
||||||
|
* ── Production is not here, and that is the point ──
|
||||||
|
*
|
||||||
|
* The site's own build script says the public site is a directory of files behind nginx, not a Bun
|
||||||
|
* process. So production is a build and a web server with no application in it — see `static-site.ts`.
|
||||||
|
* This module is development only: `bun --hot`, which is exactly the case pm2 is right for.
|
||||||
|
*
|
||||||
|
* The two names remain, because a caller asking about `production` should get a real answer rather than
|
||||||
|
* a 400, and because the panel still shows two rows. `production` here reports the pm2 process that no
|
||||||
|
* longer exists — which is correct, and is why `startServer` refuses it.
|
||||||
*
|
*
|
||||||
* ── pm2 for BOTH, including development ──
|
* ── pm2 for BOTH, including development ──
|
||||||
*
|
*
|
||||||
@@ -74,7 +84,7 @@ const PURPOSE = 'fofos-admin';
|
|||||||
* Regenerating on every start would be worse still — it would invalidate every session and every token
|
* Regenerating on every start would be worse still — it would invalidate every session and every token
|
||||||
* anybody had written down.
|
* anybody had written down.
|
||||||
*/
|
*/
|
||||||
async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> {
|
export async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> {
|
||||||
const row = await readRow();
|
const row = await readRow();
|
||||||
const existing = {
|
const existing = {
|
||||||
apiToken: row?.siteApiToken ? decryptSecret(PURPOSE, row.siteApiToken) : null,
|
apiToken: row?.siteApiToken ? decryptSecret(PURPOSE, row.siteApiToken) : null,
|
||||||
@@ -226,15 +236,25 @@ export async function savePorts(dev: unknown, prod: unknown): Promise<ServerStat
|
|||||||
* override what somebody chose.
|
* override what somebody chose.
|
||||||
*/
|
*/
|
||||||
export async function startServer(which: ServerName): Promise<{ ok: boolean; output: string }> {
|
export async function startServer(which: ServerName): Promise<{ ok: boolean; output: string }> {
|
||||||
|
/*
|
||||||
|
Production has no process to start.
|
||||||
|
|
||||||
|
Refused here rather than in the route, so there is one place that knows it — a check in the panel
|
||||||
|
alone would be a rule the API did not have, and the API is what an agent reaches.
|
||||||
|
*/
|
||||||
|
if (which === 'production') {
|
||||||
|
throw new Error('Production is a static build served by nginx, not a process. Build it instead.');
|
||||||
|
}
|
||||||
|
|
||||||
const row = await readRow();
|
const row = await readRow();
|
||||||
if (!row?.rootPath) throw new Error('No root location is set.');
|
if (!row?.rootPath) throw new Error('No root location is set.');
|
||||||
|
|
||||||
const port = which === 'development' ? row.devPort : row.prodPort;
|
const port = row.devPort;
|
||||||
if (!port) throw new Error(`No ${which} port is set.`);
|
if (!port) throw new Error(`No ${which} port is set.`);
|
||||||
|
|
||||||
const websiteDir = join(row.rootPath, 'website');
|
const websiteDir = join(row.rootPath, 'website');
|
||||||
const databases = await readDatabases();
|
const databases = await readDatabases();
|
||||||
const target = which === 'development' ? databases.development : databases.production;
|
const target = databases.development;
|
||||||
if (!target.name || !target.exists) throw new Error(`The ${which} database does not exist yet.`);
|
if (!target.name || !target.exists) throw new Error(`The ${which} database does not exist yet.`);
|
||||||
|
|
||||||
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
||||||
@@ -265,7 +285,8 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out
|
|||||||
// to whatever the repository shipped with. Same reasoning as the schema push.
|
// to whatever the repository shipped with. Same reasoning as the schema push.
|
||||||
...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}),
|
...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}),
|
||||||
...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}),
|
...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}),
|
||||||
...(which === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }),
|
// Only development reaches here — production is refused above — so this is always the dev URL.
|
||||||
|
POSTGRES_URL_DEV: url.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Removed first so a stale process cannot survive with the old environment. Failure is expected and
|
// Removed first so a stale process cannot survive with the old environment. Failure is expected and
|
||||||
@@ -283,7 +304,7 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out
|
|||||||
websiteDir,
|
websiteDir,
|
||||||
'--',
|
'--',
|
||||||
'run',
|
'run',
|
||||||
which === 'development' ? 'dev' : 'start',
|
'dev',
|
||||||
],
|
],
|
||||||
120_000,
|
120_000,
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { stat, readdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { fofosAdminSettings } from '../db/schema';
|
||||||
|
import { readDatabases } from './databases';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production: a directory of files behind nginx, not a running server.
|
||||||
|
*
|
||||||
|
* ── Why production is not a process ──
|
||||||
|
*
|
||||||
|
* The site's own build script says it plainly: *"The public site is not served by Bun in production —
|
||||||
|
* it is a directory of files behind nginx."* `bun run build:site` walks every route, renders it through
|
||||||
|
* the same Hono app that serves development, and writes `dist/`. So production has no port, nothing to
|
||||||
|
* restart, and nothing that can crash at three in the morning — it is a build that either succeeded or
|
||||||
|
* did not, and a web server with no application in it.
|
||||||
|
*
|
||||||
|
* Development stays a pm2 process, because that is what `bun --hot` is for.
|
||||||
|
*
|
||||||
|
* ── The container exists because nginx could not see the files ──
|
||||||
|
*
|
||||||
|
* Nginx Proxy Manager mounts only its own `/data` and `/etc/letsencrypt`. It cannot read `dist`, and
|
||||||
|
* there is no other nginx on the machine. The alternatives were putting site files inside NPM's own
|
||||||
|
* volume, or editing NPM's compose and restarting it — which takes every site on the box down for a
|
||||||
|
* moment.
|
||||||
|
*
|
||||||
|
* So: one `nginx:alpine`, `dist` mounted READ-ONLY, on the same docker network as NPM and with **no
|
||||||
|
* published port**. NPM reaches it by container name. Nothing about the site is exposed except through
|
||||||
|
* the proxy, which is the same property every other part of this plugin depends on.
|
||||||
|
*
|
||||||
|
* ── This plugin owns exactly one container name ──
|
||||||
|
*
|
||||||
|
* `fofos-web`. Every command here is built from that constant and from nothing a caller supplies, so no
|
||||||
|
* request can stop or remove another container on this machine — the same rule the pm2 side follows,
|
||||||
|
* and it matters for the same reason: Officer's own services share this daemon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONTAINER = 'fofos-web';
|
||||||
|
const IMAGE = 'nginx:alpine';
|
||||||
|
|
||||||
|
/** The network NPM is on, so it can reach the container by name without a published port. */
|
||||||
|
const NETWORK = 'fofos';
|
||||||
|
|
||||||
|
const DOCKER = '/usr/bin/docker';
|
||||||
|
|
||||||
|
export type StaticStatus = {
|
||||||
|
/** `<root>/website/dist`. */
|
||||||
|
path: string;
|
||||||
|
/** The build has produced something. */
|
||||||
|
built: boolean;
|
||||||
|
/** Pages written — top-level entries, which is close enough to be useful and cheap to count. */
|
||||||
|
entries: number | null;
|
||||||
|
/** When the build last wrote. */
|
||||||
|
builtAt: string | null;
|
||||||
|
container: {
|
||||||
|
name: string;
|
||||||
|
/** docker's own word: `running`, `exited`, or null when it does not exist. */
|
||||||
|
state: string | null;
|
||||||
|
/** What NPM should forward to. Only meaningful while it is running. */
|
||||||
|
forward: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
|
||||||
|
|
||||||
|
export const distPath = (root: string): string => join(root, 'website', 'dist');
|
||||||
|
|
||||||
|
const run = async (argv: string[], timeoutMs = 60_000, env?: Record<string, string>) => {
|
||||||
|
const proc = Bun.spawn(argv, {
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
stdin: 'ignore',
|
||||||
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => proc.kill(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
return { ok: code === 0, output: `${stdout}${stderr}`.trim() };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readStatic(): Promise<StaticStatus> {
|
||||||
|
const row = await readRow();
|
||||||
|
const path = row?.rootPath ? distPath(row.rootPath) : '';
|
||||||
|
|
||||||
|
const info = path ? await stat(path).catch(() => null) : null;
|
||||||
|
const entries = info?.isDirectory() ? await readdir(path).catch(() => null) : null;
|
||||||
|
|
||||||
|
const inspected = await run([DOCKER, 'inspect', '-f', '{{.State.Status}}', CONTAINER], 20_000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
built: Boolean(info?.isDirectory() && entries?.length),
|
||||||
|
entries: entries?.length ?? null,
|
||||||
|
builtAt: info?.mtime.toISOString() ?? null,
|
||||||
|
container: {
|
||||||
|
name: CONTAINER,
|
||||||
|
// `docker inspect` fails when there is no such container, which is "not created" rather than an
|
||||||
|
// error — a state to report, not a fault to raise.
|
||||||
|
state: inspected.ok ? inspected.output.trim() : null,
|
||||||
|
forward: `${CONTAINER}:80`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the site.
|
||||||
|
*
|
||||||
|
* `FOFOS_ENV=production`, so the site's own guard reads the production database and refuses if the
|
||||||
|
* name disagrees — the same check the schema push goes through, and for the same reason. The build
|
||||||
|
* reads content, so pointing it at the wrong database would publish the wrong site rather than fail.
|
||||||
|
*
|
||||||
|
* It renders every route through the real app, so it needs everything the server needs: the database,
|
||||||
|
* the media directory, and the secrets. Assembled here for the same reason the dev server's are.
|
||||||
|
*/
|
||||||
|
export async function buildSite(secrets: {
|
||||||
|
apiToken: string;
|
||||||
|
sessionSecret: string;
|
||||||
|
}): Promise<{ ok: boolean; output: string }> {
|
||||||
|
const row = await readRow();
|
||||||
|
if (!row?.rootPath) throw new Error('No root location is set.');
|
||||||
|
|
||||||
|
const websiteDir = join(row.rootPath, 'website');
|
||||||
|
const databases = await readDatabases();
|
||||||
|
if (!databases.production.name || !databases.production.exists) {
|
||||||
|
throw new Error('The production database does not exist yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
||||||
|
if (!raw) throw new Error('The platform has no Postgres connection configured.');
|
||||||
|
const url = new URL(raw);
|
||||||
|
url.pathname = `/${databases.production.name}`;
|
||||||
|
|
||||||
|
const result = await run(
|
||||||
|
['bun', 'run', 'build:site'],
|
||||||
|
15 * 60 * 1000,
|
||||||
|
{
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
FOFOS_ENV: 'production',
|
||||||
|
POSTGRES_URL: url.toString(),
|
||||||
|
...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}),
|
||||||
|
...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}),
|
||||||
|
FOFOS_API_TOKEN: secrets.apiToken,
|
||||||
|
ADMIN_SESSION_SECRET: secrets.sessionSecret,
|
||||||
|
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
|
||||||
|
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
|
||||||
|
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
|
||||||
|
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
||||||
|
API_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: result.ok, output: `$ bun run build:site (FOFOS_ENV=production)\n${result.output}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or replace the web server.
|
||||||
|
*
|
||||||
|
* Removed and recreated rather than restarted, for the same reason the pm2 side does it: a container
|
||||||
|
* keeps the mounts and the network it was created with, so a root location changed here would be saved,
|
||||||
|
* shown, and still serving the old directory.
|
||||||
|
*
|
||||||
|
* `--restart unless-stopped`, so it comes back with the machine. No published port: NPM reaches it on
|
||||||
|
* the shared network by name, and a port on the host would be the site available unauthenticated beside
|
||||||
|
* the proxy that exists to gate it.
|
||||||
|
*/
|
||||||
|
export async function startStatic(): Promise<{ ok: boolean; output: string }> {
|
||||||
|
const row = await readRow();
|
||||||
|
if (!row?.rootPath) throw new Error('No root location is set.');
|
||||||
|
|
||||||
|
const dist = distPath(row.rootPath);
|
||||||
|
if (!(await stat(dist).catch(() => null))) throw new Error('There is no dist directory yet — build first.');
|
||||||
|
|
||||||
|
// Expected to fail the first time; the usual case is that it was never there.
|
||||||
|
await run([DOCKER, 'rm', '-f', CONTAINER], 30_000);
|
||||||
|
|
||||||
|
const created = await run(
|
||||||
|
[
|
||||||
|
DOCKER, 'run', '-d',
|
||||||
|
'--name', CONTAINER,
|
||||||
|
'--restart', 'unless-stopped',
|
||||||
|
'--network', NETWORK,
|
||||||
|
// Read-only: nginx serves this and has no business writing to somebody's build output.
|
||||||
|
'-v', `${dist}:/usr/share/nginx/html:ro`,
|
||||||
|
IMAGE,
|
||||||
|
],
|
||||||
|
120_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: created.ok, output: `$ docker run ${CONTAINER} (${dist} -> /usr/share/nginx/html, network ${NETWORK})\n${created.output}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopStatic(): Promise<{ ok: boolean; output: string }> {
|
||||||
|
const result = await run([DOCKER, 'rm', '-f', CONTAINER], 60_000);
|
||||||
|
return { ok: result.ok, output: `$ docker rm -f ${CONTAINER}\n${result.output}` };
|
||||||
|
}
|
||||||
+48
-6
@@ -73,6 +73,14 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
|||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const [hosts, setHosts] = useState<Host[] | null>(null);
|
const [hosts, setHosts] = useState<Host[] | null>(null);
|
||||||
|
/*
|
||||||
|
Production is not a port on this machine.
|
||||||
|
|
||||||
|
It is a container on the same docker network as NPM, so the forward target is a NAME — `fofos-web:80`
|
||||||
|
— and the gateway address that reaches a host process would reach nothing. Read here rather than
|
||||||
|
passed down, because the shape of production's target is this block's business.
|
||||||
|
*/
|
||||||
|
const [staticTarget, setStaticTarget] = useState<{ host: string; port: number } | null>(null);
|
||||||
const [unavailable, setUnavailable] = useState(false);
|
const [unavailable, setUnavailable] = useState(false);
|
||||||
const [domains, setDomains] = useState<Record<string, string>>({});
|
const [domains, setDomains] = useState<Record<string, string>>({});
|
||||||
const [forwardHost, setForwardHost] = useState('');
|
const [forwardHost, setForwardHost] = useState('');
|
||||||
@@ -89,6 +97,11 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
|||||||
for (const host of found) counts.set(host.forward_host, (counts.get(host.forward_host) ?? 0) + 1);
|
for (const host of found) counts.set(host.forward_host, (counts.get(host.forward_host) ?? 0) + 1);
|
||||||
const common = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
const common = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||||
if (common) setForwardHost((typed) => typed || common);
|
if (common) setForwardHost((typed) => typed || common);
|
||||||
|
|
||||||
|
const web = await clientRef.current
|
||||||
|
.get<{ container: { name: string } }>('/fofos-admin/_config/static')
|
||||||
|
.catch(() => null);
|
||||||
|
if (web?.container?.name) setStaticTarget({ host: web.container.name, port: 80 });
|
||||||
} catch {
|
} catch {
|
||||||
// Not installed, not running, or not permitted. Reported as absent rather than as an error: this
|
// Not installed, not running, or not permitted. Reported as absent rather than as an error: this
|
||||||
// block is an integration with another plugin, and its absence is a fact rather than a fault.
|
// block is an integration with another plugin, and its absence is a fact rather than a fault.
|
||||||
@@ -108,14 +121,20 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
|||||||
setFailed(false);
|
setFailed(false);
|
||||||
setOutput(null);
|
setOutput(null);
|
||||||
try {
|
try {
|
||||||
|
// Production goes to the container by name; development to the host, at its port.
|
||||||
|
const target =
|
||||||
|
server.name === 'production' && staticTarget
|
||||||
|
? { host: staticTarget.host, port: staticTarget.port }
|
||||||
|
: { host: forwardHost.trim(), port: server.port! };
|
||||||
|
|
||||||
await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', {
|
await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', {
|
||||||
...NEW_HOST,
|
...NEW_HOST,
|
||||||
domain_names: [domain],
|
domain_names: [domain],
|
||||||
forward_scheme: 'http',
|
forward_scheme: 'http',
|
||||||
forward_host: forwardHost.trim(),
|
forward_host: target.host,
|
||||||
forward_port: server.port,
|
forward_port: target.port,
|
||||||
});
|
});
|
||||||
setOutput(`${domain} → ${forwardHost.trim()}:${server.port}`);
|
setOutput(`${domain} → ${target.host}:${target.port}`);
|
||||||
setDomains((current) => ({ ...current, [server.name]: '' }));
|
setDomains((current) => ({ ...current, [server.name]: '' }));
|
||||||
await load();
|
await load();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
@@ -246,13 +265,33 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
|||||||
const row = (server: Server) => {
|
const row = (server: Server) => {
|
||||||
const label = server.name === 'development' ? 'Development' : 'Production';
|
const label = server.name === 'development' ? 'Development' : 'Production';
|
||||||
// Everything NPM already points at this server's port, whoever made it.
|
// Everything NPM already points at this server's port, whoever made it.
|
||||||
const mine = (hosts ?? []).filter((host) => host.forward_port === server.port);
|
/*
|
||||||
|
What already points at this server.
|
||||||
|
|
||||||
|
Matched on the FULL target rather than the port alone: production forwards to a container on port
|
||||||
|
80, and every other site behind this NPM that happens to use port 80 would otherwise be listed here
|
||||||
|
as though it belonged to fofos.
|
||||||
|
*/
|
||||||
|
const mine = (hosts ?? []).filter((host) =>
|
||||||
|
server.name === 'production' && staticTarget
|
||||||
|
? host.forward_host === staticTarget.host && host.forward_port === staticTarget.port
|
||||||
|
: host.forward_port === server.port && server.port !== null,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={server.name} className="flex flex-col gap-2 border-t border-border pt-3 first:border-0 first:pt-0">
|
<div key={server.name} className="flex flex-col gap-2 border-t border-border pt-3 first:border-0 first:pt-0">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
{server.port ? <span className="font-mono"> · port {server.port}</span> : ' · no port set'}
|
<span className="font-mono">
|
||||||
|
{' · '}
|
||||||
|
{server.name === 'production'
|
||||||
|
? staticTarget
|
||||||
|
? `${staticTarget.host}:${staticTarget.port}`
|
||||||
|
: 'static, no server yet'
|
||||||
|
: server.port
|
||||||
|
? `port ${server.port}`
|
||||||
|
: 'no port set'}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{mine.map((host) => (
|
{mine.map((host) => (
|
||||||
@@ -325,7 +364,10 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Nothing left to point anywhere: every server already has a domain.
|
// Nothing left to point anywhere: every server already has a domain.
|
||||||
const anythingToCreate = servers.some((server) => !(hosts ?? []).some((host) => host.forward_port === server.port));
|
// Development only: production's target is a container name, not the gateway address this field holds.
|
||||||
|
const anythingToCreate = servers.some(
|
||||||
|
(server) => server.name === 'development' && !(hosts ?? []).some((host) => host.forward_port === server.port),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Block icon={Globe} title="Domains" hint="Which addresses reach the site, through Nginx Proxy Manager.">
|
<Block icon={Globe} title="Domains" hint="Which addresses reach the site, through Nginx Proxy Manager.">
|
||||||
|
|||||||
+20
-9
@@ -8,9 +8,19 @@ import { Block } from './Block';
|
|||||||
import { Console } from './Console';
|
import { Console } from './Console';
|
||||||
import { useFlash } from './useFlash';
|
import { useFlash } from './useFlash';
|
||||||
import { ProxiesBlock } from './ProxiesBlock';
|
import { ProxiesBlock } from './ProxiesBlock';
|
||||||
|
import { StaticBlock } from './StaticBlock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two servers that serve the site, under pm2.
|
* How the site is served: a development process, and a production build.
|
||||||
|
*
|
||||||
|
* ── They are not two of the same thing, and this stopped pretending ──
|
||||||
|
*
|
||||||
|
* Development is `bun --hot` under pm2 — a process with a port, an uptime and a restart count.
|
||||||
|
* Production is a directory of files behind nginx, which the site's own build script says outright: it
|
||||||
|
* has no port and nothing to restart. It is built or stale, served or not served.
|
||||||
|
*
|
||||||
|
* So production gets Build and a container. Showing it as a process was showing something that does not
|
||||||
|
* exist, and its Start button could only ever have failed.
|
||||||
*
|
*
|
||||||
* A block inside Administration rather than a section of its own: it is the same KIND of thing as the
|
* A block inside Administration rather than a section of its own: it is the same KIND of thing as the
|
||||||
* root location and the databases — this machine, not the site's content — and a nav row for one card
|
* root location and the databases — this machine, not the site's content — and a nav row for one card
|
||||||
@@ -88,7 +98,8 @@ export const ServersBlock = () => {
|
|||||||
setServers(
|
setServers(
|
||||||
await clientRef.current.put<Server[]>('/fofos-admin/_config/servers', {
|
await clientRef.current.put<Server[]>('/fofos-admin/_config/servers', {
|
||||||
development: Number(dev),
|
development: Number(dev),
|
||||||
production: Number(prod),
|
// Still sent because the API validates that the two differ. Nothing listens on it.
|
||||||
|
production: Number(prod || 3010),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
flash();
|
flash();
|
||||||
@@ -121,9 +132,8 @@ export const ServersBlock = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dirty =
|
// Only development has a port now. Production is a container on a docker network, publishing none.
|
||||||
dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '') ||
|
const dirty = dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '');
|
||||||
prod !== String(servers?.find((s) => s.name === 'production')?.port ?? '');
|
|
||||||
|
|
||||||
const row = (server: Server, value: string, onChange: (next: string) => void) => {
|
const row = (server: Server, value: string, onChange: (next: string) => void) => {
|
||||||
// pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a
|
// pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a
|
||||||
@@ -214,16 +224,17 @@ export const ServersBlock = () => {
|
|||||||
>
|
>
|
||||||
{!servers ? null : (
|
{!servers ? null : (
|
||||||
<>
|
<>
|
||||||
{servers.map((server) =>
|
{servers.filter((server) => server.name === 'development').map((server) => row(server, dev, setDev))}
|
||||||
server.name === 'development' ? row(server, dev, setDev) : row(server, prod, setProd),
|
|
||||||
)}
|
{/* Production, which is a build rather than a process — see StaticBlock. */}
|
||||||
|
<StaticBlock />
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
<Console output={output} failed={failed} />
|
<Console output={output} failed={failed} />
|
||||||
|
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<div className="flex justify-end border-t border-border pt-3">
|
<div className="flex justify-end border-t border-border pt-3">
|
||||||
<Button size="sm" onClick={() => void savePorts()} disabled={busy !== null || !dev || !prod}>
|
<Button size="sm" onClick={() => void savePorts()} disabled={busy !== null || !dev}>
|
||||||
{busy === 'ports' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
{busy === 'ports' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
Save ports
|
Save ports
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { readableError } from './errors';
|
||||||
|
import { Console } from './Console';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production: the built site, and the nginx serving it.
|
||||||
|
*
|
||||||
|
* ── Two states, not one ──
|
||||||
|
*
|
||||||
|
* A build can succeed while nothing serves it, and a container can serve a directory built weeks ago.
|
||||||
|
* They fail independently and they are fixed differently, so they are reported separately rather than
|
||||||
|
* collapsed into "production is up" — which would be true in one of those cases and misleading in the
|
||||||
|
* other.
|
||||||
|
*
|
||||||
|
* ── Build takes minutes ──
|
||||||
|
*
|
||||||
|
* It renders every route through the real app and copies every referenced file. Said in the button,
|
||||||
|
* because a spinner sitting for three minutes with no explanation is indistinguishable from a hang.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Static = {
|
||||||
|
path: string;
|
||||||
|
built: boolean;
|
||||||
|
entries: number | null;
|
||||||
|
builtAt: string | null;
|
||||||
|
container: { name: string; state: string | null; forward: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const when = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
export const StaticBlock = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<Static | null>(null);
|
||||||
|
const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null);
|
||||||
|
const [output, setOutput] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStatus(await clientRef.current.get<Static>('/fofos-admin/_config/static'));
|
||||||
|
} catch {
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const act = async (what: 'build' | 'start' | 'stop') => {
|
||||||
|
setBusy(what);
|
||||||
|
setFailed(false);
|
||||||
|
setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null);
|
||||||
|
try {
|
||||||
|
const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`;
|
||||||
|
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {});
|
||||||
|
setStatus(result.status);
|
||||||
|
setOutput(result.output);
|
||||||
|
setFailed(!result.ok);
|
||||||
|
} catch (failure) {
|
||||||
|
setOutput(readableError(failure));
|
||||||
|
setFailed(true);
|
||||||
|
void load();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
const serving = status.container.state === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs">
|
||||||
|
<CircleDot
|
||||||
|
className={`size-3 ${serving ? 'text-success' : status.container.state ? 'text-destructive' : 'text-muted-foreground/40'}`}
|
||||||
|
/>
|
||||||
|
Production
|
||||||
|
<span className="text-muted-foreground/70">
|
||||||
|
{status.built ? `${status.entries} files` : 'not built'}
|
||||||
|
{status.builtAt ? ` · built ${when(status.builtAt)}` : ''}
|
||||||
|
{' · '}
|
||||||
|
{status.container.state ?? 'no server'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/* The forward target, because it is what a domain has to point at and it is not a port. */}
|
||||||
|
<p className="font-mono text-xs text-muted-foreground/60">
|
||||||
|
{status.container.name} · {serving ? status.container.forward : 'static files, no port'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-24 justify-center"
|
||||||
|
onClick={() => void act('build')}
|
||||||
|
disabled={busy !== null}
|
||||||
|
>
|
||||||
|
{busy === 'build' ? <Loader2 className="size-3.5 animate-spin" /> : <Hammer className="size-3.5" />}
|
||||||
|
{busy === 'build' ? 'Building' : 'Build'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-24 justify-center"
|
||||||
|
onClick={() => void act('start')}
|
||||||
|
// Nothing to serve until something is built, and starting on an empty directory produces a
|
||||||
|
// container that returns 404 for everything — which reads as a broken site, not a missing build.
|
||||||
|
disabled={busy !== null || !status.built}
|
||||||
|
title={status.built ? undefined : 'Build it first'}
|
||||||
|
>
|
||||||
|
{busy === 'start' ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
||||||
|
{serving ? 'Restart' : 'Serve'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-24 justify-center"
|
||||||
|
onClick={() => void act('stop')}
|
||||||
|
disabled={busy !== null || !status.container.state}
|
||||||
|
>
|
||||||
|
<Square className="size-3.5" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Console output={output} failed={failed} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user