mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-12 05:51:30 +02:00
Compare commits
17 Commits
87bcd8ec1b
...
1a3cf2094b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a3cf2094b | |||
| 09cb20a084 | |||
| d1fb2e19d3 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| af13790c93 | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| c500979099 | |||
| 81274960f6 |
@@ -1646,6 +1646,7 @@
|
||||
"certificateStatus": "Certificate",
|
||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||
"loading": "Loading",
|
||||
"loadingEllipsis": "Loading...",
|
||||
"loadingAnalytics": "Loading Analytics",
|
||||
"restart": "Restart",
|
||||
"domains": "Domains",
|
||||
|
||||
@@ -154,8 +154,19 @@ class AdaptiveCache {
|
||||
keys(): string[] {
|
||||
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 const cache = new AdaptiveCache();
|
||||
export const regionalCache = cache; // Alias for compatability with the private version
|
||||
export default cache;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { db, logsDb, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
|
||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
||||
entityId: number
|
||||
): Promise<void> {
|
||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
||||
const keys = await cache.keysWithPrefix(prefix);
|
||||
if (keys.length > 0) {
|
||||
await cache.del(keys);
|
||||
}
|
||||
|
||||
+145
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { redisManager } from "@server/private/lib/redis";
|
||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
||||
// Export singleton instance
|
||||
export const cache = new AdaptiveCache();
|
||||
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,6 +73,25 @@ export const privateConfigSchema = z
|
||||
.object({
|
||||
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(),
|
||||
|
||||
+164
-4
@@ -109,14 +109,14 @@ class RedisManager {
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db
|
||||
};
|
||||
|
||||
|
||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||
if (redisConfig.tls) {
|
||||
opts.tls = {
|
||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -135,14 +135,14 @@ class RedisManager {
|
||||
password: replica.password,
|
||||
db: replica.db || redisConfig.db
|
||||
};
|
||||
|
||||
|
||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||
if (redisConfig.tls) {
|
||||
opts.tls = {
|
||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -855,3 +855,163 @@ class RedisManager {
|
||||
export const redisManager = new RedisManager();
|
||||
export const redis = redisManager.getClient();
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -20,6 +20,9 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
@@ -30,23 +33,8 @@ export default function GeneralPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
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<{
|
||||
action?: string;
|
||||
type?: string;
|
||||
@@ -61,40 +49,21 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = 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);
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -103,75 +72,95 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
setCurrentPage(0);
|
||||
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 = (
|
||||
@@ -193,114 +182,8 @@ export default function GeneralPage() {
|
||||
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 () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -316,7 +199,6 @@ export default function GeneralPage() {
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -334,7 +216,6 @@ export default function GeneralPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -351,7 +232,7 @@ export default function GeneralPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -366,7 +247,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
@@ -379,7 +260,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -396,13 +276,11 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
header: () => t("ip")
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
@@ -417,7 +295,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -442,7 +319,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
@@ -455,7 +332,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -481,7 +357,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("type")}</span>
|
||||
@@ -500,7 +376,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -518,7 +393,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
@@ -531,7 +406,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -559,16 +433,12 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
header: () => t("actorId"),
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -614,13 +484,10 @@ export default function GeneralPage() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
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}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
@@ -630,14 +497,12 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||
@@ -645,3 +510,41 @@ 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,14 +10,17 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
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 axios from "axios";
|
||||
import { Key, User } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,18 +31,8 @@ export default function GeneralPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
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<{
|
||||
action?: string;
|
||||
actor?: string;
|
||||
@@ -48,40 +41,21 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = 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);
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,78 +64,90 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
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,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
setCurrentPage(0);
|
||||
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 = (
|
||||
@@ -183,110 +169,8 @@ export default function GeneralPage() {
|
||||
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 () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -302,7 +186,6 @@ export default function GeneralPage() {
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -320,7 +203,6 @@ export default function GeneralPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -337,7 +219,7 @@ export default function GeneralPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -352,22 +234,16 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actions.map((action) => ({
|
||||
label:
|
||||
action.charAt(0).toUpperCase() +
|
||||
action.slice(1),
|
||||
value: action
|
||||
}))}
|
||||
options={[]}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -385,7 +261,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
@@ -398,7 +274,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -420,7 +295,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -469,12 +344,9 @@ export default function GeneralPage() {
|
||||
title={t("actionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
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}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
@@ -485,14 +357,12 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||
@@ -500,3 +370,39 @@ 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,26 +9,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
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 axios from "axios";
|
||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
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]}`;
|
||||
}
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
|
||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||
if (endedAt === null || endedAt === undefined) return "Active";
|
||||
@@ -54,24 +48,8 @@ export default function ConnectionLogsPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
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<{
|
||||
protocol?: string;
|
||||
destAddr?: string;
|
||||
@@ -86,43 +64,24 @@ export default function ConnectionLogsPage() {
|
||||
userId: searchParams.get("userId") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = 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(
|
||||
"connection-audit-logs",
|
||||
20
|
||||
);
|
||||
|
||||
// Set default date range to last 7 days
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -131,78 +90,100 @@ export default function ConnectionLogsPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
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,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
setCurrentPage(0);
|
||||
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 = (
|
||||
@@ -224,109 +205,8 @@ export default function ConnectionLogsPage() {
|
||||
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 () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -345,7 +225,6 @@ export default function ConnectionLogsPage() {
|
||||
}
|
||||
);
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -363,7 +242,6 @@ export default function ConnectionLogsPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -380,7 +258,7 @@ export default function ConnectionLogsPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "startedAt",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -395,7 +273,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("protocol")}</span>
|
||||
@@ -426,7 +304,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
@@ -467,7 +345,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "clientName",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("client")}</span>
|
||||
@@ -510,7 +388,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("user")}</span>
|
||||
@@ -543,7 +421,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "sourceAddr",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("sourceAddress");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -556,7 +434,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "destAddr",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("destinationAddress")}</span>
|
||||
@@ -585,7 +463,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("duration");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -606,9 +484,6 @@ export default function ConnectionLogsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Connection Details
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Session ID:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
@@ -633,18 +508,6 @@ export default function ConnectionLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<strong>Client Endpoint:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
@@ -680,30 +543,8 @@ export default function ConnectionLogsPage() {
|
||||
<strong>Duration:</strong>{" "}
|
||||
{formatDuration(row.startedAt, row.endedAt)}
|
||||
</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 className="space-y-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -724,8 +565,8 @@ export default function ConnectionLogsPage() {
|
||||
title={t("connectionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="protocol"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
@@ -737,14 +578,12 @@ export default function ConnectionLogsPage() {
|
||||
id: "startedAt",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
@@ -754,3 +593,49 @@ 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,14 +9,17 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { build } from "@server/build";
|
||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -25,36 +28,11 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = 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 [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<{
|
||||
action?: string;
|
||||
resourceId?: string;
|
||||
@@ -75,32 +53,18 @@ export default function GeneralPage() {
|
||||
path: searchParams.get("path") || undefined
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,80 +73,97 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
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,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
console.log(`${filterType} filter changed:`, value);
|
||||
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
setCurrentPage(0);
|
||||
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 = (
|
||||
@@ -204,101 +185,6 @@ export default function GeneralPage() {
|
||||
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 () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
@@ -781,8 +667,8 @@ export default function GeneralPage() {
|
||||
title={t("requestLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="host"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
@@ -794,7 +680,6 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
@@ -808,3 +693,63 @@ 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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,12 +318,28 @@ export default function DeviceLoginForm({
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={9}
|
||||
maxLength={8}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
{...field}
|
||||
value={field.value
|
||||
.replace(/-/g, "")
|
||||
.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) => {
|
||||
// Strip hyphens and convert to uppercase
|
||||
const cleanedValue = value
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader,
|
||||
LoaderIcon,
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -427,7 +428,7 @@ export function LogDataTable<TData, TValue>({
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="relative">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -535,6 +536,19 @@ export function LogDataTable<TData, TValue>({
|
||||
)}
|
||||
</TableBody>
|
||||
</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">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
|
||||
+50
-16
@@ -46,6 +46,20 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
||||
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 = {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -74,19 +88,33 @@ export function RoleForm({
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional(),
|
||||
allowSsh: z.boolean().optional(),
|
||||
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||
sshSudoCommands: z.string().optional(),
|
||||
sshCreateHomeDir: z.boolean().optional(),
|
||||
sshUnixGroups: z.string().optional()
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional(),
|
||||
allowSsh: z.boolean().optional(),
|
||||
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||
sshSudoCommands: z.string().optional(),
|
||||
sshCreateHomeDir: z.boolean().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
|
||||
? {
|
||||
@@ -296,7 +324,9 @@ export function RoleForm({
|
||||
control={form.control}
|
||||
name="allowSsh"
|
||||
render={({ field }) => {
|
||||
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
||||
const allowSshOptions: OptionSelectOption<
|
||||
"allow" | "disallow"
|
||||
>[] = [
|
||||
{
|
||||
value: "allow",
|
||||
label: t("roleAllowSshAllow")
|
||||
@@ -311,7 +341,9 @@ export function RoleForm({
|
||||
<FormLabel>
|
||||
{t("roleAllowSsh")}
|
||||
</FormLabel>
|
||||
<OptionSelect<"allow" | "disallow">
|
||||
<OptionSelect<
|
||||
"allow" | "disallow"
|
||||
>
|
||||
options={allowSshOptions}
|
||||
value={
|
||||
sshDisabled
|
||||
@@ -322,7 +354,9 @@ export function RoleForm({
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (sshDisabled) return;
|
||||
field.onChange(v === "allow");
|
||||
field.onChange(
|
||||
v === "allow"
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
disabled={sshDisabled}
|
||||
|
||||
+235
-7
@@ -1,17 +1,25 @@
|
||||
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 {
|
||||
QueryAccessAuditLogResponse,
|
||||
QueryActionAuditLogResponse,
|
||||
QueryConnectionAuditLogResponse,
|
||||
QueryRequestAuditLogResponse
|
||||
} from "@server/routers/auditLogs/types";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import type {
|
||||
ListDomainsResponse,
|
||||
GetDNSRecordsResponse
|
||||
GetDNSRecordsResponse,
|
||||
ListDomainsResponse
|
||||
} from "@server/routers/domain";
|
||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import type {
|
||||
GetResourceWhitelistResponse,
|
||||
ListResourceNamesResponse,
|
||||
ListResourcesResponse
|
||||
} from "@server/routers/resource";
|
||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
@@ -31,8 +39,6 @@ import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import { remote } from "./api";
|
||||
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";
|
||||
|
||||
export type ProductUpdate = {
|
||||
@@ -589,7 +595,111 @@ export const logAnalyticsFiltersSchema = z.object({
|
||||
resourceId: z.coerce.number().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||
export type LogAnalyticsFilters = z.output<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 = {
|
||||
requestAnalytics: ({
|
||||
@@ -600,7 +710,7 @@ export const logQueries = {
|
||||
filters: LogAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
|
||||
queryKey: ["REQUEST_LOGS", orgId, "ANALYTICS", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryRequestAnalyticsResponse>
|
||||
@@ -616,6 +726,124 @@ export const logQueries = {
|
||||
}
|
||||
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