Compare commits

...

10 Commits

Author SHA1 Message Date
Owen Schwartz 2957d6592d Merge pull request #2919 from fosrl/dev
Add missing imports
2026-04-28 16:52:53 -07:00
Owen de2a22aad8 Add missing imports 2026-04-28 16:52:35 -07:00
Owen Schwartz b96db4f133 Merge pull request #2918 from fosrl/dev
Use logsDb for the status history
2026-04-28 16:38:43 -07:00
Owen 2a29062659 Use logsDb for the status history 2026-04-28 16:38:13 -07:00
Owen Schwartz 98406f63af Merge pull request #2916 from fosrl/dev
1.18.0
2026-04-28 15:53:31 -07:00
Owen 85415176ab Clean imports 2026-04-28 15:41:00 -07:00
Owen b81ae3d998 Seed satatus data for resources, sites, and hc 2026-04-28 15:15:09 -07:00
Owen 208289f498 Select all networks to prevent delete issues 2026-04-28 12:02:21 -07:00
Owen 8783c47a3c Dont allow clicking the wildcard resource link 2026-04-28 11:21:32 -07:00
Owen 592ca64253 Fix delete 2026-04-28 09:57:46 -07:00
11 changed files with 289 additions and 97 deletions
+33 -28
View File
@@ -180,36 +180,41 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-siteResource associations ///////////
// get all of the clients associated with other resources in the same network,
// joined through siteNetworks so we know which siteId each client belongs to
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
? await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(siteResources.networkId, siteResource.networkId),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
// get all of the clients associated with other site resources that share
// any of the same sites as this site resource (via siteNetworks). We can't
// simply filter by networkId since each site resource has its own network;
// two site resources serving the same site typically belong to different
// networks that both happen to include the site through siteNetworks.
const sitesListSiteIds = sitesList.map((s) => s.siteId);
const allUpdatedClientsFromOtherResourcesOnThisSite =
sitesListSiteIds.length > 0
? await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
)
: [];
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
inArray(siteNetworks.siteId, sitesListSiteIds),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
)
)
: [];
// Build a per-site map so the loop below can check by siteId rather than
// across the entire network.
+21 -15
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
@@ -27,7 +27,7 @@ export async function getCachedStatusHistory(
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
const events = await logsDb
.select()
.from(statusHistory)
.where(
@@ -74,11 +74,11 @@ export const statusHistoryQuerySchema = z
days: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 90)),
.transform((v) => (v ? parseInt(v, 10) : 90))
})
.pipe(
z.object({
days: z.number().int().min(1).max(365),
days: z.number().int().min(1).max(365)
})
);
@@ -99,7 +99,14 @@ export interface StatusHistoryResponse {
}
export function computeBuckets(
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[],
events: {
entityType: string;
entityId: number;
orgId: string;
status: string;
timestamp: number;
id: number;
}[],
days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000);
@@ -121,7 +128,8 @@ export function computeBuckets(
const currentStatus = lastBeforeDay?.status ?? null;
const windows: { start: number; end: number | null; status: string }[] = [];
const windows: { start: number; end: number | null; status: string }[] =
[];
let dayDowntime = 0;
let dayDegradedTime = 0;
@@ -132,22 +140,21 @@ export function computeBuckets(
if (windowStatus !== null && windowStatus !== evt.status) {
const windowEnd = evt.timestamp;
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy";
windowStatus === "offline" || windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
status: windowStatus
});
} else if (isDegraded) {
dayDegradedTime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
status: windowStatus
});
}
}
@@ -159,22 +166,21 @@ export function computeBuckets(
if (windowStatus !== null) {
const finalEnd = Math.min(dayEndSec, nowSec);
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy";
windowStatus === "offline" || windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
status: windowStatus
});
} else if (isDegraded && finalEnd > windowStart) {
dayDegradedTime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
status: windowStatus
});
}
}
@@ -225,7 +231,7 @@ export function computeBuckets(
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status,
status
});
}
@@ -19,7 +19,8 @@ import {
targetHealthCheck,
targets,
resources,
Transaction
Transaction,
logsDb
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
@@ -52,10 +53,10 @@ export async function fireHealthCheckHealthyAlert(
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
@@ -119,7 +120,7 @@ export async function fireHealthCheckUnhealthyAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
@@ -172,7 +173,7 @@ export async function fireHealthCheckUnknownAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
@@ -194,7 +195,12 @@ export async function fireHealthCheckUnknownAlert(
}
}
async function handleResource(orgId: string, healthCheckTargetId?: number | null, send: boolean = true, trx: Transaction | typeof db = db) {
async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) {
return;
}
@@ -222,7 +228,10 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId))
.innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
@@ -13,7 +13,7 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, statusHistory, Transaction } from "@server/db";
import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
@@ -40,7 +40,7 @@ export async function fireResourceHealthyAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
@@ -101,7 +101,7 @@ export async function fireResourceUnhealthyAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
@@ -162,7 +162,7 @@ export async function fireResourceDegradedAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
@@ -223,7 +223,7 @@ export async function fireResourceUnknownAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
@@ -13,7 +13,13 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db";
import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
@@ -41,7 +47,7 @@ export async function fireSiteOnlineAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
@@ -97,7 +103,7 @@ export async function fireSiteOfflineAlert(
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
+16 -8
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { db, logsDb, statusHistory } from "@server/db";
import {
siteProvisioningKeys,
siteProvisioningKeyOrg,
@@ -84,7 +84,7 @@ export async function registerNewt(
maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites,
approveNewSites: siteProvisioningKeys.approveNewSites
})
.from(siteProvisioningKeys)
.innerJoin(
@@ -125,7 +125,10 @@ export async function registerNewt(
);
}
if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) {
if (
keyRecord.maxBatchSize &&
keyRecord.numUsed >= keyRecord.maxBatchSize
) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -134,7 +137,10 @@ export async function registerNewt(
);
}
if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) {
if (
keyRecord.validUntil &&
new Date(keyRecord.validUntil) < new Date()
) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -154,7 +160,10 @@ export async function registerNewt(
}
if (!org.subnet) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Organization subnet not found"
)
);
}
@@ -195,7 +204,6 @@ export async function registerNewt(
let newSiteId: number | undefined;
await db.transaction(async (trx) => {
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
@@ -219,11 +227,11 @@ export async function registerNewt(
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending",
status: keyRecord.approveNewSites ? "approved" : "pending"
})
.returning();
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db, exitNodes, statusHistory } from "@server/db";
import { clients, db, exitNodes, logsDb, statusHistory } from "@server/db";
import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -351,7 +351,7 @@ export async function createSite(
})
.returning();
await trx.insert(statusHistory).values({
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
@@ -67,22 +67,9 @@ export async function deleteSiteResource(
// Delete the site resource
const [removedSiteResource] = await trx
.delete(siteResources)
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
// not sure why this is here...
// const [newt] = await trx
// .select()
// .from(newts)
// .where(eq(newts.siteId, removedSiteResource.siteId))
// .limit(1);
// if (!newt) {
// return next(
// createHttpError(HttpCode.NOT_FOUND, "Newt not found")
// );
// }
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
trx
+92 -9
View File
@@ -67,11 +67,12 @@ export default async function migration() {
FROM "siteResources" sr
WHERE sr."siteId" IS NOT NULL`
);
const existingSiteResourcesForNetwork = siteResourcesForNetworkQuery.rows as {
siteResourceId: number;
orgId: string;
siteId: number;
}[];
const existingSiteResourcesForNetwork =
siteResourcesForNetworkQuery.rows as {
siteResourceId: number;
orgId: string;
siteId: number;
}[];
console.log(
`Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks`
@@ -446,10 +447,7 @@ export default async function migration() {
`Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs`
);
} catch (e) {
console.error(
"Error while migrating targetHealthCheck rows:",
e
);
console.error("Error while migrating targetHealthCheck rows:", e);
throw e;
}
}
@@ -493,5 +491,90 @@ export default async function migration() {
}
}
// Seed statusHistory for all existing sites
try {
const sitesQuery = await db.execute(
sql`SELECT "siteId", "orgId", "online" FROM "sites"`
);
const allSites = sitesQuery.rows as {
siteId: number;
orgId: string;
online: boolean;
}[];
const now = Math.floor(Date.now() / 1000);
for (const site of allSites) {
await db.execute(sql`
INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp")
VALUES ('site', ${site.siteId}, ${site.orgId}, ${site.online ? "online" : "offline"}, ${now})
`);
}
console.log(`Seeded statusHistory for ${allSites.length} site(s)`);
} catch (e) {
console.error("Error while seeding statusHistory for sites:", e);
throw e;
}
// Seed statusHistory for all existing resources
try {
const resourcesQuery = await db.execute(
sql`SELECT "resourceId", "orgId", "health" FROM "resources"`
);
const allResources = resourcesQuery.rows as {
resourceId: number;
orgId: string;
health: string | null;
}[];
const now = Math.floor(Date.now() / 1000);
for (const resource of allResources) {
await db.execute(sql`
INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp")
VALUES ('resource', ${resource.resourceId}, ${resource.orgId}, ${resource.health ?? "unknown"}, ${now})
`);
}
console.log(
`Seeded statusHistory for ${allResources.length} resource(s)`
);
} catch (e) {
console.error("Error while seeding statusHistory for resources:", e);
throw e;
}
// Seed statusHistory for all existing health checks
try {
const healthChecksQuery = await db.execute(
sql`SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM "targetHealthCheck"`
);
const allHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number;
orgId: string;
hcHealth: string | null;
}[];
const now = Math.floor(Date.now() / 1000);
for (const hc of allHealthChecks) {
await db.execute(sql`
INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp")
VALUES ('health_check', ${hc.targetHealthCheckId}, ${hc.orgId}, ${hc.hcHealth ?? "unknown"}, ${now})
`);
}
console.log(
`Seeded statusHistory for ${allHealthChecks.length} health check(s)`
);
} catch (e) {
console.error(
"Error while seeding statusHistory for health checks:",
e
);
throw e;
}
console.log(`${version} migration complete`);
}
+86 -2
View File
@@ -340,7 +340,6 @@ export default async function migration() {
ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL;
`
).run();
})();
db.pragma("foreign_keys = ON");
@@ -364,7 +363,11 @@ export default async function migration() {
const result = insertNetwork.run("resource", sr.orgId);
const networkId = result.lastInsertRowid as number;
insertSiteNetwork.run(sr.siteId, networkId);
updateSiteResource.run(networkId, networkId, sr.siteResourceId);
updateSiteResource.run(
networkId,
networkId,
sr.siteResourceId
);
}
});
@@ -454,6 +457,87 @@ export default async function migration() {
}
console.log(`Migrated database`);
// Seed statusHistory for all existing sites
const allSites = db
.prepare(`SELECT "siteId", "orgId", "online" FROM 'sites'`)
.all() as { siteId: number; orgId: string; online: number }[];
const insertSiteHistory = db.prepare(
`INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)`
);
const now = Math.floor(Date.now() / 1000);
const seedSites = db.transaction(() => {
for (const site of allSites) {
insertSiteHistory.run(
"site",
site.siteId,
site.orgId,
site.online ? "online" : "offline",
now
);
}
});
seedSites();
console.log(`Seeded statusHistory for ${allSites.length} site(s)`);
// Seed statusHistory for all existing resources
const allResources = db
.prepare(`SELECT "resourceId", "orgId", "health" FROM 'resources'`)
.all() as {
resourceId: number;
orgId: string;
health: string | null;
}[];
const insertResourceHistory = db.prepare(
`INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)`
);
const seedResources = db.transaction(() => {
for (const resource of allResources) {
insertResourceHistory.run(
"resource",
resource.resourceId,
resource.orgId,
resource.health ?? "unknown",
now
);
}
});
seedResources();
console.log(
`Seeded statusHistory for ${allResources.length} resource(s)`
);
// Seed statusHistory for all existing health checks
const allHealthChecks = db
.prepare(
`SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM 'targetHealthCheck'`
)
.all() as {
targetHealthCheckId: number;
orgId: string;
hcHealth: string | null;
}[];
const insertHealthCheckHistory = db.prepare(
`INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)`
);
const seedHealthChecks = db.transaction(() => {
for (const hc of allHealthChecks) {
insertHealthCheckHistory.run(
"health_check",
hc.targetHealthCheckId,
hc.orgId,
hc.hcHealth ?? "unknown",
now
);
}
});
seedHealthChecks();
console.log(
`Seeded statusHistory for ${allHealthChecks.length} health check(s)`
);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
+8 -4
View File
@@ -40,10 +40,14 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
{resource.wildcard ? (
<span>{fullUrl}</span>
) : (
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>