mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-25 18:23:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6337cf4359 |
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Go to Resource",
|
"resourceGoTo": "Go to Resource",
|
||||||
"resourceDelete": "Delete Resource",
|
"resourceDelete": "Delete Resource",
|
||||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
|
|||||||
@@ -148,12 +148,6 @@ export enum ActionsEnum {
|
|||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
listOrgLabels = "listOrgLabels",
|
|
||||||
createOrgLabel = "createOrgLabel",
|
|
||||||
updateOrgLabel = "updateOrgLabel",
|
|
||||||
deleteOrgLabel = "deleteOrgLabel",
|
|
||||||
attachLabelToItem = "attachLabelToItem",
|
|
||||||
detachLabelFromItem = "detachLabelFromItem",
|
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
|
|||||||
@@ -162,89 +162,6 @@ export const resources = pgTable("resources", {
|
|||||||
wildcard: boolean("wildcard").notNull().default(false)
|
wildcard: boolean("wildcard").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
|
||||||
labelId: serial("labelId").primaryKey(),
|
|
||||||
name: varchar("name").notNull(),
|
|
||||||
color: varchar("color").notNull(),
|
|
||||||
orgId: varchar("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = pgTable(
|
|
||||||
"siteLabels",
|
|
||||||
{
|
|
||||||
siteLabelId: serial("siteLabelId").primaryKey(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceLabels = pgTable(
|
|
||||||
"resourceLabels",
|
|
||||||
{
|
|
||||||
resourceLabelId: serial("resourceLabelId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const siteResourceLabels = pgTable(
|
|
||||||
"siteResourceLabels",
|
|
||||||
{
|
|
||||||
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
|
|
||||||
siteResourceId: integer("siteResourceId")
|
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientLabels = pgTable(
|
|
||||||
"clientLabels",
|
|
||||||
{
|
|
||||||
clientLabelId: serial("clientLabelId").primaryKey(),
|
|
||||||
clientId: integer("clientId")
|
|
||||||
.references(() => clients.clientId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
targetId: serial("targetId").primaryKey(),
|
targetId: serial("targetId").primaryKey(),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -279,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
}).notNull(),
|
||||||
.notNull(),
|
|
||||||
name: varchar("name"),
|
name: varchar("name"),
|
||||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||||
hcPath: varchar("hcPath"),
|
hcPath: varchar("hcPath"),
|
||||||
@@ -1182,9 +1097,7 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
|||||||
complete: boolean("complete").notNull().default(false)
|
complete: boolean("complete").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = pgTable(
|
export const statusHistory = pgTable("statusHistory", {
|
||||||
"statusHistory",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
entityType: varchar("entityType").notNull(),
|
entityType: varchar("entityType").notNull(),
|
||||||
entityId: integer("entityId").notNull(),
|
entityId: integer("entityId").notNull(),
|
||||||
@@ -1192,20 +1105,11 @@ export const statusHistory = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
status: varchar("status").notNull(),
|
status: varchar("status").notNull(),
|
||||||
timestamp: integer("timestamp").notNull()
|
timestamp: integer("timestamp").notNull(),
|
||||||
},
|
}, (table) => [
|
||||||
(table) => [
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
index("idx_statusHistory_entity").on(
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
table.entityType,
|
]);
|
||||||
table.entityId,
|
|
||||||
table.timestamp
|
|
||||||
),
|
|
||||||
index("idx_statusHistory_org_timestamp").on(
|
|
||||||
table.orgId,
|
|
||||||
table.timestamp
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1275,4 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
|
||||||
|
|||||||
@@ -183,95 +183,6 @@ export const resources = sqliteTable("resources", {
|
|||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
|
||||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
color: text("color").notNull(),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = sqliteTable(
|
|
||||||
"siteLabels",
|
|
||||||
{
|
|
||||||
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceLabels = sqliteTable(
|
|
||||||
"resourceLabels",
|
|
||||||
{
|
|
||||||
resourceLabelId: integer("resourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const siteResourceLabels = sqliteTable(
|
|
||||||
"siteResourceLabels",
|
|
||||||
{
|
|
||||||
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
siteResourceId: integer("siteResourceId")
|
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientLabels = sqliteTable(
|
|
||||||
"clientLabels",
|
|
||||||
{
|
|
||||||
clientLabelId: integer("clientLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
clientId: integer("clientId")
|
|
||||||
.references(() => clients.clientId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -308,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
}).notNull(),
|
||||||
.notNull(),
|
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -1287,9 +1196,7 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
|||||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = sqliteTable(
|
export const statusHistory = sqliteTable("statusHistory", {
|
||||||
"statusHistory",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
@@ -1297,20 +1204,11 @@ export const statusHistory = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||||
},
|
}, (table) => [
|
||||||
(table) => [
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
index("idx_statusHistory_entity").on(
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
table.entityType,
|
]);
|
||||||
table.entityId,
|
|
||||||
table.timestamp
|
|
||||||
),
|
|
||||||
index("idx_statusHistory_org_timestamp").on(
|
|
||||||
table.orgId,
|
|
||||||
table.timestamp
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1380,4 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ export enum TierFeature {
|
|||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain"
|
||||||
Labels = "labels"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const RuleSchema = z
|
|||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional()
|
priority: z.int().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
@@ -340,7 +340,8 @@ export const ResourceSchema = z
|
|||||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||||
if (parts.length < 3) return false; // need at least *.label.tld
|
if (parts.length < 3) return false; // need at least *.label.tld
|
||||||
|
|
||||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
const labelRegex =
|
||||||
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -154,19 +154,8 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get keys with a specific prefix
|
|
||||||
* @param prefix - Key prefix to match
|
|
||||||
* @returns Array of matching keys
|
|
||||||
*/
|
|
||||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
|
||||||
const allKeys = localCache.keys();
|
|
||||||
return allKeys.filter((key) => key.startsWith(prefix));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export const regionalCache = cache; // Alias for compatability with the private version
|
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = await cache.keysWithPrefix(prefix);
|
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
import { redisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,147 +298,3 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
/**
|
|
||||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
|
||||||
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
|
||||||
* Use this for data that is regional in nature (e.g. status history) so
|
|
||||||
* reads are served from the same cluster the user is hitting.
|
|
||||||
*/
|
|
||||||
const regionalLocalCache = new NodeCache({
|
|
||||||
stdTTL: 3600,
|
|
||||||
checkperiod: 120,
|
|
||||||
maxKeys: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
class RegionalAdaptiveCache {
|
|
||||||
private useRedis(): boolean {
|
|
||||||
return (
|
|
||||||
regionalRedisManager.isRedisEnabled() &&
|
|
||||||
regionalRedisManager.getHealthStatus().isHealthy
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
|
||||||
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
|
||||||
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
|
||||||
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
const success = await regionalRedisManager.set(
|
|
||||||
key,
|
|
||||||
serialized,
|
|
||||||
redisTtl
|
|
||||||
);
|
|
||||||
if (success) {
|
|
||||||
logger.debug(`[regional] Set key in Redis: ${key}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis set error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
|
||||||
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T = any>(key: string): Promise<T | undefined> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const value = await regionalRedisManager.get(key);
|
|
||||||
if (value !== null) {
|
|
||||||
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
}
|
|
||||||
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis get error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = regionalLocalCache.get<T>(key);
|
|
||||||
if (value !== undefined) {
|
|
||||||
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
|
||||||
} else {
|
|
||||||
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async del(key: string | string[]): Promise<number> {
|
|
||||||
const keys = Array.isArray(key) ? key : [key];
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
for (const k of keys) {
|
|
||||||
const success = await regionalRedisManager.del(k);
|
|
||||||
if (success) {
|
|
||||||
deletedCount++;
|
|
||||||
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (deletedCount === keys.length) return deletedCount;
|
|
||||||
deletedCount = 0;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[regional] Redis del error:`, error);
|
|
||||||
deletedCount = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const k of keys) {
|
|
||||||
const count = regionalLocalCache.del(k);
|
|
||||||
if (count > 0) {
|
|
||||||
deletedCount++;
|
|
||||||
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
async has(key: string): Promise<boolean> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
const value = await regionalRedisManager.get(key);
|
|
||||||
return value !== null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`[regional] Redis has error for key ${key}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regionalLocalCache.has(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns keys matching the given prefix from whichever backend is active.
|
|
||||||
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
|
||||||
*/
|
|
||||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
|
||||||
if (this.useRedis()) {
|
|
||||||
try {
|
|
||||||
return await regionalRedisManager.keys(`${prefix}*`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[regional] Redis keys error:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentBackend(): "redis" | "local" {
|
|
||||||
return this.useRedis() ? "redis" : "local";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const regionalCache = new RegionalAdaptiveCache();
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import { LogStreamingManager } from "./LogStreamingManager";
|
|||||||
*/
|
*/
|
||||||
export const logStreamingManager = new LogStreamingManager();
|
export const logStreamingManager = new LogStreamingManager();
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
|
||||||
// this is handled separately in the saas build, so we don't want to start it here
|
|
||||||
logStreamingManager.start();
|
logStreamingManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,25 +73,6 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.optional(),
|
|
||||||
regional_redis: z
|
|
||||||
.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: portSchema,
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
|
||||||
db: z.int().nonnegative().optional().default(0),
|
|
||||||
tls: z
|
|
||||||
.object({
|
|
||||||
rejectUnauthorized: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
})
|
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -855,163 +855,3 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
|
||||||
* Connects only when `redis.regional_redis` is present in the private config
|
|
||||||
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
|
||||||
* caching of regionally-scoped data.
|
|
||||||
*/
|
|
||||||
class RegionalRedisManager {
|
|
||||||
private writeClient: Redis | null = null;
|
|
||||||
private readClient: Redis | null = null;
|
|
||||||
private isEnabled: boolean = false;
|
|
||||||
private isHealthy: boolean = false;
|
|
||||||
private connectionTimeout: number = 5000;
|
|
||||||
private commandTimeout: number = 5000;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (build === "oss") return;
|
|
||||||
|
|
||||||
const cfg = privateConfig.getRawPrivateConfig();
|
|
||||||
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
|
||||||
|
|
||||||
this.isEnabled = true;
|
|
||||||
this.initializeClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConfig(): RedisOptions {
|
|
||||||
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
|
||||||
const opts: RedisOptions = {
|
|
||||||
host: r.host,
|
|
||||||
port: r.port,
|
|
||||||
password: r.password,
|
|
||||||
db: r.db
|
|
||||||
};
|
|
||||||
if (r.tls) {
|
|
||||||
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeClients(): void {
|
|
||||||
const cfg = this.getConfig();
|
|
||||||
const baseOpts = {
|
|
||||||
...cfg,
|
|
||||||
enableReadyCheck: false,
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
keepAlive: 10000,
|
|
||||||
connectTimeout: this.connectionTimeout,
|
|
||||||
commandTimeout: this.commandTimeout
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.writeClient = new Redis(baseOpts);
|
|
||||||
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
|
||||||
this.readClient = new Redis({
|
|
||||||
...baseOpts,
|
|
||||||
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
|
||||||
// Derive replica hostname from the headless service pattern:
|
|
||||||
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
|
||||||
// If it doesn't look like a k8s service, just use the same host
|
|
||||||
return h + rest;
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// For simplicity use same host for both; callers can always read from primary
|
|
||||||
// The real replica routing is handled by the StatefulSet headless service
|
|
||||||
this.readClient = this.writeClient;
|
|
||||||
|
|
||||||
this.writeClient.on("ready", () => {
|
|
||||||
logger.info("Regional Redis client ready");
|
|
||||||
this.isHealthy = true;
|
|
||||||
});
|
|
||||||
this.writeClient.on("error", (err) => {
|
|
||||||
logger.error("Regional Redis client error:", err);
|
|
||||||
this.isHealthy = false;
|
|
||||||
});
|
|
||||||
this.writeClient.on("reconnecting", () => {
|
|
||||||
logger.info("Regional Redis client reconnecting...");
|
|
||||||
this.isHealthy = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("Regional Redis client initialized");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to initialize regional Redis client:", error);
|
|
||||||
this.isEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public isRedisEnabled(): boolean {
|
|
||||||
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHealthStatus() {
|
|
||||||
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set(
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
ttl?: number
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
|
||||||
try {
|
|
||||||
if (ttl) {
|
|
||||||
await this.writeClient.setex(key, ttl, value);
|
|
||||||
} else {
|
|
||||||
await this.writeClient.set(key, value);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis SET error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get(key: string): Promise<string | null> {
|
|
||||||
if (!this.isRedisEnabled() || !this.readClient) return null;
|
|
||||||
try {
|
|
||||||
return await this.readClient.get(key);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis GET error:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async del(key: string): Promise<boolean> {
|
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
|
||||||
try {
|
|
||||||
await this.writeClient.del(key);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis DEL error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async keys(pattern: string): Promise<string[]> {
|
|
||||||
if (!this.isRedisEnabled() || !this.readClient) return [];
|
|
||||||
try {
|
|
||||||
return await this.readClient.keys(pattern);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Regional Redis KEYS error:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (this.writeClient) {
|
|
||||||
await this.writeClient.quit();
|
|
||||||
this.writeClient = null;
|
|
||||||
}
|
|
||||||
this.readClient = null;
|
|
||||||
logger.info("Regional Redis client disconnected");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error disconnecting regional Redis client:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const regionalRedisManager = new RegionalRedisManager();
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
if (build !== "saas") {
|
if (build != "saas") {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as labels from "#private/routers/labels";
|
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -734,59 +733,6 @@ authenticated.get(
|
|||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
|
||||||
labels.listOrgLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
|
||||||
labels.createOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.patch(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
|
||||||
labels.updateOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
|
||||||
labels.deleteOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/attach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
|
||||||
labels.attachLabelToItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/detach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
|
||||||
labels.detachLabelFromItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientLabels,
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachLabelBodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional(),
|
|
||||||
siteResourceId: z.number().int().optional(),
|
|
||||||
clientId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function attachLabelToItem(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = attachLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, resourceId, siteResourceId, clientId } =
|
|
||||||
parsedBody.data;
|
|
||||||
|
|
||||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Label with Id ${labelId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(siteLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
siteId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(resourceLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
resourceId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteResourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
siteResources,
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(siteResourceLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
siteResourceId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId) {
|
|
||||||
const clientCount = await db.$count(
|
|
||||||
clients,
|
|
||||||
and(
|
|
||||||
eq(clients.clientId, clientId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
isNull(clients.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with Id ${clientId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(clientLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label attached successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
name: z.string().nonempty(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty(),
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function createOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, color, siteId, resourceId } = parsedBody.data;
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = await db.transaction(async (tx) => {
|
|
||||||
const [label] = await tx
|
|
||||||
.insert(labels)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
orgId
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
await tx.insert(siteLabels).values({
|
|
||||||
siteId,
|
|
||||||
labelId: label.labelId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
await tx.insert(resourceLabels).values({
|
|
||||||
resourceId,
|
|
||||||
labelId: label.labelId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
});
|
|
||||||
|
|
||||||
return response<CreateOrEditLabelResponse>(res, {
|
|
||||||
data: { label },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Org Label created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function deleteOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label deleted successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientLabels,
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const detachLabelBodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional(),
|
|
||||||
siteResourceId: z.number().int().optional(),
|
|
||||||
clientId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function detachLabelFromItem(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = detachLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, resourceId, siteResourceId, clientId } =
|
|
||||||
parsedBody.data;
|
|
||||||
|
|
||||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Label with Id ${labelId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(siteLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteLabels.labelId, labelId),
|
|
||||||
eq(siteLabels.siteId, siteId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(resourceLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceLabels.labelId, labelId),
|
|
||||||
eq(resourceLabels.resourceId, resourceId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteResourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
siteResources,
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(siteResourceLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResourceLabels.labelId, labelId),
|
|
||||||
eq(siteResourceLabels.siteResourceId, siteResourceId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId) {
|
|
||||||
const clientCount = await db.$count(
|
|
||||||
clients,
|
|
||||||
and(
|
|
||||||
eq(clients.clientId, clientId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
isNull(clients.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with Id ${clientId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(clientLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientLabels.labelId, labelId),
|
|
||||||
eq(clientLabels.clientId, clientId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label detached successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./listOrgLabels";
|
|
||||||
export * from "./createOrgLabel";
|
|
||||||
export * from "./updateOrgLabel";
|
|
||||||
export * from "./attachLabelToItem";
|
|
||||||
export * from "./detachLabelFromItem";
|
|
||||||
export * from "./deleteOrgLabel";
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
const listLabelsSchema = z.object({
|
|
||||||
pageSize: z.coerce
|
|
||||||
.number<string>() // for prettier formatting
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(20)
|
|
||||||
.default(20)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 20,
|
|
||||||
description: "Number of items per page"
|
|
||||||
}),
|
|
||||||
page: z.coerce
|
|
||||||
.number<string>() // for prettier formatting
|
|
||||||
.int()
|
|
||||||
.min(0)
|
|
||||||
.optional()
|
|
||||||
.catch(1)
|
|
||||||
.default(1)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 1,
|
|
||||||
description: "Page number to retrieve"
|
|
||||||
}),
|
|
||||||
query: z.string().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
function queryLabelsBase() {
|
|
||||||
return db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color
|
|
||||||
})
|
|
||||||
.from(labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOrgLabels(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedQuery = listLabelsSchema.safeParse(req.query);
|
|
||||||
if (!parsedQuery.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedQuery.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have access to this organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pageSize, page, query } = parsedQuery.data;
|
|
||||||
|
|
||||||
const conditions = [and(eq(labels.orgId, orgId))];
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
conditions.push(
|
|
||||||
like(
|
|
||||||
sql`LOWER(${labels.name})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = queryLabelsBase().where(and(...conditions));
|
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
|
||||||
const countQuery = db.$count(
|
|
||||||
queryLabelsBase()
|
|
||||||
.where(and(...conditions))
|
|
||||||
.as("filtered_labels")
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelListQuery = baseQuery
|
|
||||||
.limit(pageSize)
|
|
||||||
.offset(pageSize * (page - 1))
|
|
||||||
.orderBy(asc(labels.name));
|
|
||||||
|
|
||||||
const [totalCount, rows] = await Promise.all([
|
|
||||||
countQuery,
|
|
||||||
labelListQuery
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response<ListOrgLabelsResponse>(res, {
|
|
||||||
data: {
|
|
||||||
labels: rows,
|
|
||||||
pagination: {
|
|
||||||
total: totalCount,
|
|
||||||
pageSize,
|
|
||||||
page
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Labels retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateLabelBodySchema = z.strictObject({
|
|
||||||
name: z.string().min(1).max(255).optional(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function updateOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = updateLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, color } = parsedBody.data;
|
|
||||||
|
|
||||||
const [label] = await db
|
|
||||||
.update(labels)
|
|
||||||
.set({
|
|
||||||
name,
|
|
||||||
color
|
|
||||||
})
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return response<CreateOrEditLabelResponse>(res, {
|
|
||||||
data: {
|
|
||||||
label
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
clientLabels,
|
|
||||||
clients,
|
clients,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
db,
|
db,
|
||||||
labels,
|
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
users,
|
users
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -174,7 +169,6 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
|||||||
siteNiceId: string | null;
|
siteNiceId: string | null;
|
||||||
}>;
|
}>;
|
||||||
olmUpdateAvailable?: boolean;
|
olmUpdateAvailable?: boolean;
|
||||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
@@ -261,11 +255,6 @@ export async function listClients(
|
|||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const conditions = [
|
const conditions = [
|
||||||
and(
|
and(
|
||||||
@@ -299,31 +288,20 @@ export async function listClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
conditions.push(
|
||||||
const queryList = [
|
or(
|
||||||
like(sql`LOWER(${clients.name})`, q),
|
like(
|
||||||
like(sql`LOWER(${clients.niceId})`, q)
|
sql`LOWER(${clients.name})`,
|
||||||
];
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
if (isLabelFeatureEnabled) {
|
like(
|
||||||
queryList.push(
|
sql`LOWER(${clients.niceId})`,
|
||||||
inArray(
|
"%" + query.toLowerCase() + "%"
|
||||||
clients.clientId,
|
|
||||||
db
|
|
||||||
.select({ id: clientLabels.clientId })
|
|
||||||
.from(clientLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, clientLabels.labelId)
|
|
||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
|
|
||||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
@@ -348,30 +326,6 @@ export async function listClients(
|
|||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
const siteAssociations = await getSiteAssociations(clientIds);
|
const siteAssociations = await getSiteAssociations(clientIds);
|
||||||
|
|
||||||
let labelsForClients: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
clientId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
|
||||||
labelsForClients = await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
clientId: clientLabels.clientId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
clientLabels,
|
|
||||||
eq(clientLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(inArray(clientLabels.clientId, clientIds))
|
|
||||||
.orderBy(asc(clientLabels.clientLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group site associations by client ID
|
// Group site associations by client ID
|
||||||
const sitesByClient = siteAssociations.reduce(
|
const sitesByClient = siteAssociations.reduce(
|
||||||
(acc, association) => {
|
(acc, association) => {
|
||||||
@@ -399,10 +353,7 @@ export async function listClients(
|
|||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
sites: sitesByClient[client.clientId] || [],
|
sites: sitesByClient[client.clientId] || []
|
||||||
labels: labelsForClients.filter(
|
|
||||||
(l) => l.clientId === client.clientId
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { Label } from "@server/db";
|
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
|
||||||
|
|
||||||
export type ListOrgLabelsResponse = PaginatedResponse<{
|
|
||||||
labels: Omit<Label, "orgId">[];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type CreateOrEditLabelResponse = {
|
|
||||||
label: Label;
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
labels,
|
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
resourceLabels,
|
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resources,
|
resources,
|
||||||
@@ -11,11 +9,8 @@ import {
|
|||||||
sites,
|
sites,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
targets,
|
targets,
|
||||||
userResources,
|
userResources
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -159,7 +154,6 @@ export type ResourceWithTargets = {
|
|||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
online?: boolean; // undefined for local sites
|
online?: boolean; // undefined for local sites
|
||||||
}>;
|
}>;
|
||||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResourcesBase() {
|
function queryResourcesBase() {
|
||||||
@@ -294,11 +288,6 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
let accessibleResources: Array<{ resourceId: number }>;
|
let accessibleResources: Array<{ resourceId: number }>;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
@@ -336,6 +325,24 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.fullDomain})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (typeof enabled !== "undefined") {
|
if (typeof enabled !== "undefined") {
|
||||||
conditions.push(eq(resources.enabled, enabled));
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
}
|
}
|
||||||
@@ -379,32 +386,6 @@ export async function listResources(
|
|||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||||
}
|
}
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
const queryList = [
|
|
||||||
like(sql`LOWER(${resources.name})`, q),
|
|
||||||
like(sql`LOWER(${resources.niceId})`, q),
|
|
||||||
like(sql`LOWER(${resources.fullDomain})`, q)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
|
||||||
resources.resourceId,
|
|
||||||
db
|
|
||||||
.select({ id: resourceLabels.resourceId })
|
|
||||||
.from(resourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, resourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
@@ -426,36 +407,6 @@ export async function listResources(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const resourceIdList = rows.map((row) => row.resourceId);
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
|
||||||
let labelsForResources: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
resourceId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForResources =
|
|
||||||
resourceIdList.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
resourceId: resourceLabels.resourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
resourceLabels,
|
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -507,10 +458,7 @@ export async function listResources(
|
|||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
health: row.health ?? null,
|
health: row.health ?? null,
|
||||||
targets: [],
|
targets: [],
|
||||||
sites: [],
|
sites: []
|
||||||
labels: labelsForResources.filter(
|
|
||||||
(l) => l.resourceId === row.resourceId
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import {
|
|||||||
siteResources,
|
siteResources,
|
||||||
targets,
|
targets,
|
||||||
sites,
|
sites,
|
||||||
userSites,
|
userSites
|
||||||
labels,
|
|
||||||
siteLabels,
|
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -26,8 +23,6 @@ import createHttpError from "http-errors";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||||
// a transient network failure / timeout does not flip every site back to
|
// a transient network failure / timeout does not flip every site back to
|
||||||
@@ -192,7 +187,7 @@ const listSitesSchema = z.object({
|
|||||||
|
|
||||||
function querySitesBase() {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.selectDistinct({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
niceId: sites.niceId,
|
niceId: sites.niceId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
@@ -238,7 +233,6 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
|||||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||||
online?: SiteRowBase["online"]; // undefined for local sites
|
online?: SiteRowBase["online"]; // undefined for local sites
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = PaginatedResponse<{
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
@@ -314,11 +308,6 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online, status } =
|
const { pageSize, page, query, sort_by, order, online, status } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
@@ -330,43 +319,33 @@ export async function listSites(
|
|||||||
eq(sites.orgId, orgId)
|
eq(sites.orgId, orgId)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (typeof online !== "undefined") {
|
if (typeof online !== "undefined") {
|
||||||
conditions.push(eq(sites.online, online));
|
conditions.push(eq(sites.online, online));
|
||||||
}
|
}
|
||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
const queryList = [
|
|
||||||
like(sql`LOWER(${sites.name})`, q),
|
|
||||||
like(sql`LOWER(${sites.niceId})`, q)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
|
||||||
sites.siteId,
|
|
||||||
db
|
|
||||||
.select({ id: siteLabels.siteId })
|
|
||||||
.from(siteLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = querySitesBase().where(and(...conditions));
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
querySitesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_sites")
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteListQuery = baseQuery
|
const siteListQuery = baseQuery
|
||||||
@@ -388,46 +367,11 @@ export async function listSites(
|
|||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
const siteIds = rows.map((site) => site.siteId);
|
|
||||||
|
|
||||||
let labelsForSites: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
siteId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForSites =
|
|
||||||
siteIds.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
siteId: siteLabels.siteId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteLabels,
|
|
||||||
eq(siteLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(inArray(siteLabels.siteId, siteIds))
|
|
||||||
.orderBy(asc(siteLabels.siteLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
// Initially set to false, will be updated if version check succeeds
|
// Initially set to false, will be updated if version check succeeds
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
|
return siteWithUpdate;
|
||||||
// associate labels
|
|
||||||
const labelsForSite = labelsForSites.filter(
|
|
||||||
(label) => label.siteId === site.siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...siteWithUpdate, labels: labelsForSite };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||||
db,
|
|
||||||
DB_TYPE,
|
|
||||||
Label,
|
|
||||||
SiteResource,
|
|
||||||
siteNetworks,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites,
|
|
||||||
labels
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -19,8 +9,6 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -81,7 +69,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
}),
|
}),
|
||||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
siteId: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.openapi({
|
||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
@@ -95,7 +88,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
|||||||
siteNames: string[];
|
siteNames: string[];
|
||||||
siteNiceIds: string[];
|
siteNiceIds: string[];
|
||||||
siteAddresses: (string | null)[];
|
siteAddresses: (string | null)[];
|
||||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
|
||||||
})[];
|
})[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -242,11 +234,6 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
@@ -271,39 +258,39 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
inArray(siteResources.siteResourceId, resourcesForSite)
|
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode) {
|
|
||||||
conditions.push(eq(siteResources.mode, mode));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
conditions.push(
|
||||||
const queryList = [
|
or(
|
||||||
like(sql`LOWER(${siteResources.name})`, q),
|
like(
|
||||||
like(sql`LOWER(${siteResources.niceId})`, q),
|
sql`LOWER(${siteResources.name})`,
|
||||||
like(sql`LOWER(${siteResources.destination})`, q),
|
"%" + query.toLowerCase() + "%"
|
||||||
like(sql`LOWER(${siteResources.alias})`, q),
|
),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
like(
|
||||||
like(sql`LOWER(${sites.name})`, q)
|
sql`LOWER(${siteResources.niceId})`,
|
||||||
];
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
if (isLabelFeatureEnabled) {
|
like(
|
||||||
queryList.push(
|
sql`LOWER(${siteResources.destination})`,
|
||||||
inArray(
|
"%" + query.toLowerCase() + "%"
|
||||||
siteResources.siteResourceId,
|
),
|
||||||
db
|
like(
|
||||||
.select({ id: siteResourceLabels.siteResourceId })
|
sql`LOWER(${siteResources.alias})`,
|
||||||
.from(siteResourceLabels)
|
"%" + query.toLowerCase() + "%"
|
||||||
.innerJoin(
|
),
|
||||||
labels,
|
like(
|
||||||
eq(labels.labelId, siteResourceLabels.labelId)
|
sql`LOWER(${siteResources.aliasAddress})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
if (mode) {
|
||||||
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
@@ -328,51 +315,11 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
countQuery
|
countQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteResourcesList = siteResourcesRaw.map(
|
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||||
transformSiteResourceRow
|
|
||||||
);
|
|
||||||
|
|
||||||
const siteResourceIdList = siteResourcesList.map(
|
|
||||||
(r) => r.siteResourceId
|
|
||||||
);
|
|
||||||
|
|
||||||
let labelsForSiteResources: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
siteResourceId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
|
||||||
labelsForSiteResources = await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
siteResourceId: siteResourceLabels.siteResourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteResourceLabels,
|
|
||||||
eq(siteResourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
siteResourceLabels.siteResourceId,
|
|
||||||
siteResourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
siteResources: siteResourcesList.map((r) => ({
|
siteResources: siteResourcesList,
|
||||||
...r,
|
|
||||||
labels: labelsForSiteResources.filter(
|
|
||||||
(l) => l.siteResourceId === r.siteResourceId
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Labels"
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
params: Promise<{ orgId: string }>;
|
|
||||||
searchParams: Promise<Record<string, string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function LabelsPage({ params, searchParams }: Props) {
|
|
||||||
const { orgId } = await params;
|
|
||||||
|
|
||||||
const searchParamsObj = new URLSearchParams(await searchParams);
|
|
||||||
|
|
||||||
let labels: ListOrgLabelsResponse["labels"] = [];
|
|
||||||
let pagination: ListOrgLabelsResponse["pagination"] = {
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
|
|
||||||
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
const responseData = res.data.data;
|
|
||||||
labels = responseData.labels;
|
|
||||||
pagination = responseData.pagination;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("labels")}
|
|
||||||
description={t("orgLabelsDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrgLabelsTable
|
|
||||||
labels={labels}
|
|
||||||
orgId={orgId}
|
|
||||||
rowCount={pagination.total}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.page - 1,
|
|
||||||
pageSize: pagination.pageSize
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -76,8 +76,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false,
|
blocked: client.blocked || false,
|
||||||
approvalState: client.approvalState ?? "approved",
|
approvalState: client.approvalState ?? "approved"
|
||||||
labels: client.labels ?? []
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useTransition, useMemo } from "react";
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,9 +20,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,8 +30,23 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -49,21 +61,40 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,95 +103,75 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
const defaultRange = getDefaultDateRange();
|
||||||
|
queryDateTime(
|
||||||
if (dateRange.startDate?.date) {
|
defaultRange.startDate,
|
||||||
const dt = new Date(dateRange.startDate.date);
|
defaultRange.endDate,
|
||||||
if (dateRange.startDate.time) {
|
0,
|
||||||
const [h, m, s] = dateRange.startDate.time
|
pageSize
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
|
||||||
timeStart = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateRange.endDate?.date) {
|
|
||||||
const dt = new Date(dateRange.endDate.date);
|
|
||||||
if (dateRange.endDate.time) {
|
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
resourceId: filters.resourceId
|
|
||||||
? Number(filters.resourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.access({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -182,8 +193,114 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
location?: string;
|
||||||
|
actor?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -199,6 +316,7 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -216,6 +334,7 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -232,7 +351,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -247,7 +366,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
@@ -260,6 +379,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -276,11 +396,13 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: () => t("ip")
|
header: ({ column }) => {
|
||||||
|
return t("ip");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("location")}</span>
|
<span>{t("location")}</span>
|
||||||
@@ -295,6 +417,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -319,7 +442,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -332,6 +455,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -357,7 +481,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("type")}</span>
|
<span>{t("type")}</span>
|
||||||
@@ -376,6 +500,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -393,7 +518,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -406,6 +531,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -433,12 +559,16 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => t("actorId"),
|
header: ({ column }) => {
|
||||||
cell: ({ row }) => (
|
return t("actorId");
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{row.original.actorId || "-"}
|
{row.original.actorId || "-"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -484,10 +614,13 @@ export default function GeneralPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
|
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||||
|
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
|
||||||
|
// }
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
start: dateRange.startDate,
|
start: dateRange.startDate,
|
||||||
@@ -497,12 +630,14 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||||
@@ -510,41 +645,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
|
||||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
|
||||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com",
|
|
||||||
null
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const action = Math.random() > 0.3;
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action,
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: actor ? "user" : null,
|
|
||||||
actor,
|
|
||||||
actorId: actor ? `user-${i}` : null,
|
|
||||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
|
||||||
location: locations[Math.floor(Math.random() * locations.length)],
|
|
||||||
userAgent: "Mozilla/5.0",
|
|
||||||
metadata: null,
|
|
||||||
type: types[Math.floor(Math.random() * types.length)]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Key, User } from "lucide-react";
|
import { Key, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -31,8 +28,18 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
actions: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
actions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -41,21 +48,40 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,90 +90,78 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.action({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = {
|
|
||||||
actors: data?.filterAttributes?.actors ?? []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -169,8 +183,110 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
actor?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.actionLogs)) {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -186,6 +302,7 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -203,6 +320,7 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -219,7 +337,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -234,16 +352,22 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
<ColumnFilter
|
<ColumnFilter
|
||||||
options={[]}
|
options={filterAttributes.actions.map((action) => ({
|
||||||
|
label:
|
||||||
|
action.charAt(0).toUpperCase() +
|
||||||
|
action.slice(1),
|
||||||
|
value: action
|
||||||
|
}))}
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -261,7 +385,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -274,6 +398,7 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
|
// placeholder=""
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -295,7 +420,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("actorId");
|
return t("actorId");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -344,9 +469,12 @@ export default function GeneralPage() {
|
|||||||
title={t("actionLogs")}
|
title={t("actionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="action"
|
searchColumn="action"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
|
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||||
|
// !isPaidUser(tierMatrix.logExport) || build === "oss"
|
||||||
|
// }
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
@@ -357,12 +485,14 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||||
@@ -370,39 +500,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
|
||||||
const actions = [
|
|
||||||
"createResource",
|
|
||||||
"deleteResource",
|
|
||||||
"updateResource",
|
|
||||||
"createSite",
|
|
||||||
"deleteSite",
|
|
||||||
"inviteUser",
|
|
||||||
"removeUser"
|
|
||||||
];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com"
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action: actions[Math.floor(Math.random() * actions.length)],
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: "user",
|
|
||||||
actor,
|
|
||||||
actorId: `user-${i}`,
|
|
||||||
metadata: null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,18 +11,24 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null): string {
|
||||||
|
if (bytes === null || bytes === undefined) return "-";
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||||
if (endedAt === null || endedAt === undefined) return "Active";
|
if (endedAt === null || endedAt === undefined) return "Active";
|
||||||
@@ -48,8 +54,24 @@ export default function ConnectionLogsPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
protocols: string[];
|
||||||
|
destAddrs: string[];
|
||||||
|
clients: { id: number; name: string }[];
|
||||||
|
resources: { id: number; name: string | null }[];
|
||||||
|
users: { id: string; email: string | null }[];
|
||||||
|
}>({
|
||||||
|
protocols: [],
|
||||||
|
destAddrs: [],
|
||||||
|
clients: [],
|
||||||
|
resources: [],
|
||||||
|
users: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
destAddr?: string;
|
destAddr?: string;
|
||||||
@@ -64,24 +86,43 @@ export default function ConnectionLogsPage() {
|
|||||||
userId: searchParams.get("userId") || undefined
|
userId: searchParams.get("userId") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize(
|
const [pageSize, setPageSize] = useStoredPageSize(
|
||||||
"connection-audit-logs",
|
"connection-audit-logs",
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set default date range to last 7 days
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,100 +131,78 @@ export default function ConnectionLogsPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
clientId: filters.clientId ? Number(filters.clientId) : undefined,
|
|
||||||
siteResourceId: filters.siteResourceId
|
|
||||||
? Number(filters.siteResourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.connection({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading
|
|
||||||
? generateSampleConnectionLogs()
|
|
||||||
: (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
protocols: [],
|
|
||||||
destAddrs: [],
|
|
||||||
clients: [],
|
|
||||||
resources: [],
|
|
||||||
users: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -205,8 +224,109 @@ export default function ConnectionLogsPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: typeof filters
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
||||||
|
console.log(
|
||||||
|
"Access denied: subscription inactive or license locked"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched connection logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prepare query params for export
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -225,6 +345,7 @@ export default function ConnectionLogsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create a URL for the blob and trigger a download
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -242,6 +363,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
// Parse the Blob as JSON
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -258,7 +380,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "startedAt",
|
accessorKey: "startedAt",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -273,7 +395,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("protocol")}</span>
|
<span>{t("protocol")}</span>
|
||||||
@@ -304,7 +426,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -345,7 +467,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "clientName",
|
accessorKey: "clientName",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("client")}</span>
|
<span>{t("client")}</span>
|
||||||
@@ -388,7 +510,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("user")}</span>
|
<span>{t("user")}</span>
|
||||||
@@ -421,7 +543,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "sourceAddr",
|
accessorKey: "sourceAddr",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("sourceAddress");
|
return t("sourceAddress");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -434,7 +556,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destAddr",
|
accessorKey: "destAddr",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("destinationAddress")}</span>
|
<span>{t("destinationAddress")}</span>
|
||||||
@@ -463,7 +585,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
header: () => {
|
header: ({ column }) => {
|
||||||
return t("duration");
|
return t("duration");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -484,6 +606,9 @@ export default function ConnectionLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Connection Details
|
||||||
|
</div>*/}
|
||||||
<div>
|
<div>
|
||||||
<strong>Session ID:</strong>{" "}
|
<strong>Session ID:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -508,6 +633,18 @@ export default function ConnectionLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Resource & Site
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Resource:</strong>{" "}
|
||||||
|
{row.resourceName ?? "-"}
|
||||||
|
{row.resourceNiceId && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({row.resourceNiceId})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>*/}
|
||||||
<div>
|
<div>
|
||||||
<strong>Client Endpoint:</strong>{" "}
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -543,8 +680,30 @@ export default function ConnectionLogsPage() {
|
|||||||
<strong>Duration:</strong>{" "}
|
<strong>Duration:</strong>{" "}
|
||||||
{formatDuration(row.startedAt, row.endedAt)}
|
{formatDuration(row.startedAt, row.endedAt)}
|
||||||
</div>
|
</div>
|
||||||
|
{/*<div>
|
||||||
|
<strong>Resource ID:</strong>{" "}
|
||||||
|
{row.siteResourceId ?? "-"}
|
||||||
|
</div>*/}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||||
|
Client & Transfer
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Bytes Sent (TX):</strong>{" "}
|
||||||
|
{formatBytes(row.bytesTx)}
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Bytes Received (RX):</strong>{" "}
|
||||||
|
{formatBytes(row.bytesRx)}
|
||||||
|
</div>*/}
|
||||||
|
{/*<div>
|
||||||
|
<strong>Total Transfer:</strong>{" "}
|
||||||
|
{formatBytes(
|
||||||
|
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
||||||
|
)}
|
||||||
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -565,8 +724,8 @@ export default function ConnectionLogsPage() {
|
|||||||
title={t("connectionLogs")}
|
title={t("connectionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="protocol"
|
searchColumn="protocol"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -578,12 +737,14 @@ export default function ConnectionLogsPage() {
|
|||||||
id: "startedAt",
|
id: "startedAt",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
// Row expansion props
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -593,49 +754,3 @@ export default function ConnectionLogsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
|
||||||
const protocols = ["tcp", "udp", "icmp"];
|
|
||||||
const destAddrs = [
|
|
||||||
"10.0.0.1:22",
|
|
||||||
"10.0.0.2:80",
|
|
||||||
"10.0.0.3:443",
|
|
||||||
"192.168.1.10:3306"
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const startedAt = Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
);
|
|
||||||
const active = Math.random() > 0.3;
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: `session-${i}`,
|
|
||||||
siteResourceId: (i % 3) + 1,
|
|
||||||
orgId: "sample-org",
|
|
||||||
siteId: 1,
|
|
||||||
clientId: (i % 4) + 1,
|
|
||||||
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
|
||||||
userId: i % 2 === 0 ? `user-${i}` : null,
|
|
||||||
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
|
||||||
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
|
||||||
protocol:
|
|
||||||
protocols[Math.floor(Math.random() * protocols.length)],
|
|
||||||
startedAt,
|
|
||||||
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
|
||||||
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
|
||||||
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
siteName: "Sample Site",
|
|
||||||
siteNiceId: "sample-site",
|
|
||||||
clientName: `Client ${(i % 4) + 1}`,
|
|
||||||
clientNiceId: `client-${(i % 4) + 1}`,
|
|
||||||
clientType: i % 2 === 0 ? "user" : "machine",
|
|
||||||
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,17 +9,14 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { logQueries } from "@app/lib/queries";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,11 +25,36 @@ export default function GeneralPage() {
|
|||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize page size from storage or default
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
||||||
|
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
hosts: string[];
|
||||||
|
paths: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: [],
|
||||||
|
hosts: [],
|
||||||
|
paths: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -53,18 +75,32 @@ export default function GeneralPage() {
|
|||||||
path: searchParams.get("path") || undefined
|
path: searchParams.get("path") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: { date: new Date(startParam) },
|
startDate: {
|
||||||
endDate: { date: new Date(endParam) }
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastWeek = getSevenDaysAgo();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: { date: getSevenDaysAgo() },
|
startDate: {
|
||||||
endDate: { date: new Date() }
|
date: lastWeek
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: now
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,97 +109,80 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
const queryFilters = useMemo(() => {
|
// Trigger search with default values on component mount
|
||||||
let timeStart: string | undefined;
|
useEffect(() => {
|
||||||
let timeEnd: string | undefined;
|
if (build === "oss") {
|
||||||
|
return;
|
||||||
if (dateRange.startDate?.date) {
|
|
||||||
const dt = new Date(dateRange.startDate.date);
|
|
||||||
if (dateRange.startDate.time) {
|
|
||||||
const [h, m, s] = dateRange.startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
}
|
}
|
||||||
timeStart = dt.toISOString();
|
const defaultRange = getDefaultDateRange();
|
||||||
}
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
if (dateRange.endDate?.date) {
|
defaultRange.endDate,
|
||||||
const dt = new Date(dateRange.endDate.date);
|
0,
|
||||||
if (dateRange.endDate.time) {
|
pageSize
|
||||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
|
||||||
dt.setHours(h, m, s || 0);
|
|
||||||
} else {
|
|
||||||
const now = new Date();
|
|
||||||
dt.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
);
|
||||||
}
|
}, [orgId]); // Re-run if orgId changes
|
||||||
timeEnd = dt.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
page: currentPage,
|
|
||||||
pageSize,
|
|
||||||
...filters,
|
|
||||||
resourceId: filters.resourceId
|
|
||||||
? Number(filters.resourceId)
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}, [dateRange, currentPage, pageSize, filters]);
|
|
||||||
|
|
||||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
|
||||||
...logQueries.requests({
|
|
||||||
orgId: orgId as string,
|
|
||||||
filters: queryFilters
|
|
||||||
}),
|
|
||||||
enabled: build !== "oss"
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
|
||||||
const totalCount = data?.pagination?.total ?? 0;
|
|
||||||
const filterAttributes = data?.filterAttributes ?? {
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: [],
|
|
||||||
hosts: [],
|
|
||||||
paths: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page changes
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
newPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page size changes
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when changing page size
|
||||||
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
const newFilters = { ...filters, [filterType]: value };
|
console.log(`${filterType} filter changed:`, value);
|
||||||
|
|
||||||
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -185,6 +204,101 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryDateTime = async (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue,
|
||||||
|
page: number = currentPage,
|
||||||
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
|
// Convert the date/time values to API parameters
|
||||||
|
const params: any = {
|
||||||
|
limit: size,
|
||||||
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate?.date) {
|
||||||
|
const startDateTime = new Date(startDate.date);
|
||||||
|
if (startDate.time) {
|
||||||
|
const [hours, minutes, seconds] = startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
}
|
||||||
|
params.timeStart = startDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate?.date) {
|
||||||
|
const endDateTime = new Date(endDate.date);
|
||||||
|
if (endDate.time) {
|
||||||
|
const [hours, minutes, seconds] = endDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||||
|
} else {
|
||||||
|
// If no time is specified, set to NOW
|
||||||
|
const now = new Date();
|
||||||
|
endDateTime.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.timeEnd = endDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRows(res.data.data.log || []);
|
||||||
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
|
console.log("Fetched logs:", res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("Failed to filter logs"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
console.log("Data refreshed");
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Refresh data with current date range and pagination
|
||||||
|
await queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
currentPage,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
// Prepare query params for export
|
||||||
@@ -667,8 +781,8 @@ export default function GeneralPage() {
|
|||||||
title={t("requestLogs")}
|
title={t("requestLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="host"
|
searchColumn="host"
|
||||||
onRefresh={() => refetch()}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isFetching}
|
isRefreshing={isRefreshing}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -680,6 +794,7 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
|
// Server-side pagination props
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@@ -693,63 +808,3 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
|
|
||||||
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
|
||||||
const paths = [
|
|
||||||
"/api/v1/users",
|
|
||||||
"/dashboard",
|
|
||||||
"/settings",
|
|
||||||
"/health",
|
|
||||||
"/metrics"
|
|
||||||
];
|
|
||||||
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
|
|
||||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
|
||||||
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
|
||||||
const deniedReasons = [201, 202, 203, 204, 205, 299];
|
|
||||||
const actors = [
|
|
||||||
"alice@example.com",
|
|
||||||
"bob@example.com",
|
|
||||||
"carol@example.com",
|
|
||||||
null
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
|
||||||
const action = Math.random() > 0.3;
|
|
||||||
const reason = action
|
|
||||||
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
|
|
||||||
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
|
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: Math.floor(
|
|
||||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
|
||||||
),
|
|
||||||
action,
|
|
||||||
reason,
|
|
||||||
orgId: "sample-org",
|
|
||||||
actorType: actor ? "user" : null,
|
|
||||||
actor,
|
|
||||||
actorId: actor ? `user-${i}` : null,
|
|
||||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
|
||||||
siteResourceId: null,
|
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
|
||||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
|
||||||
location: locations[Math.floor(Math.random() * locations.length)],
|
|
||||||
userAgent: "Mozilla/5.0",
|
|
||||||
metadata: null,
|
|
||||||
headers: null,
|
|
||||||
query: null,
|
|
||||||
originalRequestURL: null,
|
|
||||||
scheme: "https",
|
|
||||||
host: hosts[Math.floor(Math.random() * hosts.length)],
|
|
||||||
path: paths[Math.floor(Math.random() * paths.length)],
|
|
||||||
method: methods[Math.floor(Math.random() * methods.length)],
|
|
||||||
tls: true
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ export default async function ClientResourcesPage(
|
|||||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||||
subdomain: siteResource.subdomain ?? null,
|
subdomain: siteResource.subdomain ?? null,
|
||||||
domainId: siteResource.domainId ?? null,
|
domainId: siteResource.domainId ?? null,
|
||||||
fullDomain: siteResource.fullDomain ?? null,
|
fullDomain: siteResource.fullDomain ?? null
|
||||||
labels: siteResource.labels ?? []
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export default async function ProxyResourcesPage(
|
|||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
labels: resource.labels,
|
|
||||||
authState: !resource.http
|
authState: !resource.http
|
||||||
? "none"
|
? "none"
|
||||||
: resource.sso ||
|
: resource.sso ||
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
return {
|
return {
|
||||||
name: site.name,
|
name: site.name,
|
||||||
id: site.siteId,
|
id: site.siteId,
|
||||||
labels: site.labels,
|
|
||||||
nice: site.niceId.toString(),
|
nice: site.niceId.toString(),
|
||||||
address: site.address?.split("/")[0],
|
address: site.address?.split("/")[0],
|
||||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
TagIcon,
|
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
User,
|
User,
|
||||||
@@ -100,7 +99,7 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/domains",
|
href: "/{orgId}/settings/domains",
|
||||||
icon: <Globe className="size-4 flex-none" />
|
icon: <Globe className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build === "saas"
|
...(build == "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarRemoteExitNodes",
|
title: "sidebarRemoteExitNodes",
|
||||||
@@ -238,19 +237,10 @@ export const orgNavSections = (
|
|||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/{orgId}/settings/api-keys",
|
href: "/{orgId}/settings/api-keys",
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
|
||||||
...(build !== "oss"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "labels",
|
|
||||||
href: "/{orgId}/settings/labels",
|
|
||||||
icon: <TagIcon className="size-4 flex-none" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
...(build === "saas" && options?.isPrimaryOrg
|
...(build == "saas" && options?.isPrimaryOrg
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarBillingAndLicenses",
|
title: "sidebarBillingAndLicenses",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -29,21 +30,13 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Funnel,
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
startTransition,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
@@ -60,10 +53,6 @@ import {
|
|||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -95,11 +84,6 @@ export type InternalResourceRow = {
|
|||||||
subdomain?: string | null;
|
subdomain?: string | null;
|
||||||
domainId?: string | null;
|
domainId?: string | null;
|
||||||
fullDomain?: string | null;
|
fullDomain?: string | null;
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||||
@@ -157,10 +141,7 @@ export default function ClientResourcesTable({
|
|||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -186,7 +167,7 @@ export default function ClientResourcesTable({
|
|||||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startRefreshTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -204,8 +185,8 @@ export default function ClientResourcesTable({
|
|||||||
siteId: number
|
siteId: number
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
startTransition(async () => {
|
|
||||||
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
||||||
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
});
|
});
|
||||||
@@ -273,10 +254,7 @@ export default function ClientResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const internalColumns = useMemo<
|
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||||
ExtendedColumnDef<InternalResourceRow>[]
|
|
||||||
>(() => {
|
|
||||||
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -312,9 +290,7 @@ export default function ClientResourcesTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("identifier")}
|
{t("identifier")}
|
||||||
@@ -328,14 +304,10 @@ export default function ClientResourcesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sites",
|
id: "sites",
|
||||||
accessorFn: (row) =>
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
row.sites.map((s) => s.siteName).join(", "),
|
|
||||||
friendlyName: t("sites"),
|
friendlyName: t("sites"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<Popover
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
open={siteFilterOpen}
|
|
||||||
onOpenChange={setSiteFilterOpen}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -413,9 +385,7 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
selectedValue={searchParams.get("mode") ?? undefined}
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||||
handleFilterChange("mode", value)
|
|
||||||
}
|
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("editInternalResourceDialogMode")}
|
label={t("editInternalResourceDialogMode")}
|
||||||
@@ -440,9 +410,7 @@ export default function ClientResourcesTable({
|
|||||||
accessorKey: "destination",
|
accessorKey: "destination",
|
||||||
friendlyName: t("resourcesTableDestination"),
|
friendlyName: t("resourcesTableDestination"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">
|
<span className="p-3">{t("resourcesTableDestination")}</span>
|
||||||
{t("resourcesTableDestination")}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
@@ -540,10 +508,7 @@ export default function ClientResourcesTable({
|
|||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t("openMenu")}
|
{t("openMenu")}
|
||||||
</span>
|
</span>
|
||||||
@@ -580,27 +545,6 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
cols.splice(cols.length - 1, 0, {
|
|
||||||
id: "labels",
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3 text-end w-full inline-block">
|
|
||||||
{t("labels")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
|
||||||
<ClientResourceLabelCell
|
|
||||||
resource={row.original}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null
|
||||||
@@ -694,8 +638,7 @@ export default function ClientResourcesTable({
|
|||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
aliasAddress: false,
|
aliasAddress: false
|
||||||
labels: false
|
|
||||||
}}
|
}}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -731,101 +674,3 @@ export default function ClientResourcesTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientResourceLabelCellProps = {
|
|
||||||
resource: InternalResourceRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ClientResourceLabelCell({
|
|
||||||
resource,
|
|
||||||
orgId
|
|
||||||
}: ClientResourceLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = resource.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleResourceLabel(
|
|
||||||
label: SelectedLabel,
|
|
||||||
action: "attach" | "detach"
|
|
||||||
) {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ siteResourceId: resource.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ siteResourceId: resource.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleResourceLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState, useTransition } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
cleanForFQDN,
|
cleanForFQDN,
|
||||||
InternalResourceForm,
|
InternalResourceForm,
|
||||||
@@ -39,11 +39,11 @@ export default function CreateInternalResourceDialog({
|
|||||||
}: CreateInternalResourceDialogProps) {
|
}: CreateInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
startTransition(async () => {
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (
|
if (
|
||||||
@@ -60,9 +60,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put<
|
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
|
||||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
`/org/${orgId}/site-resource`,
|
||||||
>(`/org/${orgId}/site-resource`, {
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
@@ -106,7 +106,8 @@ export default function CreateInternalResourceDialog({
|
|||||||
clientIds: data.clients
|
clientIds: data.clients
|
||||||
? data.clients.map((c) => parseInt(c.id))
|
? data.clients.map((c) => parseInt(c.id))
|
||||||
: []
|
: []
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
@@ -128,8 +129,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "./Credenza";
|
|
||||||
import { OrgLabelForm } from "./OrgLabelForm";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export type CreateOrgLabelDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (val: boolean) => void;
|
|
||||||
orgId: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreateOrgLabelDialog({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
orgId,
|
|
||||||
onSuccess
|
|
||||||
}: CreateOrgLabelDialogProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function createOrgLabel(data: { name: string; color: string }) {
|
|
||||||
try {
|
|
||||||
const res = await api.post<
|
|
||||||
AxiosResponse<CreateOrEditLabelResponse>
|
|
||||||
>(`/org/${orgId}/labels`, data);
|
|
||||||
|
|
||||||
if (res.status === 201) {
|
|
||||||
setOpen(false);
|
|
||||||
onSuccess?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("labelCreateSuccessMessage")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
|
||||||
<CredenzaContent className="md:max-w-md">
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("createLabelDialogDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<OrgLabelForm
|
|
||||||
onSubmit={(data) => {
|
|
||||||
startTransition(async () => createOrgLabel(data));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="org-label-form"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("labelCreate")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -318,28 +318,12 @@ export default function DeviceLoginForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={8}
|
maxLength={9}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value
|
value={field.value
|
||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
onPaste={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const pastedText =
|
|
||||||
event.clipboardData.getData(
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
const cleanedValue =
|
|
||||||
pastedText
|
|
||||||
.replace(
|
|
||||||
/[^a-zA-Z0-9]/g,
|
|
||||||
""
|
|
||||||
)
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 8);
|
|
||||||
field.onChange(cleanedValue);
|
|
||||||
}}
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// Strip hyphens and convert to uppercase
|
// Strip hyphens and convert to uppercase
|
||||||
const cleanedValue = value
|
const cleanedValue = value
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "./Credenza";
|
|
||||||
import { OrgLabelForm } from "./OrgLabelForm";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export type EditOrgLabelDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (val: boolean) => void;
|
|
||||||
orgId: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
label: {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
labelId: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditOrgLabelDialog({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
orgId,
|
|
||||||
onSuccess,
|
|
||||||
label
|
|
||||||
}: EditOrgLabelDialogProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function editOrgLabel(data: { name: string; color: string }) {
|
|
||||||
try {
|
|
||||||
const res = await api.patch<
|
|
||||||
AxiosResponse<CreateOrEditLabelResponse>
|
|
||||||
>(`/org/${orgId}/label/${label.labelId}`, data);
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
setOpen(false);
|
|
||||||
onSuccess?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("labelEditSuccessMessage")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
|
||||||
<CredenzaContent className="md:max-w-md">
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("editLabelDialogDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<OrgLabelForm
|
|
||||||
defaultValue={label}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
startTransition(async () => editOrgLabel(data));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="org-label-form"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("labelEdit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Loader,
|
Loader,
|
||||||
LoaderIcon,
|
|
||||||
RefreshCw
|
RefreshCw
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -428,7 +427,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="relative">
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -536,19 +535,6 @@ export function LogDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<>
|
|
||||||
<div className="backdrop-blur-[3px] z-10 absolute inset-0 top-10"></div>
|
|
||||||
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
|
|
||||||
<div className="flex items-center gap-2 p-6">
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
{t("loadingEllipsis")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -22,26 +19,12 @@ import {
|
|||||||
CircleSlash,
|
CircleSlash,
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { useMemo, useState, useTransition } from "react";
|
||||||
startTransition,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "./ui/popover";
|
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
@@ -70,11 +53,6 @@ export type ClientRow = {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
approvalState: "approved" | "pending" | "denied";
|
approvalState: "approved" | "pending" | "denied";
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
@@ -106,21 +84,17 @@ export default function MachineClientsTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
const defaultMachineColumnVisibility = {
|
const defaultMachineColumnVisibility = {
|
||||||
subnet: false,
|
subnet: false,
|
||||||
userId: false,
|
userId: false,
|
||||||
niceId: false,
|
niceId: false
|
||||||
labels: false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startRefreshTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -410,24 +384,6 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
baseColumns.push({
|
|
||||||
id: "labels",
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3 text-end w-full inline-block">
|
|
||||||
{t("labels")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: ClientRow } }) => (
|
|
||||||
<MachineClientLabelCell
|
|
||||||
client={row.original}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include actions column if there are rows without userIds
|
// Only include actions column if there are rows without userIds
|
||||||
if (hasRowsWithoutUserId) {
|
if (hasRowsWithoutUserId) {
|
||||||
baseColumns.push({
|
baseColumns.push({
|
||||||
@@ -508,7 +464,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -635,95 +591,3 @@ export default function MachineClientsTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineClientLabelCellProps = {
|
|
||||||
client: ClientRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = client.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ clientId: client.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ clientId: client.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleClientLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { useTranslations } from "use-intl";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "./ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "./ui/select";
|
|
||||||
import { LABEL_COLORS } from "./labels-selector";
|
|
||||||
|
|
||||||
const labelFormSchema = z.object({
|
|
||||||
name: z.string().nonempty(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LabelFormData = z.infer<typeof labelFormSchema>;
|
|
||||||
|
|
||||||
export type OrgLabelFormProps = {
|
|
||||||
onSubmit: (data: LabelFormData) => void;
|
|
||||||
defaultValue?: LabelFormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const colorValues = Object.values(LABEL_COLORS);
|
|
||||||
const randomColor =
|
|
||||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(labelFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValue?.name ?? "",
|
|
||||||
color: defaultValue?.color ?? randomColor
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="org-label-form"
|
|
||||||
className="flex flex-col gap-4 px-0.5"
|
|
||||||
action={async () => {
|
|
||||||
if (await form.trigger()) {
|
|
||||||
onSubmit(form.getValues());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("labelNameField")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder={t("labelPlaceholder")}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="color"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("labelColorField")}</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("selectColor")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(LABEL_COLORS).map(
|
|
||||||
([color, value]) => (
|
|
||||||
<SelectItem
|
|
||||||
value={value}
|
|
||||||
key={color}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="size-4 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": value
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span data-name>{color}</span>
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { type PaginationState } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowDown01Icon,
|
|
||||||
ArrowUp10Icon,
|
|
||||||
ChevronsUpDownIcon,
|
|
||||||
MoreHorizontal,
|
|
||||||
PencilIcon,
|
|
||||||
PencilLineIcon
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import {
|
|
||||||
ControlledDataTable,
|
|
||||||
type ExtendedColumnDef
|
|
||||||
} from "./ui/controlled-data-table";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
|
||||||
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
|
|
||||||
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
|
|
||||||
|
|
||||||
export type LabelRow = {
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgLabelsTableProps = {
|
|
||||||
labels: LabelRow[];
|
|
||||||
pagination: PaginationState;
|
|
||||||
orgId: string;
|
|
||||||
rowCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrgLabelsTable({
|
|
||||||
labels,
|
|
||||||
orgId,
|
|
||||||
pagination,
|
|
||||||
rowCount
|
|
||||||
}: OrgLabelsTableProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const {
|
|
||||||
navigate: filter,
|
|
||||||
isNavigating: isFiltering,
|
|
||||||
searchParams
|
|
||||||
} = useNavigationContext();
|
|
||||||
|
|
||||||
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
function refreshData() {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaginationChange = (newPage: PaginationState) => {
|
|
||||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
|
||||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
|
||||||
filter({ searchParams });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
|
||||||
searchParams.set("query", query);
|
|
||||||
searchParams.delete("page");
|
|
||||||
filter({ searchParams });
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("name")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-1.5 group">
|
|
||||||
<div
|
|
||||||
className="size-2.5 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": row.original.color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{row.original.name}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("actions")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">{t("openMenu")}</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedLabel(row.original);
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedLabel(row.original);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">
|
|
||||||
{t("delete")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[searchParams, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
function deleteLabel(label: LabelRow) {
|
|
||||||
startTransition(async () => {
|
|
||||||
await api
|
|
||||||
.delete(`/org/${orgId}/label/${label.labelId}`)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("labelErrorDelete"),
|
|
||||||
description: formatAxiosError(e, t("labelErrorDelete"))
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
router.refresh();
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{selectedLabel && (
|
|
||||||
<>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={isDeleteModalOpen}
|
|
||||||
setOpen={(val) => {
|
|
||||||
setIsDeleteModalOpen(val);
|
|
||||||
setSelectedLabel(null);
|
|
||||||
}}
|
|
||||||
dialog={
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>{t("labelQuestionRemove")}</p>
|
|
||||||
<p>{t("labelMessageRemove")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
buttonText={t("labelDeleteConfirm")}
|
|
||||||
onConfirm={async () => deleteLabel(selectedLabel)}
|
|
||||||
string={selectedLabel.name}
|
|
||||||
title={t("labelDelete")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditOrgLabelDialog
|
|
||||||
open={isEditModalOpen}
|
|
||||||
setOpen={setIsEditModalOpen}
|
|
||||||
orgId={orgId}
|
|
||||||
onSuccess={() =>
|
|
||||||
startTransition(() => router.refresh())
|
|
||||||
}
|
|
||||||
label={selectedLabel}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateOrgLabelDialog
|
|
||||||
open={isCreateModalOpen}
|
|
||||||
setOpen={setIsCreateModalOpen}
|
|
||||||
orgId={orgId}
|
|
||||||
onSuccess={() => startTransition(() => router.refresh())}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ControlledDataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={labels}
|
|
||||||
addButtonText={t("labelAdd")}
|
|
||||||
onAdd={() => setIsCreateModalOpen(true)}
|
|
||||||
tableId="org-labels-table"
|
|
||||||
searchPlaceholder={t("labelSearch")}
|
|
||||||
pagination={pagination}
|
|
||||||
onPaginationChange={handlePaginationChange}
|
|
||||||
searchQuery={searchParams.get("query")?.toString()}
|
|
||||||
onSearch={handleSearchChange}
|
|
||||||
onRefresh={refreshData}
|
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
|
||||||
rowCount={rowCount}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
|
||||||
import {
|
import {
|
||||||
ResourceSitesStatusCell,
|
ResourceSitesStatusCell,
|
||||||
type ResourceSiteRow
|
type ResourceSiteRow
|
||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
@@ -26,14 +24,12 @@ import {
|
|||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { build } from "@server/build";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
@@ -47,7 +43,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Funnel,
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
XCircle
|
XCircle
|
||||||
@@ -56,7 +51,6 @@ import { useTranslations } from "next-intl";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
startTransition,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
useOptimistic,
|
||||||
@@ -70,8 +64,8 @@ import z from "zod";
|
|||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import UptimeMiniBar from "./UptimeMiniBar";
|
import UptimeMiniBar from "./UptimeMiniBar";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -103,13 +97,31 @@ export type ResourceRow = {
|
|||||||
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
||||||
sites: ResourceSiteRow[];
|
sites: ResourceSiteRow[];
|
||||||
wildcard?: boolean;
|
wildcard?: boolean;
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function StatusIcon({
|
||||||
|
status,
|
||||||
|
className = ""
|
||||||
|
}: {
|
||||||
|
status: string | undefined | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const iconClass = `h-4 w-4 ${className}`;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "healthy":
|
||||||
|
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
|
||||||
|
case "degraded":
|
||||||
|
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
|
||||||
|
case "unhealthy":
|
||||||
|
return <XCircle className={`${iconClass} text-destructive`} />;
|
||||||
|
case "unknown":
|
||||||
|
return <Clock className={`${iconClass} text-muted-foreground`} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyResourcesTableProps = {
|
type ProxyResourcesTableProps = {
|
||||||
resources: ResourceRow[];
|
resources: ResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -118,11 +130,6 @@ type ProxyResourcesTableProps = {
|
|||||||
initialFilterSite?: Selectedsite | null;
|
initialFilterSite?: Selectedsite | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.optional()
|
|
||||||
.catch(undefined);
|
|
||||||
|
|
||||||
export default function ProxyResourcesTable({
|
export default function ProxyResourcesTable({
|
||||||
resources,
|
resources,
|
||||||
orgId,
|
orgId,
|
||||||
@@ -146,9 +153,6 @@ export default function ProxyResourcesTable({
|
|||||||
const [selectedResource, setSelectedResource] =
|
const [selectedResource, setSelectedResource] =
|
||||||
useState<ResourceRow | null>();
|
useState<ResourceRow | null>();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
@@ -229,30 +233,121 @@ export default function ProxyResourcesTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSiteFilter = () => {
|
function TargetStatusCell({
|
||||||
handleFilterChange("siteId", undefined);
|
targets,
|
||||||
setSiteFilterOpen(false);
|
healthStatus
|
||||||
};
|
}: {
|
||||||
|
targets?: TargetHealth[];
|
||||||
|
healthStatus?: string;
|
||||||
|
}) {
|
||||||
|
const overallStatus = healthStatus;
|
||||||
|
|
||||||
const onPickSite = (site: Selectedsite) => {
|
if (!targets || targets.length === 0) {
|
||||||
handleFilterChange("siteId", String(site.siteId));
|
return (
|
||||||
setSiteFilterOpen(false);
|
<div className="flex items-center gap-2">
|
||||||
};
|
<StatusIcon status="unknown" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{t("resourcesTableNoTargets")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const siteFilterOpenRef = useRef(siteFilterOpen);
|
const monitoredTargets = targets.filter(
|
||||||
siteFilterOpenRef.current = siteFilterOpen;
|
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
|
||||||
|
);
|
||||||
|
const unknownTargets = targets.filter(
|
||||||
|
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
const selectedSiteRef = useRef(selectedSite);
|
return (
|
||||||
selectedSiteRef.current = selectedSite;
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<StatusIcon status={overallStatus} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{overallStatus === "healthy" &&
|
||||||
|
t("resourcesTableHealthy")}
|
||||||
|
{overallStatus === "degraded" &&
|
||||||
|
t("resourcesTableDegraded")}
|
||||||
|
{overallStatus === "unhealthy" &&
|
||||||
|
t("resourcesTableUnhealthy")}
|
||||||
|
{overallStatus === "unknown" &&
|
||||||
|
t("resourcesTableUnknown")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-70">
|
||||||
|
{monitoredTargets.length > 0 && (
|
||||||
|
<>
|
||||||
|
{monitoredTargets.map((target) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={target.targetId}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon
|
||||||
|
status={
|
||||||
|
target.healthStatus ===
|
||||||
|
"healthy"
|
||||||
|
? "online"
|
||||||
|
: "offline"
|
||||||
|
}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
{target.siteName
|
||||||
|
? `${target.siteName} (${target.ip}:${target.port})`
|
||||||
|
: `${target.ip}:${target.port}`}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`capitalize ${
|
||||||
|
target.healthStatus === "healthy"
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{target.healthStatus}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{unknownTargets.length > 0 && (
|
||||||
|
<>
|
||||||
|
{unknownTargets.map((target) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={target.targetId}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon
|
||||||
|
status="unknown"
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
{target.siteName
|
||||||
|
? `${target.siteName} (${target.ip}:${target.port})`
|
||||||
|
: `${target.ip}:${target.port}`}
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{!target.enabled
|
||||||
|
? t("disabled")
|
||||||
|
: t("resourcesTableNotMonitored")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const clearSiteFilterRef = useRef(clearSiteFilter);
|
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
|
||||||
clearSiteFilterRef.current = clearSiteFilter;
|
|
||||||
|
|
||||||
const onPickSiteRef = useRef(onPickSite);
|
|
||||||
onPickSiteRef.current = onPickSite;
|
|
||||||
|
|
||||||
const proxyColumns = useMemo<ExtendedColumnDef<ResourceRow>[]>(() => {
|
|
||||||
const cols: ExtendedColumnDef<ResourceRow>[] = [
|
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -290,14 +385,10 @@ export default function ProxyResourcesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sites",
|
id: "sites",
|
||||||
accessorFn: (row) =>
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
row.sites.map((s) => s.siteName).join(", "),
|
|
||||||
friendlyName: t("sites"),
|
friendlyName: t("sites"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<Popover
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
open={siteFilterOpenRef.current}
|
|
||||||
onOpenChange={setSiteFilterOpen}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -305,19 +396,18 @@ export default function ProxyResourcesTable({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
!selectedSiteRef.current &&
|
!selectedSite && "text-muted-foreground"
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{t("sites")}
|
{t("sites")}
|
||||||
<Funnel className="size-4 flex-none" />
|
<Funnel className="size-4 flex-none" />
|
||||||
{selectedSiteRef.current && (
|
{selectedSite && (
|
||||||
<Badge
|
<Badge
|
||||||
className="truncate max-w-[10rem]"
|
className="truncate max-w-[10rem]"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{selectedSiteRef.current.name}
|
{selectedSite.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,17 +423,15 @@ export default function ProxyResourcesTable({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-full justify-start font-normal"
|
className="h-8 w-full justify-start font-normal"
|
||||||
onClick={() => clearSiteFilterRef.current()}
|
onClick={clearSiteFilter}
|
||||||
>
|
>
|
||||||
{t("standaloneHcFilterAnySite")}
|
{t("standaloneHcFilterAnySite")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<SitesSelector
|
<SitesSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedSite={selectedSiteRef.current}
|
selectedSite={selectedSite}
|
||||||
onSelectSite={(site) =>
|
onSelectSite={onPickSite}
|
||||||
onPickSiteRef.current(site)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -380,10 +468,7 @@ export default function ProxyResourcesTable({
|
|||||||
header: () => (
|
header: () => (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: "healthy", label: t("resourcesTableHealthy") },
|
||||||
value: "healthy",
|
|
||||||
label: t("resourcesTableHealthy")
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "degraded",
|
value: "degraded",
|
||||||
label: t("resourcesTableDegraded")
|
label: t("resourcesTableDegraded")
|
||||||
@@ -392,10 +477,7 @@ export default function ProxyResourcesTable({
|
|||||||
value: "unhealthy",
|
value: "unhealthy",
|
||||||
label: t("resourcesTableUnhealthy")
|
label: t("resourcesTableUnhealthy")
|
||||||
},
|
},
|
||||||
{
|
{ value: "unknown", label: t("resourcesTableUnknown") }
|
||||||
value: "unknown",
|
|
||||||
label: t("resourcesTableUnknown")
|
|
||||||
}
|
|
||||||
]}
|
]}
|
||||||
selectedValue={
|
selectedValue={
|
||||||
searchParams.get("healthStatus") ?? undefined
|
searchParams.get("healthStatus") ?? undefined
|
||||||
@@ -439,9 +521,7 @@ export default function ProxyResourcesTable({
|
|||||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return <UptimeMiniBar resourceId={resourceRow.id} days={30} />;
|
||||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -455,9 +535,7 @@ export default function ProxyResourcesTable({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={
|
text={resourceRow.proxyPort?.toString() || ""}
|
||||||
resourceRow.proxyPort?.toString() || ""
|
|
||||||
}
|
|
||||||
isLink={false}
|
isLink={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,15 +591,10 @@ export default function ProxyResourcesTable({
|
|||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
options={[
|
options={[
|
||||||
{ value: "protected", label: t("protected") },
|
{ value: "protected", label: t("protected") },
|
||||||
{
|
{ value: "not_protected", label: t("notProtected") },
|
||||||
value: "not_protected",
|
|
||||||
label: t("notProtected")
|
|
||||||
},
|
|
||||||
{ value: "none", label: t("none") }
|
{ value: "none", label: t("none") }
|
||||||
]}
|
]}
|
||||||
selectedValue={
|
selectedValue={searchParams.get("authState") ?? undefined}
|
||||||
searchParams.get("authState") ?? undefined
|
|
||||||
}
|
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("authState", value)
|
handleFilterChange("authState", value)
|
||||||
}
|
}
|
||||||
@@ -590,10 +663,7 @@ export default function ProxyResourcesTable({
|
|||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t("openMenu")}
|
{t("openMenu")}
|
||||||
</span>
|
</span>
|
||||||
@@ -635,23 +705,10 @@ export default function ProxyResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
const booleanSearchFilterSchema = z
|
||||||
cols.splice(cols.length - 1, 0, {
|
.enum(["true", "false"])
|
||||||
id: "labels",
|
.optional()
|
||||||
accessorKey: "labels",
|
.catch(undefined);
|
||||||
header: () => (
|
|
||||||
<span className="p-3 text-end w-full inline-block">
|
|
||||||
{t("labels")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
|
||||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
@@ -668,6 +725,16 @@ export default function ProxyResourcesTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSiteFilter = () => {
|
||||||
|
handleFilterChange("siteId", undefined);
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickSite = (site: Selectedsite) => {
|
||||||
|
handleFilterChange("siteId", String(site.siteId));
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
@@ -733,11 +800,7 @@ export default function ProxyResourcesTable({
|
|||||||
isRefreshing={isRefreshing || isFiltering}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{ niceId: false, protocol: false }}
|
||||||
niceId: false,
|
|
||||||
protocol: false,
|
|
||||||
labels: false
|
|
||||||
}}
|
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
@@ -745,217 +808,6 @@ export default function ProxyResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourceLabelCellProps = {
|
|
||||||
resource: ResourceRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = resource.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
|
||||||
label: SelectedLabel,
|
|
||||||
action: "attach" | "detach"
|
|
||||||
) {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ resourceId: resource.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ resourceId: resource.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TargetStatusCell({
|
|
||||||
targets,
|
|
||||||
healthStatus
|
|
||||||
}: {
|
|
||||||
targets?: TargetHealth[];
|
|
||||||
healthStatus?: string;
|
|
||||||
}) {
|
|
||||||
const overallStatus = healthStatus;
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
if (!targets || targets.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-2">
|
|
||||||
<StatusIcon status="unknown" />
|
|
||||||
<span className="text-sm">{t("resourcesTableNoTargets")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const monitoredTargets = targets.filter(
|
|
||||||
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
|
|
||||||
);
|
|
||||||
const unknownTargets = targets.filter(
|
|
||||||
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2 h-8 px-2 font-normal"
|
|
||||||
>
|
|
||||||
<StatusIcon status={overallStatus} />
|
|
||||||
<span className="text-sm">
|
|
||||||
{overallStatus === "healthy" &&
|
|
||||||
t("resourcesTableHealthy")}
|
|
||||||
{overallStatus === "degraded" &&
|
|
||||||
t("resourcesTableDegraded")}
|
|
||||||
{overallStatus === "unhealthy" &&
|
|
||||||
t("resourcesTableUnhealthy")}
|
|
||||||
{overallStatus === "unknown" &&
|
|
||||||
t("resourcesTableUnknown")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="min-w-70">
|
|
||||||
{monitoredTargets.length > 0 && (
|
|
||||||
<>
|
|
||||||
{monitoredTargets.map((target) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={target.targetId}
|
|
||||||
className="flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusIcon
|
|
||||||
status={
|
|
||||||
target.healthStatus === "healthy"
|
|
||||||
? "online"
|
|
||||||
: "offline"
|
|
||||||
}
|
|
||||||
className="h-3 w-3"
|
|
||||||
/>
|
|
||||||
{target.siteName
|
|
||||||
? `${target.siteName} (${target.ip}:${target.port})`
|
|
||||||
: `${target.ip}:${target.port}`}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`capitalize ${
|
|
||||||
target.healthStatus === "healthy"
|
|
||||||
? "text-green-500"
|
|
||||||
: "text-destructive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{target.healthStatus}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{unknownTargets.length > 0 && (
|
|
||||||
<>
|
|
||||||
{unknownTargets.map((target) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={target.targetId}
|
|
||||||
className="flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusIcon
|
|
||||||
status="unknown"
|
|
||||||
className="h-3 w-3"
|
|
||||||
/>
|
|
||||||
{target.siteName
|
|
||||||
? `${target.siteName} (${target.ip}:${target.port})`
|
|
||||||
: `${target.ip}:${target.port}`}
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{!target.enabled
|
|
||||||
? t("disabled")
|
|
||||||
: t("resourcesTableNotMonitored")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResourceEnabledFormProps = {
|
type ResourceEnabledFormProps = {
|
||||||
resource: ResourceRow;
|
resource: ResourceRow;
|
||||||
onToggleResourceEnabled: (
|
onToggleResourceEnabled: (
|
||||||
@@ -995,26 +847,3 @@ function ResourceEnabledForm({
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIcon({
|
|
||||||
status,
|
|
||||||
className = ""
|
|
||||||
}: {
|
|
||||||
status: string | undefined | null;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const iconClass = `h-4 w-4 ${className}`;
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case "healthy":
|
|
||||||
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
|
|
||||||
case "degraded":
|
|
||||||
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
|
|
||||||
case "unhealthy":
|
|
||||||
return <XCircle className={`${iconClass} text-destructive`} />;
|
|
||||||
case "unknown":
|
|
||||||
return <Clock className={`${iconClass} text-muted-foreground`} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,20 +46,6 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
|
||||||
if (!value?.trim()) return true;
|
|
||||||
|
|
||||||
const commands = value
|
|
||||||
.split(",")
|
|
||||||
.map((command) => command.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return commands.every((command) => {
|
|
||||||
const executable = command.split(/\s+/)[0];
|
|
||||||
return executable.startsWith("/");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoleFormValues = {
|
export type RoleFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -88,8 +74,7 @@ export function RoleForm({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z.object({
|
||||||
.object({
|
|
||||||
name: z
|
name: z
|
||||||
.string({ message: t("nameRequired") })
|
.string({ message: t("nameRequired") })
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -101,19 +86,6 @@ export function RoleForm({
|
|||||||
sshSudoCommands: z.string().optional(),
|
sshSudoCommands: z.string().optional(),
|
||||||
sshCreateHomeDir: z.boolean().optional(),
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
sshUnixGroups: z.string().optional()
|
sshUnixGroups: z.string().optional()
|
||||||
})
|
|
||||||
.superRefine((values, ctx) => {
|
|
||||||
if (
|
|
||||||
values.sshSudoMode === "commands" &&
|
|
||||||
!hasOnlyAbsoluteSudoCommands(values.sshSudoCommands)
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["sshSudoCommands"],
|
|
||||||
message:
|
|
||||||
"Each sudo command must start with an absolute path (for example, /usr/bin/systemctl)."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = role
|
const defaultValues: RoleFormValues = role
|
||||||
@@ -324,9 +296,7 @@ export function RoleForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="allowSsh"
|
name="allowSsh"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const allowSshOptions: OptionSelectOption<
|
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
||||||
"allow" | "disallow"
|
|
||||||
>[] = [
|
|
||||||
{
|
{
|
||||||
value: "allow",
|
value: "allow",
|
||||||
label: t("roleAllowSshAllow")
|
label: t("roleAllowSshAllow")
|
||||||
@@ -341,9 +311,7 @@ export function RoleForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("roleAllowSsh")}
|
{t("roleAllowSsh")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<OptionSelect<
|
<OptionSelect<"allow" | "disallow">
|
||||||
"allow" | "disallow"
|
|
||||||
>
|
|
||||||
options={allowSshOptions}
|
options={allowSshOptions}
|
||||||
value={
|
value={
|
||||||
sshDisabled
|
sshDisabled
|
||||||
@@ -354,9 +322,7 @@ export function RoleForm({
|
|||||||
}
|
}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (sshDisabled) return;
|
if (sshDisabled) return;
|
||||||
field.onChange(
|
field.onChange(v === "allow");
|
||||||
v === "allow"
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
|
|||||||
@@ -3,16 +3,6 @@
|
|||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||||
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -24,9 +14,9 @@ import {
|
|||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { type PaginationState } from "@tanstack/react-table";
|
import { type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
@@ -36,35 +26,30 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import { useState, useTransition, useEffect } from "react";
|
||||||
startTransition,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
import {
|
import {
|
||||||
ControlledDataTable,
|
ControlledDataTable,
|
||||||
type ExtendedColumnDef
|
type ExtendedColumnDef
|
||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
nice: string;
|
nice: string;
|
||||||
@@ -81,11 +66,6 @@ export type SiteRow = {
|
|||||||
exitNodeEndpoint?: string;
|
exitNodeEndpoint?: string;
|
||||||
remoteExitNodeId?: string;
|
remoteExitNodeId?: string;
|
||||||
resourceCount: number;
|
resourceCount: number;
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SitesTableProps = {
|
type SitesTableProps = {
|
||||||
@@ -116,9 +96,6 @@ export default function SitesTable({
|
|||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -181,8 +158,7 @@ export default function SitesTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
|
const columns: ExtendedColumnDef<SiteRow>[] = [
|
||||||
const cols: ExtendedColumnDef<SiteRow>[] = [
|
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -390,7 +366,7 @@ export default function SitesTable({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setResourcesDialogSite(siteRow)}
|
onClick={() => setResourcesDialogSite(siteRow)}
|
||||||
className="flex h-8 items-center gap-2 px-2 font-normal"
|
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||||
>
|
>
|
||||||
<span className="text-sm tabular-nums">
|
<span className="text-sm tabular-nums">
|
||||||
{siteRow.resourceCount} {t("resources")}
|
{siteRow.resourceCount} {t("resources")}
|
||||||
@@ -461,7 +437,7 @@ export default function SitesTable({
|
|||||||
header: () => {
|
header: () => {
|
||||||
return <span className="p-3">{t("address")}</span>;
|
return <span className="p-3">{t("address")}</span>;
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }: { row: any }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
return originalRow.address ? (
|
return originalRow.address ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -512,6 +488,16 @@ export default function SitesTable({
|
|||||||
{t("sitesTableViewPrivateResources")}
|
{t("sitesTableViewPrivateResources")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSite(siteRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
@@ -528,23 +514,6 @@ export default function SitesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
cols.splice(cols.length - 1, 0, {
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3 text-end w-full inline-block">
|
|
||||||
{t("labels")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
|
||||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
@@ -653,8 +622,7 @@ export default function SitesTable({
|
|||||||
niceId: false,
|
niceId: false,
|
||||||
nice: false,
|
nice: false,
|
||||||
exitNode: false,
|
exitNode: false,
|
||||||
address: false,
|
address: false
|
||||||
labels: false
|
|
||||||
}}
|
}}
|
||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
@@ -663,102 +631,3 @@ export default function SitesTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteLabelCellProps = {
|
|
||||||
site: SiteRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = site.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
|
||||||
label: SelectedLabel,
|
|
||||||
action: "attach" | "detach"
|
|
||||||
) {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ siteId: site.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ siteId: site.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export type LabelBadgeProps = {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LabelBadge({
|
|
||||||
onClick,
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
className
|
|
||||||
}: LabelBadgeProps) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"pl-1.5 pr-2 py-0 h-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="size-3 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="whitespace-nowrap text-ellipsis max-w-16 overflow-hidden relative">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { orgQueries } from "@app/lib/queries";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useActionState, useMemo, useState } from "react";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Checkbox } from "./ui/checkbox";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "./ui/command";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "./ui/select";
|
|
||||||
|
|
||||||
export type SelectedLabel = {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
labelId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LabelsSelectorProps = {
|
|
||||||
orgId: string;
|
|
||||||
selectedLabels: SelectedLabel[];
|
|
||||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LABEL_COLORS = {
|
|
||||||
red: "#ff6467",
|
|
||||||
green: "#05df72",
|
|
||||||
blue: "#51a2ff",
|
|
||||||
yellow: "#fdc744",
|
|
||||||
orange: "#ff8905",
|
|
||||||
purple: "#a684ff",
|
|
||||||
gray: "#b4b4b4"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LabelsSelector({
|
|
||||||
orgId,
|
|
||||||
selectedLabels,
|
|
||||||
toggleLabel
|
|
||||||
}: LabelsSelectorProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const { data: labels = [] } = useQuery(
|
|
||||||
orgQueries.labels({
|
|
||||||
orgId,
|
|
||||||
query: debouncedQuery,
|
|
||||||
perPage: 10
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelsShown = useMemo(() => {
|
|
||||||
const base = [...labels];
|
|
||||||
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
|
|
||||||
const selectedNotInBase = selectedLabels.filter(
|
|
||||||
(sel) => !base.some((s) => s.labelId === sel.labelId)
|
|
||||||
);
|
|
||||||
return [...selectedNotInBase, ...base];
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}, [debouncedQuery, labels, selectedLabels]);
|
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
|
||||||
() => new Set(selectedLabels.map((s) => s.labelId)),
|
|
||||||
[selectedLabels]
|
|
||||||
);
|
|
||||||
|
|
||||||
const colorValues = Object.values(LABEL_COLORS);
|
|
||||||
const randomColor =
|
|
||||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
|
||||||
|
|
||||||
const [, action, isPending] = useActionState(createLabel, null);
|
|
||||||
|
|
||||||
async function createLabel(_: any, formData: FormData) {
|
|
||||||
const name = formData.get("name")?.toString();
|
|
||||||
const color = formData.get("color")?.toString();
|
|
||||||
try {
|
|
||||||
const res = await api.post<
|
|
||||||
AxiosResponse<CreateOrEditLabelResponse>
|
|
||||||
>(`/org/${orgId}/labels`, { name, color });
|
|
||||||
|
|
||||||
const { label } = res.data.data;
|
|
||||||
|
|
||||||
toggleLabel(
|
|
||||||
{
|
|
||||||
labelId: label.labelId,
|
|
||||||
name: label.name,
|
|
||||||
color: label.color
|
|
||||||
},
|
|
||||||
"attach"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setlabelsSearchQuery("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Command shouldFilter={false}>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={t("labelSearch")}
|
|
||||||
value={labelSearchQuery}
|
|
||||||
onValueChange={setlabelsSearchQuery}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="px-3 break-all wrap-anywhere text-wrap">
|
|
||||||
{labelSearchQuery.trim().length > 0 ? (
|
|
||||||
<div className="flex flex-col gap-2 items-center">
|
|
||||||
<span className="max-w-34">
|
|
||||||
{t("createNewLabel", {
|
|
||||||
label: labelSearchQuery.trim()
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<form
|
|
||||||
action={action}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="name"
|
|
||||||
value={labelSearchQuery.trim()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select defaultValue={randomColor} name="color">
|
|
||||||
<SelectTrigger className="w-18 [&_[data-name]]:hidden [&_[svg]]:hidden!">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("selectColor")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(LABEL_COLORS).map(
|
|
||||||
([color, value]) => (
|
|
||||||
<SelectItem
|
|
||||||
value={value}
|
|
||||||
key={color}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="size-4 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": value
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span data-name>
|
|
||||||
{color}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
loading={isPending}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("create")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t("labelsNotFound")
|
|
||||||
)}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{labelsShown.map((label) => (
|
|
||||||
<CommandItem
|
|
||||||
key={label.labelId}
|
|
||||||
value={`${label.labelId}`}
|
|
||||||
onSelect={() => {
|
|
||||||
toggleLabel(
|
|
||||||
label,
|
|
||||||
selectedIds.has(label.labelId)
|
|
||||||
? "detach"
|
|
||||||
: "attach"
|
|
||||||
);
|
|
||||||
// } else {
|
|
||||||
// onSelectionChange([
|
|
||||||
// ...selectedLabels,
|
|
||||||
// label
|
|
||||||
// ]);
|
|
||||||
// }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
className="pointer-events-none shrink-0"
|
|
||||||
checked={selectedIds.has(label.labelId)}
|
|
||||||
onCheckedChange={() => {}}
|
|
||||||
aria-hidden
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="inline-block size-3 flex-none rounded-full bg-(--label-color)"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error CSS variable
|
|
||||||
"--label-color": label.color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
variant: "outline"
|
variant: "outline"
|
||||||
}),
|
}),
|
||||||
"justify-between w-full inline-flex",
|
"justify-between w-full inline-flex",
|
||||||
"text-muted-foreground pl-1.5 cursor-text h-auto py-1",
|
"text-muted-foreground pl-1.5 cursor-text",
|
||||||
"hover:bg-transparent hover:text-muted-foreground",
|
"hover:bg-transparent hover:text-muted-foreground",
|
||||||
props.disabled && "pointer-events-none opacity-50"
|
props.disabled && "pointer-events-none opacity-50"
|
||||||
)}
|
)}
|
||||||
@@ -49,7 +49,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1",
|
"inline-flex items-center gap-1",
|
||||||
"overflow-x-auto flex-wrap h-auto"
|
"overflow-x-auto"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.value.map((option) => (
|
{props.value.map((option) => (
|
||||||
@@ -61,9 +61,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
|
||||||
{option.text}
|
{option.text}
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
className="p-0.5 flex-none cursor-pointer"
|
className="p-0.5 flex-none cursor-pointer"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -305,7 +305,6 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
onSearch(e.currentTarget.value)
|
onSearch(e.currentTarget.value)
|
||||||
}
|
}
|
||||||
className="w-full pl-8"
|
className="w-full pl-8"
|
||||||
type="search"
|
|
||||||
/>
|
/>
|
||||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type {
|
|
||||||
QueryAccessAuditLogResponse,
|
|
||||||
QueryActionAuditLogResponse,
|
|
||||||
QueryConnectionAuditLogResponse,
|
|
||||||
QueryRequestAuditLogResponse
|
|
||||||
} from "@server/routers/auditLogs/types";
|
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
import type {
|
import type {
|
||||||
GetDNSRecordsResponse,
|
ListDomainsResponse,
|
||||||
ListDomainsResponse
|
GetDNSRecordsResponse
|
||||||
} from "@server/routers/domain";
|
} from "@server/routers/domain";
|
||||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
|
||||||
import type {
|
import type {
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
ListResourceNamesResponse,
|
ListResourceNamesResponse,
|
||||||
ListResourcesResponse
|
ListResourcesResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
|
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -39,7 +31,8 @@ import type { AxiosResponse } from "axios";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -215,33 +208,6 @@ export const orgQueries = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
labels: ({
|
|
||||||
orgId,
|
|
||||||
query,
|
|
||||||
perPage = 10_000
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
query?: string;
|
|
||||||
perPage?: number;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ORG", orgId, "LABELS", { query, perPage }] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const sp = new URLSearchParams({
|
|
||||||
pageSize: perPage.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (query?.trim()) {
|
|
||||||
sp.set("query", query);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListOrgLabelsResponse>
|
|
||||||
>(`/org/${orgId}/labels?${sp.toString()}`, { signal });
|
|
||||||
return res.data.data.labels;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
domains: ({ orgId }: { orgId: string }) =>
|
domains: ({ orgId }: { orgId: string }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "DOMAINS"] as const,
|
queryKey: ["ORG", orgId, "DOMAINS"] as const,
|
||||||
@@ -595,111 +561,7 @@ export const logAnalyticsFiltersSchema = z.object({
|
|||||||
resourceId: z.coerce.number().optional().catch(undefined)
|
resourceId: z.coerce.number().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||||
|
|
||||||
export const httpLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
resourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
host: z.string().optional().catch(undefined),
|
|
||||||
location: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined),
|
|
||||||
method: z.string().optional().catch(undefined),
|
|
||||||
reason: z.string().optional().catch(undefined),
|
|
||||||
path: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const accessLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
resourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
location: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined),
|
|
||||||
type: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const actionLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
action: z.string().optional().catch(undefined),
|
|
||||||
actor: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ActionLogFilters = z.output<typeof actionLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const connectionLogsFiltersSchema = z.object({
|
|
||||||
timeStart: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeStart must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
timeEnd: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
|
||||||
error: "timeEnd must be a valid ISO date string"
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
page: z.coerce.number().optional().catch(0).default(0),
|
|
||||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
|
||||||
protocol: z.string().optional().catch(undefined),
|
|
||||||
destAddr: z.string().optional().catch(undefined),
|
|
||||||
clientId: z.coerce.number().optional().catch(undefined),
|
|
||||||
siteResourceId: z.coerce.number().optional().catch(undefined),
|
|
||||||
userId: z.string().optional().catch(undefined)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
|
||||||
|
|
||||||
export const logQueries = {
|
export const logQueries = {
|
||||||
requestAnalytics: ({
|
requestAnalytics: ({
|
||||||
@@ -710,7 +572,7 @@ export const logQueries = {
|
|||||||
filters: LogAnalyticsFilters;
|
filters: LogAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["REQUEST_LOGS", orgId, "ANALYTICS", filters] as const,
|
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryRequestAnalyticsResponse>
|
AxiosResponse<QueryRequestAnalyticsResponse>
|
||||||
@@ -726,124 +588,6 @@ export const logQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
|
|
||||||
requests: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: HttpLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryRequestAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/request`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
access: ({ orgId, filters }: { orgId: string; filters: AccessLogFilters }) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryAccessAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/access`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
action: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: ActionLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ACTION_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryActionAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/action`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
connection: ({
|
|
||||||
orgId,
|
|
||||||
filters
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
filters: ConnectionLogFilters;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["CONNECTION_LOGS", orgId, "ALL", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const { page, pageSize, ...rest } = filters;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<QueryConnectionAuditLogResponse>
|
|
||||||
>(`/org/${orgId}/logs/connection`, {
|
|
||||||
params: {
|
|
||||||
...rest,
|
|
||||||
limit: pageSize,
|
|
||||||
offset: page * pageSize
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (query.state.data) {
|
|
||||||
return durationToMs(30, "seconds");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user