mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-17 08:09:47 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65ccd5a89d | |||
| 76a4f50ccf | |||
| 1dfe0124be | |||
| aa52174f96 | |||
| e23ae5707f | |||
| 17053ac2bd | |||
| 735787b183 | |||
| 262ca8a1d0 | |||
| 66c9bdbfa3 | |||
| 4716a2a647 | |||
| 847f44197b | |||
| 6aacd4d185 | |||
| ab3db3bc68 | |||
| b52baceb50 | |||
| 0d2c8a37ef | |||
| 9298ec7cdb | |||
| 0fdff2feee | |||
| 8e042e6433 | |||
| 883ad14326 | |||
| 3cb41211ee | |||
| ee4a1a6b18 | |||
| a10972990a | |||
| 6d6e105711 |
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
|
||||
* Create reusable views for common access patterns
|
||||
|
||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
|
||||
@@ -234,6 +234,9 @@
|
||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
|
||||
"licenseBillingBannerTitle": "Manage License Billing",
|
||||
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
|
||||
"licenseBillingBannerButton": "Go to Billing",
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 713 KiB |
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you need to purchase additional license keys or
|
||||
modify your existing license, please reach out to
|
||||
our support team at{" "}
|
||||
For any questions or concerns regarding your license
|
||||
or billing, please reach out to support at{" "}
|
||||
<a
|
||||
href="mailto:support@pangolin.net"
|
||||
className="text-primary font-medium"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
|
||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
// Grace period of 5 days added on top of the current billing
|
||||
// period end (usually ~1 year out) before the license expires
|
||||
const currentPeriodEnd =
|
||||
fullSubscription.items.data[0]?.current_period_end;
|
||||
const expiresAt =
|
||||
(currentPeriodEnd ?? subscription.created) +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
const response = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||
{
|
||||
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
|
||||
paidFor: true,
|
||||
users: numUsers,
|
||||
sites: numSites,
|
||||
tier: tier
|
||||
tier: tier,
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
@@ -267,6 +276,13 @@ export async function handleSubscriptionCreated(
|
||||
|
||||
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.email) {
|
||||
logger.debug(
|
||||
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||
|
||||
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
|
||||
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import {
|
||||
getFeatureIdByMetricId,
|
||||
getFeatureIdByPriceId
|
||||
} from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType, SubscriptionType } from "./getSubType";
|
||||
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
|
||||
.limit(1);
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
const previousType =
|
||||
existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
// If the subscription has been manually overridden, we lock the
|
||||
// status down so Stripe webhooks can no longer change it.
|
||||
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
type,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert subscription items
|
||||
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
|
||||
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||
// Try to get featureId from price
|
||||
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||
let featureId: string | null =
|
||||
getFeatureIdByPriceId(item.price.id) || null;
|
||||
|
||||
// If no match, try to preserve existing featureId
|
||||
if (!featureId) {
|
||||
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
null,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||
if (
|
||||
effectiveStatus === "canceled" ||
|
||||
effectiveStatus == "unpaid" ||
|
||||
effectiveStatus == "incomplete_expired"
|
||||
) {
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else if (effectiveStatus === "active" && previousAttributes) {
|
||||
// Detect a successful renewal: the billing period rolled
|
||||
// forward (the invoice was paid and the new period began
|
||||
// right where the previous one ended).
|
||||
const currentItem = fullSubscription.items.data[0];
|
||||
const prevItems = previousAttributes.items?.data;
|
||||
const prevItem = Array.isArray(prevItems)
|
||||
? prevItems.find(
|
||||
(pi: any) => pi.id === currentItem?.id
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const renewed =
|
||||
currentItem &&
|
||||
prevItem?.current_period_end &&
|
||||
currentItem.current_period_start ===
|
||||
prevItem.current_period_end &&
|
||||
currentItem.current_period_start >
|
||||
prevItem.current_period_start;
|
||||
|
||||
if (renewed) {
|
||||
const licenseKeyId =
|
||||
subscription.metadata.licenseKeyId;
|
||||
|
||||
if (!licenseKeyId) {
|
||||
logger.error(
|
||||
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
|
||||
);
|
||||
} else {
|
||||
// Grace period of 5 days added on top of the new
|
||||
// billing period end (usually ~1 year out)
|
||||
const expiresAt =
|
||||
currentItem.current_period_end +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const extendResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig()
|
||||
.server.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseId: parseInt(licenseKeyId),
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!extendResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
|
||||
);
|
||||
}
|
||||
|
||||
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
|
||||
|
||||
// check if we already have a customer for this org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
subscription_data: {
|
||||
description: licenseKeyValue
|
||||
? `License ${licenseKeyValue}`
|
||||
: `License key ID ${keyId}`,
|
||||
metadata: {
|
||||
licenseKeyId: keyId.toString(),
|
||||
licenseKey: licenseKeyValue ?? "",
|
||||
tier: licenseData.tier
|
||||
}
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -749,12 +749,6 @@ export default function BillingPage() {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Get license key count
|
||||
const getLicenseKeyCount = (): number => {
|
||||
if (!licenseSubscription?.items) return 0;
|
||||
return licenseSubscription.items.length;
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (
|
||||
targetTier: Tier | "basic"
|
||||
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
|
||||
</SettingsSection>
|
||||
|
||||
{/* Paid License Keys Section */}
|
||||
{(licenseSubscription || getLicenseKeyCount() > 0) && (
|
||||
{licenseSubscription && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") ||
|
||||
"Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getLicenseKeyCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getLicenseKeyCount() === 1
|
||||
? "key"
|
||||
: "keys"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModifySubscription}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
|
||||
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
|
||||
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
|
||||
licenseKeys = data.data.data;
|
||||
} catch {}
|
||||
|
||||
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
|
||||
const hasNonPersonalLicenseKey = licenseKeys.some(
|
||||
(key) => key.tier !== "personal"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasNonPersonalLicenseKey && (
|
||||
<LicenseBillingBanner orgId={orgId} />
|
||||
)}
|
||||
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,50 +291,74 @@ export default function PrivateResourceInferencePage() {
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<DomainPicker
|
||||
key={`inference-domain-${siteResource.id}`}
|
||||
orgId={siteResource.orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ??
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="httpConfigDomainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
key={`inference-domain-${siteResource.id}`}
|
||||
orgId={
|
||||
siteResource.orgId
|
||||
}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ??
|
||||
undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ??
|
||||
undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ??
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
"",
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
|
||||
@@ -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,63 +450,87 @@ export default function GeneralForm() {
|
||||
resource.mode
|
||||
) && (
|
||||
<SettingsFormCell span="full">
|
||||
<div id="resource-domain-picker">
|
||||
<DomainPicker
|
||||
allowWildcard={
|
||||
resource.mode !==
|
||||
"inference"
|
||||
}
|
||||
key={
|
||||
resource.resourceId
|
||||
}
|
||||
orgId={orgId as string}
|
||||
cols={2}
|
||||
defaultSubdomain={
|
||||
form.watch(
|
||||
"subdomain"
|
||||
) ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
form.watch(
|
||||
"domainId"
|
||||
) ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
resourceFullDomainName ||
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
undefined
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
undefined
|
||||
);
|
||||
setResourceFullDomain(
|
||||
`${resource.ssl ? "https" : "http"}://`
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res.subdomain ??
|
||||
undefined
|
||||
);
|
||||
setResourceFullDomain(
|
||||
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<div id="resource-domain-picker">
|
||||
<DomainPicker
|
||||
allowWildcard={
|
||||
resource.mode !==
|
||||
"inference"
|
||||
}
|
||||
key={
|
||||
resource.resourceId
|
||||
}
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
cols={2}
|
||||
defaultSubdomain={
|
||||
form.watch(
|
||||
"subdomain"
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
form.watch(
|
||||
"domainId"
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
resourceFullDomainName ||
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (
|
||||
res ===
|
||||
null
|
||||
) {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
undefined,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
undefined
|
||||
);
|
||||
setResourceFullDomain(
|
||||
`${resource.ssl ? "https" : "http"}://`
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res.subdomain ??
|
||||
undefined
|
||||
);
|
||||
setResourceFullDomain(
|
||||
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{!["tcp", "udp", "inference"].includes(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, CreditCard, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type LicenseBillingBannerProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="license-billing-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBillingBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("licenseBillingBannerDescription")}
|
||||
>
|
||||
<Link href={`/${orgId}/settings/billing`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{t("licenseBillingBannerButton")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseBillingBanner;
|
||||
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
href="https://pangolin.net/pricing#Self-Hosted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
|
||||
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
) : null;
|
||||
|
||||
if (site.type === "newt") {
|
||||
// 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 = site.agent == "cli" ? "Pangolin CLI" : "Newt";
|
||||
const agentVersion = site.agentVersion ?? site.newtVersion;
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.agent == "newt" ? "Newt" : null}
|
||||
{site.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: null}{" "}
|
||||
{site.agentVersion
|
||||
? `v${site.agentVersion}`
|
||||
: "-"}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{agentLabel}</span>
|
||||
{agentVersion && (
|
||||
<span>v{agentVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function SitesTable({
|
||||
accessorKey: "type",
|
||||
friendlyName: t("agent"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
return <span className="p-3">{t("agent")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -386,25 +386,27 @@ 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user