basic functionality

This commit is contained in:
miloschwartz
2026-06-30 21:03:19 -04:00
parent 31725eb3cc
commit f0efa4203b
44 changed files with 5048 additions and 1122 deletions

View 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
});
}

View 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
View 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}`;
}

View File

@@ -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;
}
})
};