mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-04 17:56:38 +00:00
Update member resources page and testing new org counts
This commit is contained in:
@@ -1428,6 +1428,7 @@
|
|||||||
"billingSites": "Sites",
|
"billingSites": "Sites",
|
||||||
"billingUsers": "Users",
|
"billingUsers": "Users",
|
||||||
"billingDomains": "Domains",
|
"billingDomains": "Domains",
|
||||||
|
"billingOrganizations": "Orgs",
|
||||||
"billingRemoteExitNodes": "Remote Nodes",
|
"billingRemoteExitNodes": "Remote Nodes",
|
||||||
"billingNoLimitConfigured": "No limit configured",
|
"billingNoLimitConfigured": "No limit configured",
|
||||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||||
|
|||||||
@@ -130,18 +130,22 @@ export class UsageService {
|
|||||||
featureId,
|
featureId,
|
||||||
orgId,
|
orgId,
|
||||||
meterId,
|
meterId,
|
||||||
instantaneousValue: value,
|
instantaneousValue: value || 0,
|
||||||
latestValue: value,
|
latestValue: value || 0,
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: usage.usageId,
|
target: usage.usageId,
|
||||||
set: {
|
set: {
|
||||||
instantaneousValue: sql`${usage.instantaneousValue} + ${value}`
|
instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}`
|
||||||
|
);
|
||||||
|
|
||||||
return returnUsage;
|
return returnUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,16 +171,7 @@ export async function createOrg(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build == "saas" && billingOrgIdForNewOrg) {
|
||||||
if (!billingOrgIdForNewOrg) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Billing org not found for user. Cannot create new organization."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
|
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
userOrgs,
|
userOrgs,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resourceWhitelist
|
resourceWhitelist,
|
||||||
|
siteResources,
|
||||||
|
userSiteResources,
|
||||||
|
roleSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -57,9 +60,21 @@ export async function getUserResources(
|
|||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(eq(roleResources.roleId, userRoleId));
|
.where(eq(roleResources.roleId, userRoleId));
|
||||||
|
|
||||||
const [directResources, roleResourceResults] = await Promise.all([
|
const directSiteResourcesQuery = db
|
||||||
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
|
.from(userSiteResources)
|
||||||
|
.where(eq(userSiteResources.userId, userId));
|
||||||
|
|
||||||
|
const roleSiteResourcesQuery = db
|
||||||
|
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(eq(roleSiteResources.roleId, userRoleId));
|
||||||
|
|
||||||
|
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
|
||||||
directResourcesQuery,
|
directResourcesQuery,
|
||||||
roleResourcesQuery
|
roleResourcesQuery,
|
||||||
|
directSiteResourcesQuery,
|
||||||
|
roleSiteResourcesQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Combine all accessible resource IDs
|
// Combine all accessible resource IDs
|
||||||
@@ -68,18 +83,25 @@ export async function getUserResources(
|
|||||||
...roleResourceResults.map((r) => r.resourceId)
|
...roleResourceResults.map((r) => r.resourceId)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (accessibleResourceIds.length === 0) {
|
// Combine all accessible site resource IDs
|
||||||
return response(res, {
|
const accessibleSiteResourceIds = [
|
||||||
data: { resources: [] },
|
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||||
success: true,
|
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||||
error: false,
|
];
|
||||||
message: "No resources found",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get resource details for accessible resources
|
// Get resource details for accessible resources
|
||||||
const resourcesData = await db
|
let resourcesData: Array<{
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
fullDomain: string | null;
|
||||||
|
ssl: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sso: boolean;
|
||||||
|
protocol: string;
|
||||||
|
emailWhitelistEnabled: boolean;
|
||||||
|
}> = [];
|
||||||
|
if (accessibleResourceIds.length > 0) {
|
||||||
|
resourcesData = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
@@ -98,6 +120,40 @@ export async function getUserResources(
|
|||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get site resource details for accessible site resources
|
||||||
|
let siteResourcesData: Array<{
|
||||||
|
siteResourceId: number;
|
||||||
|
name: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
protocol: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
alias: string | null;
|
||||||
|
aliasAddress: string | null;
|
||||||
|
}> = [];
|
||||||
|
if (accessibleSiteResourceIds.length > 0) {
|
||||||
|
siteResourcesData = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
destination: siteResources.destination,
|
||||||
|
mode: siteResources.mode,
|
||||||
|
protocol: siteResources.protocol,
|
||||||
|
enabled: siteResources.enabled,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
aliasAddress: siteResources.aliasAddress
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for password, pincode, and whitelist protection for each resource
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
const resourcesWithAuth = await Promise.all(
|
const resourcesWithAuth = await Promise.all(
|
||||||
@@ -161,8 +217,26 @@ export async function getUserResources(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Format site resources
|
||||||
|
const siteResourcesFormatted = siteResourcesData.map((siteResource) => {
|
||||||
|
return {
|
||||||
|
siteResourceId: siteResource.siteResourceId,
|
||||||
|
name: siteResource.name,
|
||||||
|
destination: siteResource.destination,
|
||||||
|
mode: siteResource.mode,
|
||||||
|
protocol: siteResource.protocol,
|
||||||
|
enabled: siteResource.enabled,
|
||||||
|
alias: siteResource.alias,
|
||||||
|
aliasAddress: siteResource.aliasAddress,
|
||||||
|
type: 'site' as const
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { resources: resourcesWithAuth },
|
data: {
|
||||||
|
resources: resourcesWithAuth,
|
||||||
|
siteResources: siteResourcesFormatted
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "User resources retrieved successfully",
|
message: "User resources retrieved successfully",
|
||||||
@@ -190,5 +264,16 @@ export type GetUserResourcesResponse = {
|
|||||||
protected: boolean;
|
protected: boolean;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
}>;
|
}>;
|
||||||
|
siteResources: Array<{
|
||||||
|
siteResourceId: number;
|
||||||
|
name: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
protocol: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
alias: string | null;
|
||||||
|
aliasAddress: string | null;
|
||||||
|
type: 'site';
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export async function deleteSite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
|
|
||||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -768,7 +768,7 @@ export default function BillingPage() {
|
|||||||
<div className="text-sm text-muted-foreground mb-3">
|
<div className="text-sm text-muted-foreground mb-3">
|
||||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||||
</div>
|
</div>
|
||||||
<InfoSections cols={4}>
|
<InfoSections cols={5}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||||
{t("billingUsers") || "Users"}
|
{t("billingUsers") || "Users"}
|
||||||
@@ -888,7 +888,7 @@ export default function BillingPage() {
|
|||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(ORGINIZATIONS) !==
|
{getLimitValue(ORGINIZATIONS) !==
|
||||||
null && "organizations"}
|
null && "orgs"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -901,7 +901,7 @@ export default function BillingPage() {
|
|||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(ORGINIZATIONS) !==
|
{getLimitValue(ORGINIZATIONS) !==
|
||||||
null && "organizations"}
|
null && "orgs"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -923,7 +923,7 @@ export default function BillingPage() {
|
|||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||||
null && "remote nodes"}
|
null && "nodes"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -936,7 +936,7 @@ export default function BillingPage() {
|
|||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||||
null && "remote nodes"}
|
null && "nodes"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ type Resource = {
|
|||||||
siteName?: string | null;
|
siteName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SiteResource = {
|
||||||
|
siteResourceId: number;
|
||||||
|
name: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
protocol: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
alias: string | null;
|
||||||
|
aliasAddress: string | null;
|
||||||
|
type: 'site';
|
||||||
|
};
|
||||||
|
|
||||||
type MemberResourcesPortalProps = {
|
type MemberResourcesPortalProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
};
|
};
|
||||||
@@ -334,7 +346,9 @@ export default function MemberResourcesPortal({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
|
||||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||||
|
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -360,7 +374,9 @@ export default function MemberResourcesPortal({
|
|||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setResources(response.data.data.resources);
|
setResources(response.data.data.resources);
|
||||||
|
setSiteResources(response.data.data.siteResources || []);
|
||||||
setFilteredResources(response.data.data.resources);
|
setFilteredResources(response.data.data.resources);
|
||||||
|
setFilteredSiteResources(response.data.data.siteResources || []);
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to load resources");
|
setError("Failed to load resources");
|
||||||
}
|
}
|
||||||
@@ -417,17 +433,61 @@ export default function MemberResourcesPortal({
|
|||||||
|
|
||||||
setFilteredResources(filtered);
|
setFilteredResources(filtered);
|
||||||
|
|
||||||
|
// Filter and sort site resources
|
||||||
|
const filteredSites = siteResources.filter(
|
||||||
|
(resource) =>
|
||||||
|
resource.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()) ||
|
||||||
|
resource.destination
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort site resources
|
||||||
|
filteredSites.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case "name-asc":
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case "name-desc":
|
||||||
|
return b.name.localeCompare(a.name);
|
||||||
|
case "domain-asc":
|
||||||
|
case "domain-desc":
|
||||||
|
// Sort by destination for site resources
|
||||||
|
const destCompare = sortBy === "domain-asc"
|
||||||
|
? a.destination.localeCompare(b.destination)
|
||||||
|
: b.destination.localeCompare(a.destination);
|
||||||
|
return destCompare;
|
||||||
|
case "status-enabled":
|
||||||
|
return b.enabled ? 1 : -1;
|
||||||
|
case "status-disabled":
|
||||||
|
return a.enabled ? 1 : -1;
|
||||||
|
default:
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredSiteResources(filteredSites);
|
||||||
|
|
||||||
// Reset to first page when search/sort changes
|
// Reset to first page when search/sort changes
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [resources, searchQuery, sortBy]);
|
}, [resources, siteResources, searchQuery, sortBy]);
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
|
const totalItems = filteredResources.length + filteredSiteResources.length;
|
||||||
|
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const paginatedResources = filteredResources.slice(
|
const paginatedResources = filteredResources.slice(
|
||||||
startIndex,
|
startIndex,
|
||||||
startIndex + itemsPerPage
|
startIndex + itemsPerPage
|
||||||
);
|
);
|
||||||
|
const remainingSlots = itemsPerPage - paginatedResources.length;
|
||||||
|
const paginatedSiteResources = remainingSlots > 0
|
||||||
|
? filteredSiteResources.slice(
|
||||||
|
Math.max(0, startIndex - filteredResources.length),
|
||||||
|
Math.max(0, startIndex - filteredResources.length) + remainingSlots
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
const handleOpenResource = (resource: Resource) => {
|
const handleOpenResource = (resource: Resource) => {
|
||||||
// Open the resource in a new tab
|
// Open the resource in a new tab
|
||||||
@@ -575,7 +635,7 @@ export default function MemberResourcesPortal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resources Content */}
|
{/* Resources Content */}
|
||||||
{filteredResources.length === 0 ? (
|
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? (
|
||||||
/* Enhanced Empty State */
|
/* Enhanced Empty State */
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
@@ -623,9 +683,20 @@ export default function MemberResourcesPortal({
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Resources Grid */}
|
{/* Public Resources Section */}
|
||||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
|
{paginatedResources.length > 0 && (
|
||||||
{paginatedResources.map((resource) => (
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
Public Resources
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Web applications and services accessible via browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||||
|
{paginatedResources.map((resource) => (
|
||||||
<Card key={resource.resourceId}>
|
<Card key={resource.resourceId}>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -702,13 +773,167 @@ export default function MemberResourcesPortal({
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Private Resources (Site Resources) Section */}
|
||||||
|
{paginatedSiteResources.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Combine className="h-5 w-5" />
|
||||||
|
Private Resources
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Internal network resources accessible via client
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||||
|
{paginatedSiteResources.map((siteResource) => (
|
||||||
|
<Card key={siteResource.siteResourceId}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="min-w-0 max-w-full">
|
||||||
|
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
|
||||||
|
{siteResource.name}
|
||||||
|
</CardTitle>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs break-words">
|
||||||
|
{siteResource.name}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<InfoPopup>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="text-xs font-medium mb-1.5">Resource Details</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Mode:</span>
|
||||||
|
<span className="ml-2 text-muted-foreground capitalize">
|
||||||
|
{siteResource.mode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{siteResource.protocol && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Protocol:</span>
|
||||||
|
<span className="ml-2 text-muted-foreground uppercase">
|
||||||
|
{siteResource.protocol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{siteResource.alias && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Alias:</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{siteResource.alias}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{siteResource.aliasAddress && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Alias Address:</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{siteResource.aliasAddress}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Status:</span>
|
||||||
|
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{siteResource.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoPopup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{siteResource.alias ? (
|
||||||
|
<>
|
||||||
|
{/* Alias as primary */}
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
|
||||||
|
{siteResource.alias}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
siteResource.alias!
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Copied to clipboard",
|
||||||
|
description:
|
||||||
|
"Resource alias has been copied to your clipboard.",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Destination as secondary */}
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{siteResource.destination}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Destination as primary when no alias */
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
|
||||||
|
{siteResource.destination}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
siteResource.destination
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Copied to clipboard",
|
||||||
|
description:
|
||||||
|
"Resource destination has been copied to your clipboard.",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 pt-0 mt-auto">
|
||||||
|
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||||
|
<Combine className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Requires Client Connection
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
totalItems={filteredResources.length}
|
totalItems={totalItems}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user