mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
basic functionality
This commit is contained in:
98
src/lib/launcherLocalStorage.ts
Normal file
98
src/lib/launcherLocalStorage.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type LauncherActiveViewId = number | "default";
|
||||
|
||||
const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:";
|
||||
const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:";
|
||||
|
||||
function lastViewKey(orgId: string) {
|
||||
return `${LAST_VIEW_PREFIX}${orgId}`;
|
||||
}
|
||||
|
||||
function groupOpenKey(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
) {
|
||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||
}
|
||||
|
||||
function readJson<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Error writing localStorage key "${key}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function readLauncherLastView(
|
||||
orgId: string
|
||||
): LauncherActiveViewId | null {
|
||||
const value = readJson<LauncherActiveViewId | null>(
|
||||
lastViewKey(orgId),
|
||||
null
|
||||
);
|
||||
if (value === "default" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLauncherLastView(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId
|
||||
) {
|
||||
writeJson(lastViewKey(orgId), viewId);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpenState(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
): Record<string, boolean> {
|
||||
return readJson<Record<string, boolean>>(
|
||||
groupOpenKey(orgId, viewId, groupBy),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupKey: string,
|
||||
defaultOpen: boolean
|
||||
): boolean {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
return groupKey in state ? state[groupKey] : defaultOpen;
|
||||
}
|
||||
|
||||
export function writeLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupKey: string,
|
||||
isOpen: boolean
|
||||
) {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
writeJson(groupOpenKey(orgId, viewId, groupBy), {
|
||||
...state,
|
||||
[groupKey]: isOpen
|
||||
});
|
||||
}
|
||||
123
src/lib/launcherResourceAccess.ts
Normal file
123
src/lib/launcherResourceAccess.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
resolveHttpHttpsDisplayPort,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export type PublicResourceAccessInput = {
|
||||
mode: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
proxyPort: number | null;
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
export type SiteResourceAccessInput = {
|
||||
mode: string;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
};
|
||||
|
||||
export type LauncherAccessFields = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl: string | null;
|
||||
};
|
||||
|
||||
export function formatPublicResourceAccess(
|
||||
resource: PublicResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
||||
if (!browserModes.includes(resource.mode)) {
|
||||
const port = resource.proxyPort?.toString() ?? "";
|
||||
return {
|
||||
accessDisplay: port,
|
||||
accessCopyValue: port,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: resource.wildcard ? null : url
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSiteResourceAccess(
|
||||
resource: SiteResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
if (resource.alias) {
|
||||
return {
|
||||
accessDisplay: resource.alias,
|
||||
accessCopyValue: resource.alias,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.mode === "http" && resource.fullDomain) {
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
||||
destination: resource.destination,
|
||||
destinationPort: resource.destinationPort,
|
||||
scheme: resource.scheme
|
||||
});
|
||||
|
||||
if (destination) {
|
||||
return {
|
||||
accessDisplay: destination,
|
||||
accessCopyValue: destination,
|
||||
accessUrl: resource.mode === "http" ? destination : null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.aliasAddress) {
|
||||
return {
|
||||
accessDisplay: resource.aliasAddress,
|
||||
accessCopyValue: resource.aliasAddress,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeUrlForLink(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
292
src/lib/launcherUrlState.ts
Normal file
292
src/lib/launcherUrlState.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
parseIdListParam,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const launcherUrlBooleanSchema = z
|
||||
.enum(["0", "1"])
|
||||
.transform((value) => value === "1");
|
||||
|
||||
export type LauncherUrlConfigOverrides = Partial<
|
||||
Pick<
|
||||
LauncherViewConfig,
|
||||
| "groupBy"
|
||||
| "layout"
|
||||
| "order"
|
||||
| "showLabels"
|
||||
| "showSiteTags"
|
||||
| "siteIds"
|
||||
| "labelIds"
|
||||
| "query"
|
||||
>
|
||||
>;
|
||||
|
||||
export type ParsedLauncherUrlState = {
|
||||
viewId: LauncherActiveViewId | null;
|
||||
configOverrides: LauncherUrlConfigOverrides;
|
||||
hasAnyLauncherParams: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedLauncherState = {
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
};
|
||||
|
||||
const LAUNCHER_CONFIG_PARAM_KEYS = [
|
||||
"query",
|
||||
"groupBy",
|
||||
"layout",
|
||||
"order",
|
||||
"showLabels",
|
||||
"showSiteTags",
|
||||
"siteIds",
|
||||
"labelIds"
|
||||
] as const;
|
||||
|
||||
const LAUNCHER_URL_PARAM_KEYS = [
|
||||
"view",
|
||||
...LAUNCHER_CONFIG_PARAM_KEYS
|
||||
] as const;
|
||||
|
||||
export function hasLauncherConfigParams(searchParams: URLSearchParams) {
|
||||
return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key));
|
||||
}
|
||||
|
||||
export function isLauncherConfigEqual(
|
||||
a: LauncherViewConfig,
|
||||
b: LauncherViewConfig
|
||||
) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function getLauncherUrlBaseConfig(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
): LauncherViewConfig {
|
||||
if (viewId === "default") {
|
||||
return defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
const savedView = views.find((view) => view.viewId === viewId);
|
||||
return savedView?.config ?? defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
export function resolveLauncherConfig(
|
||||
baseConfig: LauncherViewConfig,
|
||||
overrides: LauncherUrlConfigOverrides
|
||||
): LauncherViewConfig {
|
||||
return {
|
||||
...baseConfig,
|
||||
...overrides,
|
||||
sortBy: "name"
|
||||
};
|
||||
}
|
||||
|
||||
function parseViewParam(value: string | null): LauncherActiveViewId | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === "default") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseConfigOverrides(
|
||||
searchParams: URLSearchParams
|
||||
): LauncherUrlConfigOverrides {
|
||||
const overrides: LauncherUrlConfigOverrides = {};
|
||||
|
||||
const query = searchParams.get("query");
|
||||
if (query !== null) {
|
||||
overrides.query = query;
|
||||
}
|
||||
|
||||
const groupBy = searchParams.get("groupBy");
|
||||
if (groupBy === "site" || groupBy === "label") {
|
||||
overrides.groupBy = groupBy;
|
||||
}
|
||||
|
||||
const layout = searchParams.get("layout");
|
||||
if (layout === "grid" || layout === "list") {
|
||||
overrides.layout = layout;
|
||||
}
|
||||
|
||||
const order = searchParams.get("order");
|
||||
if (order === "asc" || order === "desc") {
|
||||
overrides.order = order;
|
||||
}
|
||||
|
||||
const showLabels = searchParams.get("showLabels");
|
||||
if (showLabels !== null) {
|
||||
const parsed = launcherUrlBooleanSchema.safeParse(showLabels);
|
||||
if (parsed.success) {
|
||||
overrides.showLabels = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const showSiteTags = searchParams.get("showSiteTags");
|
||||
if (showSiteTags !== null) {
|
||||
const parsed = launcherUrlBooleanSchema.safeParse(showSiteTags);
|
||||
if (parsed.success) {
|
||||
overrides.showSiteTags = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const siteIds = searchParams.get("siteIds");
|
||||
if (siteIds !== null) {
|
||||
overrides.siteIds = parseIdListParam(siteIds);
|
||||
}
|
||||
|
||||
const labelIds = searchParams.get("labelIds");
|
||||
if (labelIds !== null) {
|
||||
overrides.labelIds = parseIdListParam(labelIds);
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function parseLauncherUrlState(
|
||||
searchParams: URLSearchParams
|
||||
): ParsedLauncherUrlState {
|
||||
const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) =>
|
||||
searchParams.has(key)
|
||||
);
|
||||
|
||||
return {
|
||||
viewId: parseViewParam(searchParams.get("view")),
|
||||
configOverrides: parseConfigOverrides(searchParams),
|
||||
hasAnyLauncherParams
|
||||
};
|
||||
}
|
||||
|
||||
function isValidActiveViewId(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
) {
|
||||
return viewId === "default" || views.some((view) => view.viewId === viewId);
|
||||
}
|
||||
|
||||
export function resolveLauncherStateFromUrl(
|
||||
searchParams: URLSearchParams,
|
||||
views: LauncherViewRecord[],
|
||||
fallbackViewId: LauncherActiveViewId | null
|
||||
): ResolvedLauncherState {
|
||||
const parsed = parseLauncherUrlState(searchParams);
|
||||
|
||||
let activeViewId: LauncherActiveViewId = "default";
|
||||
|
||||
if (parsed.viewId !== null) {
|
||||
activeViewId = isValidActiveViewId(parsed.viewId, views)
|
||||
? parsed.viewId
|
||||
: "default";
|
||||
} else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) {
|
||||
activeViewId = isValidActiveViewId(fallbackViewId, views)
|
||||
? fallbackViewId
|
||||
: "default";
|
||||
}
|
||||
|
||||
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
|
||||
|
||||
let config: LauncherViewConfig;
|
||||
if (hasLauncherConfigParams(searchParams)) {
|
||||
config = resolveLauncherConfig(
|
||||
defaultLauncherViewConfig,
|
||||
parsed.configOverrides
|
||||
);
|
||||
} else if (activeViewId !== "default") {
|
||||
config = savedConfig;
|
||||
} else {
|
||||
config = defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig
|
||||
};
|
||||
}
|
||||
|
||||
function idListsEqual(a: number[], b: number[]) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
export function serializeLauncherUrlState({
|
||||
viewId,
|
||||
config
|
||||
}: {
|
||||
viewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
}): URLSearchParams {
|
||||
const baseConfig = defaultLauncherViewConfig;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (viewId !== "default") {
|
||||
params.set("view", String(viewId));
|
||||
}
|
||||
|
||||
if (config.query !== baseConfig.query && config.query) {
|
||||
params.set("query", config.query);
|
||||
} else if (config.query !== baseConfig.query && !config.query) {
|
||||
params.set("query", "");
|
||||
}
|
||||
|
||||
if (config.groupBy !== baseConfig.groupBy) {
|
||||
params.set("groupBy", config.groupBy);
|
||||
}
|
||||
|
||||
if (config.layout !== baseConfig.layout) {
|
||||
params.set("layout", config.layout);
|
||||
}
|
||||
|
||||
if (config.order !== baseConfig.order) {
|
||||
params.set("order", config.order);
|
||||
}
|
||||
|
||||
if (config.showLabels !== baseConfig.showLabels) {
|
||||
params.set("showLabels", config.showLabels ? "1" : "0");
|
||||
}
|
||||
|
||||
if (config.showSiteTags !== baseConfig.showSiteTags) {
|
||||
params.set("showSiteTags", config.showSiteTags ? "1" : "0");
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.siteIds, baseConfig.siteIds)) {
|
||||
if (config.siteIds.length > 0) {
|
||||
params.set("siteIds", config.siteIds.join(","));
|
||||
} else {
|
||||
params.set("siteIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.labelIds, baseConfig.labelIds)) {
|
||||
if (config.labelIds.length > 0) {
|
||||
params.set("labelIds", config.labelIds.join(","));
|
||||
} else {
|
||||
params.set("labelIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildLauncherPath(orgId: string, params: URLSearchParams) {
|
||||
const query = params.toString();
|
||||
return query ? `/${orgId}?${query}` : `/${orgId}`;
|
||||
}
|
||||
@@ -46,6 +46,13 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type {
|
||||
ListLauncherGroupsResponse,
|
||||
ListLauncherResourcesResponse,
|
||||
ListLauncherViewsResponse,
|
||||
LauncherListQuery,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -1166,3 +1173,96 @@ export const domainQueries = {
|
||||
refetchInterval: durationToMs(10, "seconds")
|
||||
})
|
||||
};
|
||||
|
||||
export type LauncherQueryFilters = {
|
||||
query?: string;
|
||||
groupBy?: LauncherListQuery["groupBy"];
|
||||
groupKey?: string;
|
||||
siteIds?: number[];
|
||||
labelIds?: number[];
|
||||
sort_by?: LauncherListQuery["sort_by"];
|
||||
order?: LauncherListQuery["order"];
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
function launcherSearchParams(filters: LauncherQueryFilters, page: number) {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("page", String(page));
|
||||
sp.set("pageSize", String(filters.pageSize ?? 20));
|
||||
if (filters.query) {
|
||||
sp.set("query", filters.query);
|
||||
}
|
||||
if (filters.groupBy) {
|
||||
sp.set("groupBy", filters.groupBy);
|
||||
}
|
||||
if (filters.groupKey) {
|
||||
sp.set("groupKey", filters.groupKey);
|
||||
}
|
||||
if (filters.siteIds?.length) {
|
||||
sp.set("siteIds", filters.siteIds.join(","));
|
||||
}
|
||||
if (filters.labelIds?.length) {
|
||||
sp.set("labelIds", filters.labelIds.join(","));
|
||||
}
|
||||
if (filters.sort_by) {
|
||||
sp.set("sort_by", filters.sort_by);
|
||||
}
|
||||
if (filters.order) {
|
||||
sp.set("order", filters.order);
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
export const launcherQueries = {
|
||||
views: (orgId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherViewsResponse>
|
||||
>(`/org/${orgId}/launcher/views`, { signal });
|
||||
return res.data.data.views;
|
||||
}
|
||||
}),
|
||||
groups: (orgId: string, filters: LauncherQueryFilters) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
}),
|
||||
resources: (
|
||||
orgId: string,
|
||||
filters: LauncherQueryFilters & { groupKey: string }
|
||||
) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherResourcesResponse>
|
||||
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user