Compare commits

..

1 Commits

Author SHA1 Message Date
Fred KISSIE 1580b7abff 🚧 wip: batch status histories 2026-07-10 07:02:22 +02:00
15 changed files with 209 additions and 239 deletions
+6
View File
@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource, rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations"; } from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
+7 -2
View File
@@ -26,6 +26,9 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
@@ -240,7 +243,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceData.enabled ?? true, enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: disableIcmp:
resourceData["disable-icmp"] || resourceData["disable-icmp"] ||
@@ -492,7 +496,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceData.enabled ?? true, enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: disableIcmp:
+1 -1
View File
@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
enabled: z.boolean().default(true), // enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false), "disable-icmp": z.boolean().optional().default(false),
-9
View File
@@ -496,7 +496,6 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (!sr.enabled) return false;
if (!sr.destination) return false; if (!sr.destination) return false;
if (sr.mode === "cidr") { if (sr.mode === "cidr") {
@@ -531,7 +530,6 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter(
(sr) => (sr) =>
sr.enabled &&
sr.aliasAddress && sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) || ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http")) (sr.fullDomain && sr.mode == "http"))
@@ -664,13 +662,6 @@ export async function generateSubnetProxyTargetV2(
subnet: string | null; subnet: string | null;
}[] }[]
): Promise<SubnetProxyTargetV2[] | undefined> { ): Promise<SubnetProxyTargetV2[] | undefined> {
if (!siteResource.enabled) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
);
return;
}
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
+16 -30
View File
@@ -1561,19 +1561,9 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource.udpPortRangeString || updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !== existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp); updatedSiteResource.disableIcmp);
// Toggling enabled on/off doesn't change any of the fields above, but it
// does change whether targets/peer data should exist at all, so it needs
// to drive the same old->new diff machinery: going enabled->disabled
// diffs "real data" against "nothing" (a remove), and disabled->enabled
// diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/
// generateRemoteSubnets/generateAliasConfig already return nothing for a
// disabled resource, so no other changes are needed here.
const enabledChanged =
existingSiteResource &&
existingSiteResource.enabled !== updatedSiteResource.enabled;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}` `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
); );
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
@@ -1584,16 +1574,14 @@ export async function handleMessagingForUpdatedSiteResource(
fullDomainChanged || fullDomainChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
destinationPortChanged || destinationPortChanged
enabledChanged
) { ) {
const shouldUpdateTargets = const shouldUpdateTargets =
destinationChanged || destinationChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
fullDomainChanged || fullDomainChanged ||
destinationPortChanged || destinationPortChanged;
enabledChanged;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
@@ -1669,22 +1657,20 @@ export async function handleMessagingForUpdatedSiteResource(
peerDataUpdateBatch.push({ peerDataUpdateBatch.push({
clientId: client.clientId, clientId: client.clientId,
siteId, siteId,
remoteSubnets: remoteSubnets: destinationChanged
destinationChanged || enabledChanged ? {
? { oldRemoteSubnets: !oldDestinationStillInUseBySite
oldRemoteSubnets: ? generateRemoteSubnets([
!oldDestinationStillInUseBySite existingSiteResource
? generateRemoteSubnets([ ])
existingSiteResource : [],
]) newRemoteSubnets: generateRemoteSubnets([
: [], updatedSiteResource
newRemoteSubnets: generateRemoteSubnets([ ])
updatedSiteResource }
]) : undefined,
}
: undefined,
aliases: aliases:
aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? { ? {
oldAliases: generateAliasConfig([ oldAliases: generateAliasConfig([
existingSiteResource existingSiteResource
+79 -1
View File
@@ -1,6 +1,6 @@
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, lt, asc, desc } from "drizzle-orm"; import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm";
import { regionalCache as cache } from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -273,3 +273,81 @@ export function computeBuckets(
return { buckets, totalDowntime }; return { buckets, totalDowntime };
} }
export interface BatchedStatusHistoryResponse {
entityType: string;
entityIds: number[];
days: StatusHistoryDayBucket[];
overallUptimePercent: number;
totalDowntimeSeconds: number;
}
export async function getBatchedStatusHistory(
entityType: string,
entityIds: number[],
days: number
): Promise<BatchedStatusHistoryResponse> {
// const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
// const cached = await cache.get<StatusHistoryResponse>(cacheKey);
// if (cached !== undefined) {
// return cached;
// }
// Anchor to UTC midnight so the query window aligns with stable calendar days
const utcToday = new Date();
utcToday.setUTCHours(0, 0, 0, 0);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const startSec = todayMidnightSec - days * 86400;
const events = await logsDb
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
inArray(statusHistory.entityId, entityIds),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
// Fetch the last known state before the window so that entities that
// haven't changed status recently still show the correct status rather
// than appearing as "no_data".
const [lastKnownEvent] = await logsDb
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
inArray(statusHistory.entityId, entityIds),
lt(statusHistory.timestamp, startSec)
)
)
.orderBy(desc(statusHistory.timestamp))
.limit(1);
const priorStatus = lastKnownEvent?.status ?? null;
const { buckets, totalDowntime } = computeBuckets(
events,
days,
priorStatus
);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100)
: 100;
const result: BatchedStatusHistoryResponse = {
entityType,
entityIds,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
};
// await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result;
}
+1 -6
View File
@@ -148,12 +148,7 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources) .from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(eq(siteNetworks.siteId, siteId))
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.enabled, true)
)
)
.then((rows) => rows.map((r) => r.siteResources)); .then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
+2 -8
View File
@@ -16,7 +16,7 @@ import {
generateRemoteSubnets generateRemoteSubnets
} from "@server/lib/ip"; } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -70,13 +70,7 @@ export async function buildSiteConfigurationForOlmClient(
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(
and( eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.enabled, true)
)
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
@@ -0,0 +1,62 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
const siteParamsSchema = z.object({
siteId: z.string().transform((v) => parseInt(v, 10))
});
export async function getBatchedSiteStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = siteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "site";
const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data,
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -56,6 +56,7 @@ const createSiteResourceSchema = z
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(), destination: z.string().min(1).nullish(),
enabled: z.boolean().default(true),
alias: z alias: z
.string() .string()
.regex( .regex(
@@ -274,6 +275,7 @@ export async function createSiteResource(
scheme, scheme,
destinationPort, destinationPort,
destination, destination,
enabled,
ssl, ssl,
alias, alias,
userIds, userIds,
@@ -537,6 +539,7 @@ export async function createSiteResource(
destination: destination, // the ssh can be null destination: destination, // the ssh can be null
scheme, scheme,
destinationPort, destinationPort,
enabled,
alias: alias ? alias.trim() : null, alias: alias ? alias.trim() : null,
aliasAddress, aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
@@ -152,11 +152,6 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// this is a partial update; only enforce destination when the
// caller is actually changing mode or destination
if (data.mode === undefined && data.destination === undefined) {
return true;
}
// destination is only optional for ssh mode with native authDaemonMode // destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") { if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true; return true;
@@ -416,10 +411,8 @@ export async function updateSiteResource(
: []; : [];
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
// undefined means "leave unchanged" (partial update); only nulled out let fullDomain: string | null = null;
// when the mode is explicitly being changed away from http let finalSubdomain: string | null = null;
let fullDomain: string | null | undefined = undefined;
let finalSubdomain: string | null | undefined = undefined;
if (domainId) { if (domainId) {
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
@@ -455,11 +448,6 @@ export async function updateSiteResource(
) )
); );
} }
} else if (mode !== undefined && mode !== "http") {
// mode is explicitly changing away from http, so the resource
// can no longer have a domain associated with it
fullDomain = null;
finalSubdomain = null;
} }
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
@@ -528,28 +516,15 @@ export async function updateSiteResource(
destination: destination, destination: destination,
destinationPort: destinationPort, destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias: alias ? alias.trim() : null,
alias !== undefined
? alias
? alias.trim()
: null
: mode !== undefined &&
mode !== "host" &&
mode !== "ssh"
? null
: undefined,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString: udpPortRangeString:
mode == "http" || mode == "ssh" mode == "http" || mode == "ssh"
? "" ? ""
: udpPortRangeString, : udpPortRangeString,
disableIcmp: disableIcmp:
mode !== undefined disableIcmp ||
? disableIcmp || (mode == "http" || mode == "ssh" ? true : false),
(mode == "http" || mode == "ssh"
? true
: false)
: disableIcmp,
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
@@ -16,14 +16,12 @@ import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -43,8 +41,7 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: siteResource.name, name: siteResource.name,
niceId: siteResource.niceId, niceId: siteResource.niceId
enabled: siteResource.enabled
} }
}); });
@@ -55,8 +52,7 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues(); const data = form.getValues();
await save({ await save({
name: data.name, name: data.name,
niceId: data.niceId, niceId: data.niceId
enabled: data.enabled
}); });
}, null); }, null);
@@ -80,42 +76,6 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form" id="private-resource-general-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
siteResource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
+1 -108
View File
@@ -23,7 +23,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
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 { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -37,9 +36,7 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
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 { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight, ArrowRight,
@@ -52,17 +49,8 @@ import {
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 { startTransition, useMemo, useState, useTransition } from "react";
startTransition,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
@@ -126,11 +114,6 @@ function isSafeUrlForLink(href: string): boolean {
} }
} }
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -191,30 +174,6 @@ export default function PrivateResourcesTable({
}); });
}; };
async function toggleInternalResourceEnabled(
val: boolean,
resourceId: number
) {
try {
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
`site-resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
const deleteInternalResource = async ( const deleteInternalResource = async (
resourceId: number, resourceId: number,
siteId: number siteId: number
@@ -470,36 +429,6 @@ export default function PrivateResourcesTable({
); );
} }
}, },
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<InternalResourceEnabledForm
resource={row.original}
onToggleInternalResourceEnabled={
toggleInternalResourceEnabled
}
/>
)
},
{ {
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
@@ -714,39 +643,3 @@ function ClientResourceLabelCell({
/> />
); );
} }
type InternalResourceEnabledFormProps = {
resource: InternalResourceRow;
onToggleInternalResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function InternalResourceEnabledForm({
resource,
onToggleInternalResourceEnabled
}: InternalResourceEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
resource.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleInternalResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== resource.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+2 -2
View File
@@ -165,6 +165,7 @@ export function buildCreateSiteResourcePayload(
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined), destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
...(data.mode === "http" && { ...(data.mode === "http" && {
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
@@ -341,8 +342,7 @@ export function createGeneralFormSchema(t: TranslateFn) {
.string() .string()
.min(1) .min(1)
.max(255) .max(255)
.regex(/^[a-zA-Z0-9-]+$/), .regex(/^[a-zA-Z0-9-]+$/)
enabled: z.boolean()
}); });
} }
+22
View File
@@ -640,6 +640,28 @@ export const orgQueries = {
}; };
} }
}), }),
batchedSiteStatusHistory: ({
siteIds,
days = 90
}: {
siteIds: number[];
days?: number;
}) =>
queryOptions({
queryKey: [
"SITE_STATUS_HISTORY",
"BATCHED",
siteIds,
days
] as const,
queryFn: async ({ signal, meta }) => {
// TODO
// const res = await meta!.api.get<
// AxiosResponse<StatusHistoryResponse>
// >(`/site/${siteId}/status-history?days=${days}`, { signal });
// return res.data.data;
}
}),
siteStatusHistory: ({ siteStatusHistory: ({
siteId, siteId,
days = 90 days = 90