From d81cb936be0d12dda85387b152af9534b84e78ce Mon Sep 17 00:00:00 2001 From: Moda20 Date: Sun, 14 Jun 2026 17:17:53 +0100 Subject: [PATCH 1/4] Adding a new Job focused, proxy link dialog in the job action dropdown disabling pointer events on job dropdown when another dialog is on top of it (same for the search bar) --- .../custom/DrawerMenuConfigurator.tsx | 4 + src/components/custom/SearchBar.tsx | 10 +- .../custom/jobsTable/actionDropdown.tsx | 23 +- .../custom/system/JobProxyLinkDialog.tsx | 207 ++++++++++++++++++ .../custom/system/ProxyConfigDialog.tsx | 20 +- src/hooks/useProxies.tsx | 51 ++++- src/models/proxies.ts | 24 ++ src/services/SystemService.ts | 13 ++ 8 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 src/components/custom/system/JobProxyLinkDialog.tsx diff --git a/src/components/custom/DrawerMenuConfigurator.tsx b/src/components/custom/DrawerMenuConfigurator.tsx index a736b3a..9b20465 100644 --- a/src/components/custom/DrawerMenuConfigurator.tsx +++ b/src/components/custom/DrawerMenuConfigurator.tsx @@ -16,6 +16,7 @@ import { verifyUserConnection } from "@/utils/authUtils" import { ButtonWithTooltip } from "@/components/custom/general/ButtonWithTooltip" export default function DrawerMenuConfigurator() { + const [open, setOpen] = useState(false) useHotkeys( ["ctrl+l", "meta+l"], () => { @@ -25,6 +26,7 @@ export default function DrawerMenuConfigurator() { }, { preventDefault: true, + enabled: open, }, ) @@ -69,6 +71,7 @@ export default function DrawerMenuConfigurator() { { enableOnFormTags: true, enableOnContentEditable: true, + enabled: open, }, ) @@ -88,6 +91,7 @@ export default function DrawerMenuConfigurator() { side={"right"} title={"Configuration"} description={"Update server configuration"} + onOpenChange={setOpen} trigger={ + + + + ( +
+ + + Select the Proxies to link to this job + +
+ + + + + The selected proxy will be part of the job's linked + proxies. The proxies are going to be injected to the + job based on a strategy + + +
+
+ )} + /> +
+ + + + + + Update proxy links + + + + + + + ) +} diff --git a/src/components/custom/system/ProxyConfigDialog.tsx b/src/components/custom/system/ProxyConfigDialog.tsx index 5d1cb48..16c4cca 100644 --- a/src/components/custom/system/ProxyConfigDialog.tsx +++ b/src/components/custom/system/ProxyConfigDialog.tsx @@ -27,7 +27,7 @@ import { ComboBox } from "@/components/ui/combo-box" import { cn, parseCron } from "@/lib/utils" import useDialogueManager from "@/hooks/useDialogManager" import { useHotkeys } from "react-hotkeys-hook" -import type { ProxyTableData } from "@/models/proxies" +import { ProxyTableData, ProxyUpdateSchema } from "@/models/proxies" import { proxyProtocolOptions, ProxyStatus } from "@/models/proxies" import { Checkbox } from "@/components/ui/checkbox" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -35,7 +35,7 @@ import { PasswordInput } from "@/components/ui/password-input" import ManagedSelect from "@/components/custom/ManagedSelect" import ButtonWithStrCut from "@/components/custom/general/ButtonWithStrCut" -export interface ProxyConffigDialogProps { +export interface ProxyConfigDialogProps { children: React.ReactNode isCreateDialog?: boolean proxyDetails?: ProxyTableData @@ -44,27 +44,13 @@ export interface ProxyConffigDialogProps { triggerClassName?: string } -const ProxyUpdateSchema = z.object({ - id: z.union([z.number(), z.string()]).optional(), - proxy_ip: z.string(), - proxy_port: z.coerce.number().positive().max(65535), - description: z.string().optional(), - status: z.union([z.nativeEnum(ProxyStatus), z.number()]).optional(), - username: z.string().optional(), - password: z.string().optional(), - protocol: z.string().optional(), - jobs: z.array(z.number()).optional(), -}) - -export type ProxyConfigUpdateType = z.infer - export function ProxyConfigDialog({ children, isCreateDialog, proxyDetails, onChange, triggerClassName, -}: ProxyConffigDialogProps) { +}: ProxyConfigDialogProps) { const form = useForm>({ resolver: zodResolver(ProxyUpdateSchema), defaultValues: { diff --git a/src/hooks/useProxies.tsx b/src/hooks/useProxies.tsx index 6b60392..082d2c1 100644 --- a/src/hooks/useProxies.tsx +++ b/src/hooks/useProxies.tsx @@ -1,9 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import systemService from "@/services/SystemService" -import { ProxyActions, type ProxyTableData } from "@/models/proxies" +import { + JobProxyLinkUpdateType, + ProxyActions, + ProxyConfigUpdateType, + type ProxyTableData, +} from "@/models/proxies" import { Row } from "@tanstack/react-table" -import { ProxyConfigUpdateType } from "@/components/custom/system/ProxyConfigDialog" import { toast } from "@/hooks/use-toast" +import { useCallback, useMemo } from "react" export interface UseProxyProps { filters?: { @@ -28,6 +33,19 @@ export function useProxies(props?: UseProxyProps) { placeholderData: [], }) + const proxyItems = useMemo(() => { + return data?.map((item: any) => { + return { + value: item.id?.toString(), + label: `${item.proxy_ip}:${item.proxy_port}`, + } + }) + }, [data]) + + const jobProxies = useCallback((jobId: number) => { + return systemService.getJobProxies(jobId) + }, []) + const createMutation = useMutation({ mutationFn: systemService.addProxy, onSuccess: async (data, variables) => { @@ -106,6 +124,27 @@ export function useProxies(props?: UseProxyProps) { }, }) + const jobLinkMutation = useMutation({ + mutationFn: (d: any) => + systemService.addProxiesToASingleJob(d.id, d.proxies), + onSuccess: async (data, vars) => { + toast({ + title: `${vars.proxies.length} proxies linked to a job`, + duration: 2000, + }) + await queryClient.invalidateQueries({ + queryKey: ["proxies"], + }) + }, + onError: (error, variables) => { + toast({ + title: "Error linking proxies to job", + description: error.message, + variant: "destructive", + }) + }, + }) + const testMutation = useMutation({ mutationFn: systemService.testProxy, onMutate: (id, _context) => { @@ -133,6 +172,7 @@ export function useProxies(props?: UseProxyProps) { action: ProxyActions, row?: Row, proxyData?: ProxyConfigUpdateType, + jobProxyLinkData?: JobProxyLinkUpdateType, ) => { switch (action) { case ProxyActions.UPDATE: @@ -152,6 +192,11 @@ export function useProxies(props?: UseProxyProps) { id: proxyData!.id!, jobs: proxyData!.jobs!.map(e => Number(e)), }) + case ProxyActions.LINK_TO_JOBS: + return jobLinkMutation.mutateAsync({ + id: Number(jobProxyLinkData!.id), + proxies: jobProxyLinkData!.proxies.map(e => Number(e)), + }) case ProxyActions.TEST: return testMutation.mutateAsync(Number(row?.original?.id)) default: @@ -161,7 +206,9 @@ export function useProxies(props?: UseProxyProps) { return { proxies: data, + proxyItems, isLoading, proxyActions, + jobProxies, } } diff --git a/src/models/proxies.ts b/src/models/proxies.ts index 78f1702..5512325 100644 --- a/src/models/proxies.ts +++ b/src/models/proxies.ts @@ -1,3 +1,5 @@ +import { z } from "zod/v4" + export interface ProxyTableData { id: string proxy_ip: string @@ -19,6 +21,7 @@ export enum ProxyActions { CREATE, LINK, TEST, + LINK_TO_JOBS, } export enum ProxyStatus { @@ -44,3 +47,24 @@ export const proxyProtocolOptions = [ label: "SOCKS5", }, ] + +export const ProxyUpdateSchema = z.object({ + id: z.union([z.number(), z.string()]).optional(), + proxy_ip: z.string(), + proxy_port: z.coerce.number().positive().max(65535), + description: z.string().optional(), + status: z.union([z.nativeEnum(ProxyStatus), z.number()]).optional(), + username: z.string().optional(), + password: z.string().optional(), + protocol: z.string().optional(), + jobs: z.array(z.number()).optional(), +}) + +export type ProxyConfigUpdateType = z.infer + +export const JobProxyLinkUpdateSchema = z.object({ + id: z.union([z.number(), z.string()]), + proxies: z.array(z.any()).optional(), +}) + +export type JobProxyLinkUpdateType = z.infer diff --git a/src/services/SystemService.ts b/src/services/SystemService.ts index f6e17a5..6b65a64 100644 --- a/src/services/SystemService.ts +++ b/src/services/SystemService.ts @@ -49,6 +49,13 @@ const systemService = { }, }) }, + getJobProxies(jobId: number): Promise { + return axios.get("/proxies/getJobProxies", { + params: { + jobId, + }, + }) + }, updateProxy(id: number, proxyData: ProxyConfigUpdateType): Promise { return axios.put("/proxies/updateProxy", { id, @@ -64,6 +71,12 @@ const systemService = { job_ids: jobIds, }) }, + addProxiesToASingleJob(jobId: number, proxyIds: Array): Promise { + return axios.post("/proxies/addProxiesToJob", { + job_id: jobId, + proxy_ids: proxyIds, + }) + }, deleteProxy(id: number | string): Promise { return axios.delete("/proxies/deleteProxy", { params: { From e982e93ebc99d194174c085ece0bf9713fe7ab1d Mon Sep 17 00:00:00 2001 From: Moda20 Date: Thu, 18 Jun 2026 08:52:27 +0100 Subject: [PATCH 2/4] Adding strategy picker for job proxy link dialog Update managedSelect component to better handle keyboard shortcuts and disabled prop --- package.json | 9 +- src/components/custom/ManagedSelect.tsx | 56 ++++- .../custom/jobsTable/actionDropdown.tsx | 22 +- .../custom/system/JobProxyLinkDialog.tsx | 113 +++++++++- src/components/ui/input-base.tsx | 213 ++++++++++++++++++ src/models/proxies.ts | 16 ++ 6 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/input-base.tsx diff --git a/package.json b/package.json index 71c49ec..36a7c4c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "scheduler-ui-by-moda20", + "name": "type-scheduler-ui-by-moda20", "description": "The frontend for the scheduler backend", "private": false, "version": "0.0.0-pre-alpha", @@ -24,19 +24,22 @@ "@icons-pack/react-simple-icons": "^13.8.0", "@melloware/react-logviewer": "^6.3.5", "@monaco-editor/react": "^4.7.0", + "@radix-ui/primitive": "1.1.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.2", @@ -47,6 +50,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.13.18", + "@types/lodash-es": "^4.17.12", "ansi-colors": "^4.1.3", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", @@ -58,6 +62,7 @@ "fuse.js": "^7.4.0", "history": "^5.3.0", "js-cookie": "^3.0.5", + "lodash-es": "^4.18.1", "lucide": "^0.474.0", "lucide-react": "^0.453.0", "moment": "^2.30.1", diff --git a/src/components/custom/ManagedSelect.tsx b/src/components/custom/ManagedSelect.tsx index 516ba3f..64baaab 100644 --- a/src/components/custom/ManagedSelect.tsx +++ b/src/components/custom/ManagedSelect.tsx @@ -7,9 +7,16 @@ import { SelectValue, } from "@/components/ui/select" import useDialogueManager from "@/hooks/useDialogManager" -import { useEffect, useState } from "react" +import { + forwardRef, + useEffect, + useState, + type ElementRef, + useCallback, +} from "react" import { safeStringCast } from "@/utils/generalUtils" import { cn } from "@/lib/utils" +import { useHotkeys } from "react-hotkeys-hook" export type ManagedSelectInputValue = { value?: string | boolean @@ -32,12 +39,26 @@ export interface ManagedSelectProps { itemClassName?: string } -export default function ManagedSelect(props: ManagedSelectProps) { +const ManagedSelect = forwardRef< + ElementRef, + ManagedSelectProps +>((props, ref) => { const { isDialogOpen, setDialogState } = useDialogueManager() const [parsedInputs, setParsedInputs] = useState< Array >([]) + const hotkeyRef = useHotkeys( + ["enter"], + () => { + handleDialogState(!isDialogOpen) + }, + { + ignoreModifiers: false, + preventDefault: true, + }, + ) + useEffect(() => { setParsedInputs( props.inputOptions.map(e => { @@ -49,24 +70,43 @@ export default function ManagedSelect(props: ManagedSelectProps) { ) }, [props.inputOptions]) + const handleDialogState = useCallback( + (open: boolean) => { + if (open) { + if (!props.disabled) { + setDialogState(true) + } + } else { + setDialogState(false) + } + }, + [props, setDialogState], + ) + return ( ) -} +}) + +ManagedSelect.displayName = "ManagedSelect" + +export default ManagedSelect diff --git a/src/components/custom/jobsTable/actionDropdown.tsx b/src/components/custom/jobsTable/actionDropdown.tsx index 5a19158..026abd3 100644 --- a/src/components/custom/jobsTable/actionDropdown.tsx +++ b/src/components/custom/jobsTable/actionDropdown.tsx @@ -45,6 +45,7 @@ import DrawerFilePreview from "@/components/custom/DrawerFilePreview" import JobEventNotificationsDrawer from "@/components/custom/jobsTable/JobEvents/JobEventNotificationsDrawer" import { JobProxyLinkDialog } from "@/components/custom/system/JobProxyLinkDialog" import { cn } from "@/lib/utils" +import { set, unset } from "lodash-es" export interface ActionDropdownProps { columnsProps: tableColumnsProps @@ -170,6 +171,22 @@ export default function ActionDropdown({ return columnsProps.takeAction(row, jobActions.REFRESH) }, [row]) + const updateProxyStrategy = useCallback( + (newStrategy: string, specificProxyId?: string) => { + const oldParam = JSON.parse(row.param || "{}") + set(oldParam, "proxyConfig.proxyStrategy", newStrategy) + if (specificProxyId) { + set(oldParam, "proxyConfig.targetProxyId", specificProxyId) + } else { + unset(oldParam, "proxyConfig.targetProxyId") + } + return columnsProps.takeAction(row, jobActions.UPDATE, { + param: JSON.stringify(oldParam), + }) + }, + [row], + ) + const eventHandlers = useMemo(() => { try { const params = JSON.parse(row.param || "{}") @@ -330,7 +347,10 @@ export default function ActionDropdown({ /> - + void + onProxyStrategyChange?: (value: string, proxyId?: string) => void triggerClassName?: string } @@ -49,9 +61,15 @@ export function JobProxyLinkDialog({ children, jobDetails, onChange, + onProxyStrategyChange, triggerClassName, }: ProxyLinkDialogProps) { const { isDialogOpen, setDialogState } = useDialogueManager() + const strategySelectRef = useRef(null) + const proxyConfig = useMemo(() => { + const jobParams = JSON.parse(jobDetails?.param ?? "{}") + return jobParams["proxyConfig"] + }, [jobDetails]) const { proxyItems, jobProxies, proxyActions } = useProxies() const { data: jobProxyList, isLoading } = useQuery({ @@ -71,13 +89,47 @@ export function JobProxyLinkDialog({ defaultValues: { id: jobDetails?.id ?? "", proxies: jobProxyList?.map(e => String(e.proxy_id)) ?? [], + strategy: proxyConfig?.proxyStrategy, + proxyId: proxyConfig?.targetProxyId, }, }) + + const handlePickingStrategy = useCallback( + (newStrategy: ProxyStrategyOptionEnum) => { + form.setValue("strategy", newStrategy) + if (newStrategy !== ProxyStrategyOptionEnum.SPECIFIC) { + form.setValue("proxyId", "") + } + strategySelectRef.current?.blur() + }, + [strategySelectRef.current], + ) const handleSubmit = useCallback( async (inputValue: JobProxyLinkUpdateType) => { - proxyActions(ProxyActions.LINK_TO_JOBS, undefined, undefined, inputValue) onChange?.(inputValue) - setDialogState(false) + return proxyActions( + ProxyActions.LINK_TO_JOBS, + undefined, + undefined, + inputValue, + ) + .then(() => { + if ( + inputValue.strategy !== proxyConfig?.proxyStrategy || + (inputValue.strategy === ProxyStrategyOptionEnum.SPECIFIC && + inputValue.proxyId !== proxyConfig?.targetProxyId) + ) { + return onProxyStrategyChange?.( + inputValue.strategy, + inputValue.proxyId, + ) + } else { + return Promise.resolve() + } + }) + .then(() => { + setDialogState(false) + }) }, [onChange, isDialogOpen], ) @@ -101,17 +153,17 @@ export function JobProxyLinkDialog({ setDialogState(false) }} > + Update proxy Links - Update proxy Links Proxies linked to {jobDetails?.name} - {isDialogOpen?.toString()}
{ + (v, event) => { + event.preventDefault() handleSubmit(v) }, err => { @@ -120,7 +172,52 @@ export function JobProxyLinkDialog({ )} className="space-y-8" > -
+
+
+
+

Pick strategy

+

+ Strategy for default proxy choice. +

+
+
+ ( +
+ +
+ )} + /> + + {form.watch("strategy") === + ProxyStrategyOptionEnum.SPECIFIC && ( + ( + + id + + field.onChange(e.target.value)} + /> + + + )} + /> + )} +
+
+ @@ -191,7 +288,9 @@ export function JobProxyLinkDialog({
diff --git a/src/components/ui/input-base.tsx b/src/components/ui/input-base.tsx new file mode 100644 index 0000000..1e3618c --- /dev/null +++ b/src/components/ui/input-base.tsx @@ -0,0 +1,213 @@ +"use client" + +import { composeEventHandlers } from "@radix-ui/primitive" +import { useComposedRefs } from "@radix-ui/react-compose-refs" +import { Primitive } from "@radix-ui/react-primitive" +import { Slot } from "@radix-ui/react-slot" +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +export type InputBaseContextProps = Pick< + InputBaseProps, + "autoFocus" | "disabled" +> & { + controlRef: React.RefObject + onFocusedChange: (focused: boolean) => void +} + +const InputBaseContext = React.createContext({ + autoFocus: false, + controlRef: { current: null }, + disabled: false, + onFocusedChange: () => {}, +}) + +function useInputBase() { + const context = React.useContext(InputBaseContext) + if (!context) { + throw new Error("useInputBase must be used within a .") + } + + return context +} + +export interface InputBaseProps + extends React.ComponentProps { + autoFocus?: boolean + disabled?: boolean + error?: boolean +} + +function InputBase({ + autoFocus, + disabled, + className, + onClick, + error, + ...props +}: InputBaseProps) { + const [focused, setFocused] = React.useState(false) + const controlRef = React.useRef(null) + + return ( + + implementation. + // https://github.com/mui/material-ui/blob/master/packages/mui-material/src/InputBase/InputBase.js#L458~L460 + onClick={composeEventHandlers(onClick, event => { + if (controlRef.current && event.currentTarget === event.target) { + controlRef.current.focus() + } + })} + className={cn( + "border-input selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex min-h-9 cursor-text items-center gap-2 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm", + disabled && "pointer-events-none cursor-not-allowed opacity-50", + focused && "border-ring ring-ring/50 ring-[3px]", + error && + "ring-destructive/20 dark:ring-destructive/40 border-destructive", + className, + )} + {...props} + /> + + ) +} + +function InputBaseFlexWrapper({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function InputBaseControl({ + ref, + onFocus, + onBlur, + ...props +}: React.ComponentProps) { + const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBase() + + const composedRefs = useComposedRefs(controlRef, ref) + + return ( + onFocusedChange(true))} + onBlur={composeEventHandlers(onBlur, () => onFocusedChange(false))} + {...{ disabled }} + {...props} + /> + ) +} + +export interface InputBaseAdornmentProps extends React.ComponentProps<"div"> { + asChild?: boolean +} + +function InputBaseAdornment({ + className, + asChild, + children, + ...props +}: InputBaseAdornmentProps) { + const Comp = asChild ? Slot : typeof children === "string" ? "p" : "div" + + return ( + + {children} + + ) +} + +function InputBaseAdornmentButton({ + type = "button", + variant = "ghost", + size = "icon", + disabled: disabledProp, + className, + ...props +}: React.ComponentProps) { + const { disabled } = useInputBase() + + return ( +