mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-26 10:43:09 +00:00
Compare commits
1 Commits
dev
...
exit-node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c68b6cd3 |
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
@@ -14,13 +14,12 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version:
|
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
- Edition (Community or Enterprise):
|
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Client Version:
|
- Olm Version: (if applicable)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1646,7 +1646,6 @@
|
|||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
|
|||||||
@@ -154,19 +154,8 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get keys with a specific prefix
|
|
||||||
* @param prefix - Key prefix to match
|
|
||||||
* @returns Array of matching keys
|
|
||||||
*/
|
|
||||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
|
||||||
const allKeys = localCache.keys();
|
|
||||||
return allKeys.filter((key) => key.startsWith(prefix));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export const regionalCache = cache; // Alias for compatability with the private version
|
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,11 +39,6 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
// TTL for rebuild-association locks. These functions can fan out into many
|
|
||||||
// peer/proxy updates, so give them a generous window.
|
|
||||||
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
|
||||||
|
|
||||||
export async function getClientSiteResourceAccess(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -166,23 +161,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
|
||||||
return await lockManager.withLock(
|
|
||||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
|
||||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildClientAssociationsFromSiteResourceImpl(
|
|
||||||
siteResource: SiteResource,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<{
|
|
||||||
mergedAllClients: {
|
|
||||||
clientId: number;
|
|
||||||
pubKey: string | null;
|
|
||||||
subnet: string | null;
|
|
||||||
}[];
|
|
||||||
}> {
|
}> {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -561,29 +539,6 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
|
||||||
const clientSiteCounts: Record<number, number> = {};
|
|
||||||
if (clientsToProcess.size > 0) {
|
|
||||||
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
|
||||||
const siteCounts = await trx
|
|
||||||
.select({
|
|
||||||
clientId: clientSitesAssociationsCache.clientId,
|
|
||||||
siteCount: count(clientSitesAssociationsCache.siteId)
|
|
||||||
})
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
clientSitesAssociationsCache.clientId,
|
|
||||||
clientIdsToProcess
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(clientSitesAssociationsCache.clientId);
|
|
||||||
|
|
||||||
for (const row of siteCounts) {
|
|
||||||
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of clientsToProcess.values()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -627,14 +582,7 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
if (clientSiteCounts[client.clientId] > 250) {
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
|
||||||
logger.info(
|
|
||||||
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -652,24 +600,9 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(exitNodeJobs).catch((error) => {
|
await Promise.all(exitNodeJobs);
|
||||||
logger.error(
|
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
||||||
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
await Promise.all(olmJobs);
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(newtJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(olmJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -952,17 +885,6 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
|
||||||
return await lockManager.withLock(
|
|
||||||
`rebuild-client-associations:client:${client.clientId}`,
|
|
||||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildClientAssociationsFromClientImpl(
|
|
||||||
client: Client,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1235,12 +1157,6 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
const totalSitesOnClient = await trx
|
|
||||||
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
|
||||||
.then((rows) => Number(rows[0].count));
|
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1301,14 +1217,7 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalSitesOnClient > 250) {
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
|
||||||
logger.info(
|
|
||||||
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1336,24 +1245,9 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(exitNodeJobs).catch((error) => {
|
await Promise.all(exitNodeJobs);
|
||||||
logger.error(
|
await Promise.all(newtJobs);
|
||||||
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
await Promise.all(olmJobs);
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(newtJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(olmJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1634,195 +1528,3 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClientAssociationsCacheVerification = {
|
|
||||||
clientId: number;
|
|
||||||
consistent: boolean;
|
|
||||||
// What permissions say the cache should contain
|
|
||||||
expectedSiteResourceIds: number[];
|
|
||||||
expectedSiteIds: number[];
|
|
||||||
// What the cache currently contains
|
|
||||||
actualSiteResourceIds: number[];
|
|
||||||
actualSiteIds: number[];
|
|
||||||
// Diff
|
|
||||||
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
|
||||||
extraSiteResourceIds: number[]; // present in cache, not in expected
|
|
||||||
missingSiteIds: number[];
|
|
||||||
extraSiteIds: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
|
||||||
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
|
||||||
// returns the expected vs actual cache contents and a boolean indicating
|
|
||||||
// whether the cache is in sync with what permissions imply.
|
|
||||||
export async function verifyClientAssociationsCache(
|
|
||||||
client: Client,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<ClientAssociationsCacheVerification> {
|
|
||||||
let newSiteResourceIds: number[] = [];
|
|
||||||
|
|
||||||
// 1. Direct client associations
|
|
||||||
const directSiteResources = await trx
|
|
||||||
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
|
||||||
.from(clientSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientSiteResources.clientId, client.clientId),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...directSiteResources.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. User-based and role-based access (if client has a userId)
|
|
||||||
if (client.userId) {
|
|
||||||
const userSiteResourceIds = await trx
|
|
||||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
|
||||||
.from(userSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
userSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userSiteResources.userId, client.userId),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...userSiteResourceIds.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const roleIds = await trx
|
|
||||||
.select({ roleId: userOrgRoles.roleId })
|
|
||||||
.from(userOrgRoles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userOrgRoles.userId, client.userId),
|
|
||||||
eq(userOrgRoles.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then((rows) => rows.map((row) => row.roleId));
|
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
|
||||||
const roleSiteResourceIds = await trx
|
|
||||||
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
|
||||||
.from(roleSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
roleSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(roleSiteResources.roleId, roleIds),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
|
||||||
|
|
||||||
const newSiteResources =
|
|
||||||
newSiteResourceIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const networkIds = Array.from(
|
|
||||||
new Set(
|
|
||||||
newSiteResources
|
|
||||||
.map((sr) => sr.networkId)
|
|
||||||
.filter((id): id is number => id !== null)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const newSiteIds =
|
|
||||||
networkIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select({ siteId: siteNetworks.siteId })
|
|
||||||
.from(siteNetworks)
|
|
||||||
.where(inArray(siteNetworks.networkId, networkIds))
|
|
||||||
.then((rows) =>
|
|
||||||
Array.from(new Set(rows.map((r) => r.siteId)))
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Read the existing cache state
|
|
||||||
const existingResourceAssociations = await trx
|
|
||||||
.select({
|
|
||||||
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
|
||||||
})
|
|
||||||
.from(clientSiteResourcesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
|
||||||
);
|
|
||||||
const existingSiteResourceIds = existingResourceAssociations.map(
|
|
||||||
(r) => r.siteResourceId
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingSiteAssociations = await trx
|
|
||||||
.select({ siteId: clientSitesAssociationsCache.siteId })
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
|
||||||
|
|
||||||
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
|
||||||
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
|
||||||
const expectedSiteSet = new Set(newSiteIds);
|
|
||||||
const actualSiteSet = new Set(existingSiteIds);
|
|
||||||
|
|
||||||
const missingSiteResourceIds = newSiteResourceIds.filter(
|
|
||||||
(id) => !actualSiteResourceSet.has(id)
|
|
||||||
);
|
|
||||||
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
|
||||||
(id) => !expectedSiteResourceSet.has(id)
|
|
||||||
);
|
|
||||||
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
|
||||||
const extraSiteIds = existingSiteIds.filter(
|
|
||||||
(id) => !expectedSiteSet.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const consistent =
|
|
||||||
missingSiteResourceIds.length === 0 &&
|
|
||||||
extraSiteResourceIds.length === 0 &&
|
|
||||||
missingSiteIds.length === 0 &&
|
|
||||||
extraSiteIds.length === 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
clientId: client.clientId,
|
|
||||||
consistent,
|
|
||||||
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
|
||||||
(a, b) => a - b
|
|
||||||
),
|
|
||||||
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
|
||||||
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
|
||||||
(a, b) => a - b
|
|
||||||
),
|
|
||||||
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
|
||||||
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
|
||||||
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
|
||||||
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
|
||||||
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = await cache.keysWithPrefix(prefix);
|
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
import { redisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,147 +298,3 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
/**
|
|
||||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
|
||||||
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
|
||||||
* Use this for data that is regional in nature (e.g. status history) so
|
|
||||||
* reads are served from the same cluster the user is hitting.
|
|
||||||
*/
|
|
||||||
const regionalLocalCache = new NodeCache({
|
|
||||||
stdTTL: 3600,
|
|
||||||
checkperiod: 120,
|
|
||||||
maxKeys: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
class RegionalAdaptiveCache {
|
|
||||||
private useRedis(): boolean {
|
|
||||||
return (
|
|
||||||
regionalRedisManager.isRedisEnabled() &&
|
|
||||||
regionalRedisManager.getHealthStatus().isHealthy
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
|
||||||
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
|
||||||
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
|
||||||
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
const success = await regionalRedisManager.set(
|
|
||||||
key,
|
|
||||||
serialized,
|
|
||||||
redisTtl
|
|
||||||
);
|
|
||||||
if (success) {
|
|
||||||
logger.debug(`[regional] Set key in Redis: ${key}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis set error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
|
||||||
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T = any>(key: string): Promise<T | undefined> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const value = await regionalRedisManager.get(key);
|
|
||||||
if (value !== null) {
|
|
||||||
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
}
|
|
||||||
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis get error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = regionalLocalCache.get<T>(key);
|
|
||||||
if (value !== undefined) {
|
|
||||||
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
|
||||||
} else {
|
|
||||||
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async del(key: string | string[]): Promise<number> {
|
|
||||||
const keys = Array.isArray(key) ? key : [key];
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
for (const k of keys) {
|
|
||||||
const success = await regionalRedisManager.del(k);
|
|
||||||
if (success) {
|
|
||||||
deletedCount++;
|
|
||||||
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (deletedCount === keys.length) return deletedCount;
|
|
||||||
deletedCount = 0;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[regional] Redis del error:`, error);
|
|
||||||
deletedCount = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const k of keys) {
|
|
||||||
const count = regionalLocalCache.del(k);
|
|
||||||
if (count > 0) {
|
|
||||||
deletedCount++;
|
|
||||||
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
async has(key: string): Promise<boolean> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const value = await regionalRedisManager.get(key);
|
|
||||||
return value !== null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis has error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regionalLocalCache.has(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns keys matching the given prefix from whichever backend is active.
|
|
||||||
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
|
||||||
*/
|
|
||||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
return await regionalRedisManager.keys(`${prefix}*`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[regional] Redis keys error:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentBackend(): "redis" | "local" {
|
|
||||||
return this.useRedis() ? "redis" : "local";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const regionalCache = new RegionalAdaptiveCache();
|
|
||||||
|
|||||||
@@ -73,25 +73,6 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.optional(),
|
|
||||||
regional_redis: z
|
|
||||||
.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: portSchema,
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
|
||||||
db: z.int().nonnegative().optional().default(0),
|
|
||||||
tls: z
|
|
||||||
.object({
|
|
||||||
rejectUnauthorized: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
})
|
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -855,163 +855,3 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
|
||||||
* Connects only when `redis.regional_redis` is present in the private config
|
|
||||||
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
|
||||||
* caching of regionally-scoped data.
|
|
||||||
*/
|
|
||||||
class RegionalRedisManager {
|
|
||||||
private writeClient: Redis | null = null;
|
|
||||||
private readClient: Redis | null = null;
|
|
||||||
private isEnabled: boolean = false;
|
|
||||||
private isHealthy: boolean = false;
|
|
||||||
private connectionTimeout: number = 5000;
|
|
||||||
private commandTimeout: number = 5000;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (build === "oss") return;
|
|
||||||
|
|
||||||
const cfg = privateConfig.getRawPrivateConfig();
|
|
||||||
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
|
||||||
|
|
||||||
this.isEnabled = true;
|
|
||||||
this.initializeClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConfig(): RedisOptions {
|
|
||||||
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
|
||||||
const opts: RedisOptions = {
|
|
||||||
host: r.host,
|
|
||||||
port: r.port,
|
|
||||||
password: r.password,
|
|
||||||
db: r.db
|
|
||||||
};
|
|
||||||
if (r.tls) {
|
|
||||||
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeClients(): void {
|
|
||||||
const cfg = this.getConfig();
|
|
||||||
const baseOpts = {
|
|
||||||
...cfg,
|
|
||||||
enableReadyCheck: false,
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
keepAlive: 10000,
|
|
||||||
connectTimeout: this.connectionTimeout,
|
|
||||||
commandTimeout: this.commandTimeout
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.writeClient = new Redis(baseOpts);
|
|
||||||
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
|
||||||
this.readClient = new Redis({
|
|
||||||
...baseOpts,
|
|
||||||
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
|
||||||
// Derive replica hostname from the headless service pattern:
|
|
||||||
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
|
||||||
// If it doesn't look like a k8s service, just use the same host
|
|
||||||
return h + rest;
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// For simplicity use same host for both; callers can always read from primary
|
|
||||||
// The real replica routing is handled by the StatefulSet headless service
|
|
||||||
this.readClient = this.writeClient;
|
|
||||||
|
|
||||||
this.writeClient.on("ready", () => {
|
|
||||||
logger.info("Regional Redis client ready");
|
|
||||||
this.isHealthy = true;
|
|
||||||
});
|
|
||||||
this.writeClient.on("error", (err) => {
|
|
||||||
logger.error("Regional Redis client error:", err);
|
|
||||||
this.isHealthy = false;
|
|
||||||
});
|
|
||||||
this.writeClient.on("reconnecting", () => {
|
|
||||||
logger.info("Regional Redis client reconnecting...");
|
|
||||||
this.isHealthy = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("Regional Redis client initialized");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to initialize regional Redis client:", error);
|
|
||||||
this.isEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public isRedisEnabled(): boolean {
|
|
||||||
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHealthStatus() {
|
|
||||||
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set(
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
ttl?: number
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
|
||||||
try {
|
|
||||||
if (ttl) {
|
|
||||||
await this.writeClient.setex(key, ttl, value);
|
|
||||||
} else {
|
|
||||||
await this.writeClient.set(key, value);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis SET error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get(key: string): Promise<string | null> {
|
|
||||||
if (!this.isRedisEnabled() || !this.readClient) return null;
|
|
||||||
try {
|
|
||||||
return await this.readClient.get(key);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis GET error:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async del(key: string): Promise<boolean> {
|
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
|
||||||
try {
|
|
||||||
await this.writeClient.del(key);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis DEL error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async keys(pattern: string): Promise<string[]> {
|
|
||||||
if (!this.isRedisEnabled() || !this.readClient) return [];
|
|
||||||
try {
|
|
||||||
return await this.readClient.keys(pattern);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis KEYS error:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (this.writeClient) {
|
|
||||||
await this.writeClient.quit();
|
|
||||||
this.writeClient = null;
|
|
||||||
}
|
|
||||||
this.readClient = null;
|
|
||||||
logger.info("Regional Redis client disconnected");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error disconnecting regional Redis client:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const regionalRedisManager = new RegionalRedisManager();
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
|
|||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
import * as client from "@server/routers/client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -830,15 +829,3 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/client/:clientId/verify-associations-cache",
|
|
||||||
verifyClientAccess,
|
|
||||||
client.verifyClientAssociationsCache
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/client/:clientId/rebuild-associations-cache",
|
|
||||||
verifyClientAccess,
|
|
||||||
client.rebuildClientAssociationsCacheRoute
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -34,9 +35,78 @@ const paramsSchema = z.strictObject({
|
|||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
|
.union([
|
||||||
|
z.literal(""),
|
||||||
|
z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.superRefine(async (urlOrPath, ctx) => {
|
||||||
.transform((val) => (val === "" ? null : val)),
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlOrPath, {
|
||||||
|
method: "HEAD"
|
||||||
|
}).catch(() => {
|
||||||
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
|
return fetch(urlOrPath, { method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Failed to load image. Please check that the URL is accessible.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.startsWith("image/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage =
|
||||||
|
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||||
|
|
||||||
|
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||||
|
errorMessage =
|
||||||
|
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = `Error verifying URL: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.transform((val) => (val === "" ? null : val))
|
||||||
|
.nullish(),
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { db, exitNodes, newts, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import redisManager from "#private/lib/redis";
|
||||||
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
|
||||||
|
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||||
|
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||||
|
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
||||||
|
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
||||||
|
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||||
|
|
||||||
|
interface PendingReconnect {
|
||||||
|
startTime: number;
|
||||||
|
reachableAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory tracking for this node
|
||||||
|
const pendingReconnects = new Map<number, PendingReconnect>();
|
||||||
|
|
||||||
|
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a reconnect check for newts connected to the given exit node.
|
||||||
|
* Called when an exit node transitions from offline to online.
|
||||||
|
*/
|
||||||
|
export async function scheduleExitNodeReconnect(
|
||||||
|
exitNodeId: number,
|
||||||
|
reachableAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info(
|
||||||
|
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry: PendingReconnect = {
|
||||||
|
startTime: Date.now(),
|
||||||
|
reachableAt
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingReconnects.set(exitNodeId, entry);
|
||||||
|
|
||||||
|
// Store in Redis if available for cross-node coordination
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
||||||
|
await redisManager.hset(
|
||||||
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
|
"startTime",
|
||||||
|
entry.startTime.toString()
|
||||||
|
);
|
||||||
|
await redisManager.hset(
|
||||||
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
|
"reachableAt",
|
||||||
|
reachableAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the background interval that checks pending exit node reconnects.
|
||||||
|
*/
|
||||||
|
export function startExitNodeReconnectScheduler(): void {
|
||||||
|
if (schedulerInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulerInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await processPendingReconnects();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in exit node reconnect scheduler", { error });
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
logger.debug("Started exit node reconnect scheduler");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPendingReconnects(): Promise<void> {
|
||||||
|
// Merge in-memory and Redis-tracked pending reconnects
|
||||||
|
const toProcess = new Map(pendingReconnects);
|
||||||
|
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
||||||
|
for (const idStr of redisIds) {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (!toProcess.has(id)) {
|
||||||
|
const startTimeStr = await redisManager.hget(
|
||||||
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
|
"startTime"
|
||||||
|
);
|
||||||
|
const reachableAt = await redisManager.hget(
|
||||||
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
|
"reachableAt"
|
||||||
|
);
|
||||||
|
if (startTimeStr && reachableAt) {
|
||||||
|
toProcess.set(id, {
|
||||||
|
startTime: parseInt(startTimeStr, 10),
|
||||||
|
reachableAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [exitNodeId, entry] of toProcess) {
|
||||||
|
const elapsed = now - entry.startTime;
|
||||||
|
|
||||||
|
// Give up after max duration
|
||||||
|
if (elapsed >= MAX_DURATION_MS) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
||||||
|
);
|
||||||
|
await removePending(exitNodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect initial delay
|
||||||
|
if (elapsed < INITIAL_DELAY_MS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the exit node HTTP endpoint is reachable
|
||||||
|
const pingUrl = `${entry.reachableAt}/ping`;
|
||||||
|
try {
|
||||||
|
await axios.get(pingUrl, { timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
logger.debug(
|
||||||
|
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is reachable — send reconnect to all connected newts
|
||||||
|
logger.info(
|
||||||
|
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendReconnectToNewts(exitNodeId);
|
||||||
|
await removePending(exitNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const connectedNewts = await db
|
||||||
|
.select({ newtId: newts.newtId })
|
||||||
|
.from(newts)
|
||||||
|
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||||
|
.where(eq(sites.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
|
if (connectedNewts.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const reconnectMessage = {
|
||||||
|
type: "newt/wg/reconnect",
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
connectedNewts.map(({ newtId }) =>
|
||||||
|
sendToClient(newtId, reconnectMessage)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePending(exitNodeId: number): Promise<void> {
|
||||||
|
pendingReconnects.delete(exitNodeId);
|
||||||
|
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
||||||
|
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch the current state before updating so we can detect the offline→online transition
|
||||||
|
const [currentExitNode] = await db
|
||||||
|
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
// Update the exit node's last ping timestamp
|
// Update the exit node's last ping timestamp
|
||||||
await db
|
await db
|
||||||
.update(exitNodes)
|
.update(exitNodes)
|
||||||
@@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
online: true
|
online: true
|
||||||
})
|
})
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
|
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||||
|
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||||
|
scheduleExitNodeReconnect(
|
||||||
|
remoteExitNode.exitNodeId,
|
||||||
|
currentExitNode.reachableAt
|
||||||
|
).catch((error) => {
|
||||||
|
logger.error("Failed to schedule exit node reconnect", { error });
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes";
|
|||||||
export * from "./pickRemoteExitNodeDefaults";
|
export * from "./pickRemoteExitNodeDefaults";
|
||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
|
export * from "./exitNodeReconnectScheduler";
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import {
|
import {
|
||||||
handleRemoteExitNodeRegisterMessage,
|
handleRemoteExitNodeRegisterMessage,
|
||||||
handleRemoteExitNodePingMessage,
|
handleRemoteExitNodePingMessage,
|
||||||
startRemoteExitNodeOfflineChecker
|
startRemoteExitNodeOfflineChecker,
|
||||||
|
startExitNodeReconnectScheduler
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -29,4 +30,5 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
|
|
||||||
if (build != "saas") {
|
if (build != "saas") {
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
|
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
export * from "./verifyClientAssociationsCache";
|
|
||||||
export * from "./rebuildClientAssociationsCacheRoute";
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { clients } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "post",
|
|
||||||
path: "/client/{clientId}/rebuild-associations-cache",
|
|
||||||
description:
|
|
||||||
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
|
||||||
tags: [OpenAPITags.Client],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function rebuildClientAssociationsCacheRoute(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with ID ${clientId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Client association cache rebuilt successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to rebuild client association cache"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { clients } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "get",
|
|
||||||
path: "/client/{clientId}/verify-associations-cache",
|
|
||||||
description:
|
|
||||||
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
|
||||||
tags: [OpenAPITags.Client],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function verifyClientAssociationsCache(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with ID ${clientId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = await verifyClientAssociationsCacheLib(client);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: report,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: report.consistent
|
|
||||||
? "Client association cache is consistent"
|
|
||||||
: "Client association cache is INCONSISTENT",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to verify client association cache"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,65 +153,6 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [cacheCheck, setCacheCheck] = useState<null | {
|
|
||||||
consistent: boolean;
|
|
||||||
missingSiteResourceIds: number[];
|
|
||||||
extraSiteResourceIds: number[];
|
|
||||||
missingSiteIds: number[];
|
|
||||||
extraSiteIds: number[];
|
|
||||||
expectedSiteResourceIds: number[];
|
|
||||||
actualSiteResourceIds: number[];
|
|
||||||
expectedSiteIds: number[];
|
|
||||||
actualSiteIds: number[];
|
|
||||||
}>(null);
|
|
||||||
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
|
||||||
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
|
||||||
|
|
||||||
const handleRebuildCache = async () => {
|
|
||||||
if (!client.clientId) return;
|
|
||||||
setIsRebuildingCache(true);
|
|
||||||
try {
|
|
||||||
await api.post(
|
|
||||||
`/client/${client.clientId}/rebuild-associations-cache`
|
|
||||||
);
|
|
||||||
// Re-verify after rebuild so the result refreshes
|
|
||||||
const res = await api.get(
|
|
||||||
`/client/${client.clientId}/verify-associations-cache`
|
|
||||||
);
|
|
||||||
setCacheCheck(res.data.data);
|
|
||||||
toast({
|
|
||||||
title: "Cache rebuilt",
|
|
||||||
description: "Association cache rebuilt successfully."
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Rebuild failed",
|
|
||||||
description: formatAxiosError(e, "Failed to rebuild cache")
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRebuildingCache(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifyCache = async () => {
|
|
||||||
if (!client.clientId) return;
|
|
||||||
setIsCheckingCache(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(
|
|
||||||
`/client/${client.clientId}/verify-associations-cache`
|
|
||||||
);
|
|
||||||
setCacheCheck(res.data.data);
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Cache check failed",
|
|
||||||
description: formatAxiosError(e, "Failed to verify cache")
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCheckingCache(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -903,75 +844,6 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
|
||||||
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleVerifyCache}
|
|
||||||
disabled={isCheckingCache}
|
|
||||||
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
|
||||||
title="Verify the client's site association cache against current permissions (read-only)"
|
|
||||||
>
|
|
||||||
{isCheckingCache
|
|
||||||
? "Checking cache…"
|
|
||||||
: "Verify association cache"}
|
|
||||||
</button>
|
|
||||||
{cacheCheck && (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"text-xs rounded border px-2 py-1 " +
|
|
||||||
(cacheCheck.consistent
|
|
||||||
? "border-green-600 text-green-700"
|
|
||||||
: "border-red-600 text-red-700")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cacheCheck.consistent ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
|
||||||
Cache is consistent
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1 font-semibold">
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
Cache is INCONSISTENT
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Missing site resources: [
|
|
||||||
{cacheCheck.missingSiteResourceIds.join(
|
|
||||||
", "
|
|
||||||
)}
|
|
||||||
]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Extra site resources: [
|
|
||||||
{cacheCheck.extraSiteResourceIds.join(", ")}
|
|
||||||
]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Missing sites: [
|
|
||||||
{cacheCheck.missingSiteIds.join(", ")}]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Extra sites: [
|
|
||||||
{cacheCheck.extraSiteIds.join(", ")}]
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRebuildCache}
|
|
||||||
disabled={isRebuildingCache}
|
|
||||||
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isRebuildingCache
|
|
||||||
? "Rebuilding…"
|
|
||||||
: "Rebuild cache now"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useTransition, useMemo } from "react";
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,9 +20,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,8 +30,23 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -49,21 +61,40 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,95 +103,75 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
const defaultRange = getDefaultDateRange();
|
||||||
|
queryDateTime(
|
||||||
if (dateRange.startDate?.date) {
|
defaultRange.startDate,
|
||||||
const dt = new Date(dateRange.startDate.date);
|
defaultRange.endDate,
|
||||||
if (dateRange.startDate.time) {
|
0,
|
||||||
const [h, m, s] = dateRange.startDate.time
|
pageSize
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
|
||||||
timeStart = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateRange.endDate?.date) {
|
|
||||||
const dt = new Date(dateRange.endDate.date);
|
|
||||||
if (dateRange.endDate.time) {
|
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
resourceId: filters.resourceId
|
|
||||||
? Number(filters.resourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.access({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -182,8 +193,114 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
location?: string;
|
||||||
|
actor?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -199,6 +316,7 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -216,6 +334,7 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -232,7 +351,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -247,7 +366,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
@@ -260,6 +379,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -276,11 +396,13 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: () => t("ip")
|
header: ({ column }) => {
|
||||||
|
return t("ip");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("location")}</span>
|
<span>{t("location")}</span>
|
||||||
@@ -295,6 +417,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -319,7 +442,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -332,6 +455,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -357,7 +481,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("type")}</span>
|
<span>{t("type")}</span>
|
||||||
@@ -376,6 +500,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -393,7 +518,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -406,6 +531,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -433,12 +559,16 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => t("actorId"),
|
header: ({ column }) => {
|
||||||
cell: ({ row }) => (
|
return t("actorId");
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{row.original.actorId || "-"}
|
{row.original.actorId || "-"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -484,10 +614,13 @@ export default function GeneralPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
|
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||||
|
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
|
||||||
|
// }
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
start: dateRange.startDate,
|
start: dateRange.startDate,
|
||||||
@@ -497,12 +630,14 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||||
@@ -510,41 +645,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
|
||||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
|
||||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com",
|
|
||||||
null
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const action = Math.random() > 0.3;
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action,
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: actor ? "user" : null,
|
|
||||||
actor,
|
|
||||||
actorId: actor ? `user-${i}` : null,
|
|
||||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
|
||||||
location: locations[Math.floor(Math.random() * locations.length)],
|
|
||||||
userAgent: "Mozilla/5.0",
|
|
||||||
metadata: null,
|
|
||||||
type: types[Math.floor(Math.random() * types.length)]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Key, User } from "lucide-react";
|
import { Key, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -31,8 +28,18 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
actions: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
actions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -41,21 +48,40 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,90 +90,78 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.action({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = {
|
|
||||||
actors: data?.filterAttributes?.actors ?? []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -169,8 +183,110 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
actor?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.actionLogs)) {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -186,6 +302,7 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -203,6 +320,7 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -219,7 +337,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -234,16 +352,22 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
<ColumnFilter
|
<ColumnFilter
|
||||||
options={[]}
|
options={filterAttributes.actions.map((action) => ({
|
||||||
|
label:
|
||||||
|
action.charAt(0).toUpperCase() +
|
||||||
|
action.slice(1),
|
||||||
|
value: action
|
||||||
|
}))}
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -261,7 +385,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -274,6 +398,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -295,7 +420,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("actorId");
|
return t("actorId");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -344,9 +469,12 @@ export default function GeneralPage() {
|
|||||||
title={t("actionLogs")}
|
title={t("actionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="action"
|
searchColumn="action"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
|
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||||
|
// !isPaidUser(tierMatrix.logExport) || build === "oss"
|
||||||
|
// }
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
@@ -357,12 +485,14 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||||
@@ -370,39 +500,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
|
||||||
const actions = [
|
|
||||||
"createResource",
|
|
||||||
"deleteResource",
|
|
||||||
"updateResource",
|
|
||||||
"createSite",
|
|
||||||
"deleteSite",
|
|
||||||
"inviteUser",
|
|
||||||
"removeUser"
|
|
||||||
];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com"
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action: actions[Math.floor(Math.random() * actions.length)],
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: "user",
|
|
||||||
actor,
|
|
||||||
actorId: `user-${i}`,
|
|
||||||
metadata: null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,20 +9,26 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null): string {
|
||||||
|
if (bytes === null || bytes === undefined) return "-";
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||||
if (endedAt === null || endedAt === undefined) return "Active";
|
if (endedAt === null || endedAt === undefined) return "Active";
|
||||||
@@ -48,8 +54,24 @@ export default function ConnectionLogsPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
protocols: string[];
|
||||||
|
destAddrs: string[];
|
||||||
|
clients: { id: number; name: string }[];
|
||||||
|
resources: { id: number; name: string | null }[];
|
||||||
|
users: { id: string; email: string | null }[];
|
||||||
|
}>({
|
||||||
|
protocols: [],
|
||||||
|
destAddrs: [],
|
||||||
|
clients: [],
|
||||||
|
resources: [],
|
||||||
|
users: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
destAddr?: string;
|
destAddr?: string;
|
||||||
@@ -64,24 +86,43 @@ export default function ConnectionLogsPage() {
|
|||||||
userId: searchParams.get("userId") || undefined
|
userId: searchParams.get("userId") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize(
|
const [pageSize, setPageSize] = useStoredPageSize(
|
||||||
"connection-audit-logs",
|
"connection-audit-logs",
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set default date range to last 7 days
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,100 +131,78 @@ export default function ConnectionLogsPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
clientId: filters.clientId ? Number(filters.clientId) : undefined,
|
|
||||||
siteResourceId: filters.siteResourceId
|
|
||||||
? Number(filters.siteResourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.connection({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading
|
|
||||||
? generateSampleConnectionLogs()
|
|
||||||
: (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
protocols: [],
|
|
||||||
destAddrs: [],
|
|
||||||
clients: [],
|
|
||||||
resources: [],
|
|
||||||
users: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -205,8 +224,109 @@ export default function ConnectionLogsPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: typeof filters
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched connection logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(error),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -225,6 +345,7 @@ export default function ConnectionLogsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -242,6 +363,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -258,7 +380,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "startedAt",
|
accessorKey: "startedAt",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -273,7 +395,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("protocol")}</span>
|
<span>{t("protocol")}</span>
|
||||||
@@ -304,7 +426,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -345,7 +467,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "clientName",
|
accessorKey: "clientName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("client")}</span>
|
<span>{t("client")}</span>
|
||||||
@@ -388,7 +510,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("user")}</span>
|
<span>{t("user")}</span>
|
||||||
@@ -421,7 +543,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "sourceAddr",
|
accessorKey: "sourceAddr",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("sourceAddress");
|
return t("sourceAddress");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -434,7 +556,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destAddr",
|
accessorKey: "destAddr",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("destinationAddress")}</span>
|
<span>{t("destinationAddress")}</span>
|
||||||
@@ -463,7 +585,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("duration");
|
return t("duration");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -484,6 +606,9 @@ export default function ConnectionLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Connection Details
|
||||||
|
</div>*/}
|
||||||
<div>
|
<div>
|
||||||
<strong>Session ID:</strong>{" "}
|
<strong>Session ID:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -508,6 +633,18 @@ export default function ConnectionLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Resource & Site
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Resource:</strong>{" "}
|
||||||
|
{row.resourceName ?? "-"}
|
||||||
|
{row.resourceNiceId && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({row.resourceNiceId})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>*/}
|
||||||
<div>
|
<div>
|
||||||
<strong>Client Endpoint:</strong>{" "}
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -543,8 +680,30 @@ export default function ConnectionLogsPage() {
|
|||||||
<strong>Duration:</strong>{" "}
|
<strong>Duration:</strong>{" "}
|
||||||
{formatDuration(row.startedAt, row.endedAt)}
|
{formatDuration(row.startedAt, row.endedAt)}
|
||||||
</div>
|
</div>
|
||||||
|
{/*<div>
|
||||||
|
<strong>Resource ID:</strong>{" "}
|
||||||
|
{row.siteResourceId ?? "-"}
|
||||||
|
</div>*/}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Client & Transfer
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Bytes Sent (TX):</strong>{" "}
|
||||||
|
{formatBytes(row.bytesTx)}
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Bytes Received (RX):</strong>{" "}
|
||||||
|
{formatBytes(row.bytesRx)}
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Total Transfer:</strong>{" "}
|
||||||
|
{formatBytes(
|
||||||
|
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
||||||
|
)}
|
||||||
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -565,8 +724,8 @@ export default function ConnectionLogsPage() {
|
|||||||
title={t("connectionLogs")}
|
title={t("connectionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="protocol"
|
searchColumn="protocol"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -578,12 +737,14 @@ export default function ConnectionLogsPage() {
|
|||||||
id: "startedAt",
|
id: "startedAt",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -593,49 +754,3 @@ export default function ConnectionLogsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
|
||||||
const protocols = ["tcp", "udp", "icmp"];
|
|
||||||
const destAddrs = [
|
|
||||||
"10.0.0.1:22",
|
|
||||||
"10.0.0.2:80",
|
|
||||||
"10.0.0.3:443",
|
|
||||||
"192.168.1.10:3306"
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const startedAt = Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
);
|
|
||||||
const active = Math.random() > 0.3;
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: `session-${i}`,
|
|
||||||
siteResourceId: (i % 3) + 1,
|
|
||||||
orgId: "sample-org",
|
|
||||||
siteId: 1,
|
|
||||||
clientId: (i % 4) + 1,
|
|
||||||
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
|
||||||
userId: i % 2 === 0 ? `user-${i}` : null,
|
|
||||||
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
|
||||||
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
|
||||||
protocol:
|
|
||||||
protocols[Math.floor(Math.random() * protocols.length)],
|
|
||||||
startedAt,
|
|
||||||
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
|
||||||
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
|
||||||
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
siteName: "Sample Site",
|
|
||||||
siteNiceId: "sample-site",
|
|
||||||
clientName: `Client ${(i % 4) + 1}`,
|
|
||||||
clientNiceId: `client-${(i % 4) + 1}`,
|
|
||||||
clientType: i % 2 === 0 ? "user" : "machine",
|
|
||||||
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,17 +9,14 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,11 +25,36 @@ export default function GeneralPage() {
|
|||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
||||||
|
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
hosts: string[];
|
||||||
|
paths: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: [],
|
||||||
|
hosts: [],
|
||||||
|
paths: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -53,18 +75,32 @@ export default function GeneralPage() {
|
|||||||
path: searchParams.get("path") || undefined
|
path: searchParams.get("path") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,97 +109,80 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
resourceId: filters.resourceId
|
|
||||||
? Number(filters.resourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.requests({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: [],
|
|
||||||
hosts: [],
|
|
||||||
paths: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
console.log(`${filterType} filter changed:`, value);
|
||||||
|
|
||||||
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -185,6 +204,101 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
// Prepare query params for export
|
||||||
@@ -667,8 +781,8 @@ export default function GeneralPage() {
|
|||||||
title={t("requestLogs")}
|
title={t("requestLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="host"
|
searchColumn="host"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -680,6 +794,7 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@@ -693,63 +808,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
|
|
||||||
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
|
||||||
const paths = [
|
|
||||||
"/api/v1/users",
|
|
||||||
"/dashboard",
|
|
||||||
"/settings",
|
|
||||||
"/health",
|
|
||||||
"/metrics"
|
|
||||||
];
|
|
||||||
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
|
|
||||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
|
||||||
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
|
||||||
const deniedReasons = [201, 202, 203, 204, 205, 299];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com",
|
|
||||||
null
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const action = Math.random() > 0.3;
|
|
||||||
const reason = action
|
|
||||||
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
|
|
||||||
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action,
|
|
||||||
reason,
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: actor ? "user" : null,
|
|
||||||
actor,
|
|
||||||
actorId: actor ? `user-${i}` : null,
|
|
||||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
|
||||||
siteResourceId: null,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
|
||||||
location: locations[Math.floor(Math.random() * locations.length)],
|
|
||||||
userAgent: "Mozilla/5.0",
|
|
||||||
metadata: null,
|
|
||||||
headers: null,
|
|
||||||
query: null,
|
|
||||||
originalRequestURL: null,
|
|
||||||
scheme: "https",
|
|
||||||
host: hosts[Math.floor(Math.random() * hosts.length)],
|
|
||||||
path: paths[Math.floor(Math.random() * paths.length)],
|
|
||||||
method: methods[Math.floor(Math.random() * methods.length)],
|
|
||||||
tls: true
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,11 +44,77 @@ export type AuthPageCustomizationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z
|
logoUrl: z.union([
|
||||||
.string()
|
z.literal(""),
|
||||||
.optional()
|
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||||
.transform((val) => (val === "" ? undefined : val)),
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlOrPath, {
|
||||||
|
method: "HEAD"
|
||||||
|
}).catch(() => {
|
||||||
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
|
return fetch(urlOrPath, { method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Failed to load image. Please check that the URL is accessible.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.startsWith("image/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage =
|
||||||
|
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof TypeError &&
|
||||||
|
error.message.includes("fetch")
|
||||||
|
) {
|
||||||
|
errorMessage =
|
||||||
|
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = `Error verifying URL: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]),
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
orgTitle: z.string().optional(),
|
orgTitle: z.string().optional(),
|
||||||
|
|||||||
@@ -318,28 +318,12 @@ export default function DeviceLoginForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={8}
|
maxLength={9}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value
|
value={field.value
|
||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
onPaste={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const pastedText =
|
|
||||||
event.clipboardData.getData(
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
const cleanedValue =
|
|
||||||
pastedText
|
|
||||||
.replace(
|
|
||||||
/[^a-zA-Z0-9]/g,
|
|
||||||
""
|
|
||||||
)
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 8);
|
|
||||||
field.onChange(cleanedValue);
|
|
||||||
}}
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// Strip hyphens and convert to uppercase
|
// Strip hyphens and convert to uppercase
|
||||||
const cleanedValue = value
|
const cleanedValue = value
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Loader,
|
Loader,
|
||||||
LoaderIcon,
|
|
||||||
RefreshCw
|
RefreshCw
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -428,7 +427,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="relative">
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -536,19 +535,6 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<>
|
|
||||||
<div className="backdrop-blur-[3px] z-10 absolute inset-0 top-10"></div>
|
|
||||||
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
|
|
||||||
<div className="flex items-center gap-2 p-6">
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
{t("loadingEllipsis")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
@@ -46,20 +46,6 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
|
||||||
if (!value?.trim()) return true;
|
|
||||||
|
|
||||||
const commands = value
|
|
||||||
.split(",")
|
|
||||||
.map((command) => command.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return commands.every((command) => {
|
|
||||||
const executable = command.split(/\s+/)[0];
|
|
||||||
return executable.startsWith("/");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoleFormValues = {
|
export type RoleFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -88,8 +74,7 @@ export function RoleForm({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z.object({
|
||||||
.object({
|
|
||||||
name: z
|
name: z
|
||||||
.string({ message: t("nameRequired") })
|
.string({ message: t("nameRequired") })
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -101,19 +86,6 @@ export function RoleForm({
|
|||||||
sshSudoCommands: z.string().optional(),
|
sshSudoCommands: z.string().optional(),
|
||||||
sshCreateHomeDir: z.boolean().optional(),
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
sshUnixGroups: z.string().optional()
|
sshUnixGroups: z.string().optional()
|
||||||
})
|
|
||||||
.superRefine((values, ctx) => {
|
|
||||||
if (
|
|
||||||
values.sshSudoMode === "commands" &&
|
|
||||||
!hasOnlyAbsoluteSudoCommands(values.sshSudoCommands)
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["sshSudoCommands"],
|
|
||||||
message:
|
|
||||||
"Each sudo command must start with an absolute path (for example, /usr/bin/systemctl)."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = role
|
const defaultValues: RoleFormValues = role
|
||||||
@@ -324,9 +296,7 @@ export function RoleForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="allowSsh"
|
name="allowSsh"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const allowSshOptions: OptionSelectOption<
|
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
||||||
"allow" | "disallow"
|
|
||||||
>[] = [
|
|
||||||
{
|
{
|
||||||
value: "allow",
|
value: "allow",
|
||||||
label: t("roleAllowSshAllow")
|
label: t("roleAllowSshAllow")
|
||||||
@@ -341,9 +311,7 @@ export function RoleForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("roleAllowSsh")}
|
{t("roleAllowSsh")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<OptionSelect<
|
<OptionSelect<"allow" | "disallow">
|
||||||
"allow" | "disallow"
|
|
||||||
>
|
|
||||||
options={allowSshOptions}
|
options={allowSshOptions}
|
||||||
value={
|
value={
|
||||||
sshDisabled
|
sshDisabled
|
||||||
@@ -354,9 +322,7 @@ export function RoleForm({
|
|||||||
}
|
}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (sshDisabled) return;
|
if (sshDisabled) return;
|
||||||
field.onChange(
|
field.onChange(v === "allow");
|
||||||
v === "allow"
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type {
|
|
||||||
QueryAccessAuditLogResponse,
|
|
||||||
QueryActionAuditLogResponse,
|
|
||||||
QueryConnectionAuditLogResponse,
|
|
||||||
QueryRequestAuditLogResponse
|
|
||||||
} from "@server/routers/auditLogs/types";
|
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
import type {
|
import type {
|
||||||
GetDNSRecordsResponse,
|
ListDomainsResponse,
|
||||||
ListDomainsResponse
|
GetDNSRecordsResponse
|
||||||
} from "@server/routers/domain";
|
} from "@server/routers/domain";
|
||||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
|
||||||
import type {
|
import type {
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
ListResourceNamesResponse,
|
ListResourceNamesResponse,
|
||||||
ListResourcesResponse
|
ListResourcesResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
|
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -39,6 +31,8 @@ import type { AxiosResponse } from "axios";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
@@ -595,111 +589,7 @@ export const logAnalyticsFiltersSchema = z.object({
|
|||||||
resourceId: z.coerce.number().optional().catch(undefined)
|
resourceId: z.coerce.number().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||||
|
|
||||||
export const httpLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
resourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
host: z.string().optional().catch(undefined),
|
|
||||||
location: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined),
|
|
||||||
method: z.string().optional().catch(undefined),
|
|
||||||
reason: z.string().optional().catch(undefined),
|
|
||||||
path: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const accessLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
resourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
location: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined),
|
|
||||||
type: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const actionLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ActionLogFilters = z.output<typeof actionLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const connectionLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
protocol: z.string().optional().catch(undefined),
|
|
||||||
destAddr: z.string().optional().catch(undefined),
|
|
||||||
clientId: z.coerce.number().optional().catch(undefined),
|
|
||||||
siteResourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
userId: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const logQueries = {
|
export const logQueries = {
|
||||||
requestAnalytics: ({
|
requestAnalytics: ({
|
||||||
@@ -710,7 +600,7 @@ export const logQueries = {
|
|||||||
filters: LogAnalyticsFilters;
|
filters: LogAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["REQUEST_LOGS", orgId, "ANALYTICS", filters] as const,
|
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryRequestAnalyticsResponse>
|
AxiosResponse<QueryRequestAnalyticsResponse>
|
||||||
@@ -726,124 +616,6 @@ export const logQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
|
|
||||||
requests: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: HttpLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryRequestAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/request`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
access: ({ orgId, filters }: { orgId: string; filters: AccessLogFilters }) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryAccessAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/access`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
action: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: ActionLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ACTION_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryActionAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/action`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
connection: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: ConnectionLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["CONNECTION_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryConnectionAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/connection`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user