Compare commits

...

19 Commits

Author SHA1 Message Date
Owen Schwartz 2733ae1122 Merge pull request #3779 from fosrl/dev
Fix #3778
2026-09-16 10:11:43 -04:00
Owen 735787b183 Fix #3778 2026-09-16 10:11:20 -04:00
Owen Schwartz 2fb3507aa5 Merge pull request #3775 from fosrl/dev
Add domain validation for inference mode in resource forms
2026-09-16 09:53:21 -04:00
Owen 262ca8a1d0 Add domain validation for inference mode in resource forms 2026-09-16 09:41:46 -04:00
Owen Schwartz 66c9bdbfa3 Merge pull request #3774 from fosrl/dev
Make migration idempotent
2026-09-16 09:11:14 -04:00
Owen 4716a2a647 Make migration idempotent
Ref #3759
2026-09-16 09:08:20 -04:00
Owen Schwartz 847f44197b Merge pull request #3772 from Hayyan612/fix/site-type-badge-fallback
fix(sites): fall back to newtVersion so the Type badge is never empty
2026-09-16 08:59:07 -04:00
Owen Schwartz 6aacd4d185 Merge pull request #3770 from Hayyan612/fix/systemd-exec-path
fix(ui): point the manual systemd unit at the installed CLI path
2026-09-16 08:57:37 -04:00
Owen Schwartz ab3db3bc68 Merge pull request #3769 from Hayyan612/fix/backup-filename-timestamp
fix(setup): correct month index and zero-pad database backup file names
2026-09-16 08:57:12 -04:00
Hayyan Hajwani b52baceb50 fix(sites): fall back to newtVersion so the Type badge is never empty
The site overview rendered an empty badge for any site whose newt has not
reported the newer agent fields. The early return only bails out when both
agent and newtVersion are missing, so a site with newtVersion set but agent
null fell through to a badge whose label came solely from agent and whose
version came solely from agentVersion, leaving both blank.

Label such a site Newt and fall back to newtVersion for the version. Updating
the newt populated the new fields, which is why the badge appeared to fix
itself on upgrade.

Closes #3766
2026-09-16 13:55:24 +05:30
Hayyan Hajwani 0d2c8a37ef fix(ui): point the manual systemd unit at the installed CLI path
The service file offered on the site install screen hardcoded
/home/owen/fossorial/cli/bin/pangolin, a developer machine path, so the unit
fails to start on a normal install.

get-cli.sh installs to /usr/local/bin ("Prefer /usr/local/bin for system-wide
installation"), which is also where the bare `pangolin` calls in the surrounding
commands resolve from.

Closes #3768
2026-09-16 13:54:14 +05:30
Hayyan Hajwani 9298ec7cdb fix(setup): correct month index and zero-pad database backup file names
Backup names were built inline from Date#getMonth, which is zero-indexed, so a
backup taken on 12 September 2026 was written as db_2026-8-12_20-35-56.sqlite.
No field was zero-padded either, giving names like db_2026-8-12_20-36-2.sqlite.

Extract formatBackupTimestamp into server/lib and use it from both places that
built the string: the backupDb helper in migrationsSqlite.ts and the inline copy
in the 1.0.0-beta9 setup script. Padding every field also makes the names sort
lexicographically in the order the backups were taken.

Adds tests covering both reported names, single-digit padding and sort order.
Reverting the helper to the old formula fails them with the exact name from the
report.
2026-09-16 13:46:57 +05:30
Owen Schwartz 0fdff2feee Merge pull request #3765 from fosrl/dev
Show the version
2026-09-15 22:31:22 -04:00
Owen 8e042e6433 Show the version 2026-09-15 22:31:05 -04:00
Owen Schwartz 883ad14326 Merge pull request #3764 from fosrl/dev
fix premature migration execution
2026-09-15 22:30:19 -04:00
miloschwartz 3cb41211ee fix premature migration execution 2026-09-15 20:44:17 -04:00
Owen Schwartz ee4a1a6b18 Merge pull request #3758 from fosrl/dev
Update link in reference deployment
2026-09-15 17:17:22 -04:00
Owen Schwartz a10972990a Merge pull request #3757 from fosrl/dev
Install go 1.26
2026-09-15 16:57:15 -04:00
Owen Schwartz 6d6e105711 Merge pull request #3756 from fosrl/dev
1.23.0
2026-09-15 16:48:42 -04:00
11 changed files with 328 additions and 126 deletions
+101
View File
@@ -0,0 +1,101 @@
import { formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
// so these cases do not depend on the machine's timezone.
function testMonthIsOneIndexed() {
console.log("Running month indexing tests...");
// The case from the report: a backup taken on 12 September 2026 was named
// db_2026-8-12_... because Date#getMonth is zero-indexed.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
assertEquals(
result,
"2026-09-12_20-35-56",
"September must render as 09, not 8"
);
}
// The other reported name, db_2026-0-23_..., was a January backup.
{
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
assertEquals(
result,
"2026-01-23_20-25-49",
"January must render as 01, not 0"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
assertEquals(
result,
"2026-12-31_23-59-59",
"December must render as 12"
);
}
}
function testEveryFieldIsZeroPadded() {
console.log("Running zero padding tests...");
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
assertEquals(
result,
"2026-09-12_20-36-02",
"Single-digit seconds must be padded"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
assertEquals(
result,
"2026-01-01_00-00-00",
"Midnight on the first of the month must pad every field"
);
}
}
function testNamesSortChronologically() {
console.log("Running sort order tests...");
// Zero padding means a plain lexicographic sort of the backups directory
// lists the backups in the order they were taken.
const taken = [
new Date(2026, 8, 12, 20, 36, 2),
new Date(2026, 0, 23, 20, 25, 49),
new Date(2026, 8, 12, 20, 35, 56),
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken
.map((date) => formatBackupTimestamp(date))
.sort();
assertEquals(
sorted.join(","),
[
"2026-01-23_20-25-49",
"2026-09-12_20-35-56",
"2026-09-12_20-36-02",
"2026-12-31_23-59-59"
].join(","),
"Backup names must sort into the order the backups were taken"
);
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Builds the timestamp segment of a database backup file name.
*
* `Date#getMonth` is zero-indexed, so building this inline produced names like
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
* also zero-padded, which keeps the names unambiguous and makes them sort
* lexicographically in the order they were taken.
*
* @param date The moment the backup is being taken. Defaults to now.
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
*/
export function formatBackupTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
const datePart = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
const timePart = [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("-");
return `${datePart}_${timePart}`;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import semver from "semver";
import { versionMigrations } from "../db/sqlite";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { SqliteError } from "better-sqlite3";
import fs from "fs";
import { build } from "@server/build";
@@ -121,7 +122,7 @@ function backupDb() {
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
+2 -4
View File
@@ -3,8 +3,6 @@ import { sql } from "drizzle-orm";
const version = "1.23.0";
await migration();
export default async function migration() {
console.log(`Running setup script ${version}...`);
@@ -12,11 +10,11 @@ export default async function migration() {
await db.execute(sql`BEGIN`);
await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agent" varchar;
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agent" varchar;
`);
await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agentVersion" varchar;
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agentVersion" varchar;
`);
await db.execute(sql`COMMIT`);
+2 -1
View File
@@ -10,6 +10,7 @@ import {
users
} from "../../db/sqlite";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { eq, sql } from "drizzle-orm";
import fs from "fs";
import * as yaml from "js-yaml";
@@ -34,7 +35,7 @@ export default async function migration() {
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
+2 -2
View File
@@ -16,13 +16,13 @@ export default async function migration() {
db.transaction(() => {
db.prepare(
`
ALTER TABLE 'newt' ADD 'agent' text;
ALTER TABLE 'newt' ADD COLUMN 'agent' text;
`
).run();
db.prepare(
`
ALTER TABLE 'newt' ADD 'agentVersion' text;
ALTER TABLE 'newt' ADD COLUMN 'agentVersion' text;
`
).run();
})();
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
})
),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigDomainId: z
.string()
.min(1, { message: t("domainRequired") }),
httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional()
}),
[]
[t]
);
type FormValues = z.infer<typeof formSchema>;
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
defaultValues: {
providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigDomainId: siteResource.domainId ?? "",
httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false
}
@@ -289,22 +291,33 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="httpConfigDomainId"
render={() => (
<FormItem>
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={siteResource.orgId}
orgId={
siteResource.orgId
}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
httpConfigSubdomain ??
undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
httpConfigDomainId ??
undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(res) => {
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
@@ -312,7 +325,11 @@ export default function PrivateResourceInferencePage() {
);
form.setValue(
"httpConfigDomainId",
null
"",
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
@@ -322,11 +339,16 @@ export default function PrivateResourceInferencePage() {
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
@@ -334,6 +356,10 @@ export default function PrivateResourceInferencePage() {
);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
@@ -139,6 +139,22 @@ export default function GeneralForm() {
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return !!data.domainId;
}
return true;
},
{
message: t("domainRequired"),
path: ["domainId"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -434,6 +450,11 @@ export default function GeneralForm() {
resource.mode
) && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={
@@ -443,17 +464,21 @@ export default function GeneralForm() {
key={
resource.resourceId
}
orgId={orgId as string}
orgId={
orgId as string
}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
) ??
undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
) ??
undefined
}
defaultFullDomain={
resourceFullDomainName ||
@@ -462,10 +487,17 @@ export default function GeneralForm() {
onDomainChange={(
res
) => {
if (res === null) {
if (
res ===
null
) {
form.setValue(
"domainId",
undefined
undefined,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
@@ -478,7 +510,11 @@ export default function GeneralForm() {
}
form.setValue(
"domainId",
res.domainId
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
@@ -491,6 +527,10 @@ export default function GeneralForm() {
}}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{!["tcp", "udp", "inference"].includes(
+13 -13
View File
@@ -386,25 +386,25 @@ export default function SitesTable({
);
if (originalRow.type === "newt") {
if (!originalRow.agent) {
if (!originalRow.agent && !originalRow.newtVersion) {
// it has not checked in yet
return <span>-</span>;
}
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel =
originalRow.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>
{originalRow.agent == "newt"
? "Newt"
: null}
{originalRow.agent == "cli"
? "Pangolin CLI"
: null}
</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span>
<span>{agentLabel}</span>
{agentVersion && (
<span>v{agentVersion}</span>
)}
</div>
</Badge>
+1 -1
View File
@@ -150,7 +150,7 @@ Type=simple
User=root
Group=root
EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
ExecStart=/usr/local/bin/pangolin up site
Restart=always
RestartSec=2
UMask=0077
+7
View File
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
});
}
}
if (data.mode === "inference" && !data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
});
}