the universal sandbox runtime for agents and humans. pocketenv.io
sandbox openclaw agent claude-code vercel-sandbox deno-sandbox cloudflare-sandbox atproto sprites daytona
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Services management UI and backend fixes

+894 -35
apps/api/lexicons/secret/udpateSecret.json apps/api/lexicons/secret/updateSecret.json
apps/api/pkl/defs/secret/udpateSecret.pkl apps/api/pkl/defs/secret/updateSecret.pkl
+1 -3
apps/api/src/schema/services.ts
··· 5 5 const services = pgTable( 6 6 "services", 7 7 { 8 - id: text("id") 9 - .primaryKey() 10 - .default(sql`xata_id()`), 8 + id: text("id").primaryKey().default(sql`xata_id()`), 11 9 sandboxId: text("sandbox_id") 12 10 .notNull() 13 11 .references(() => sandboxes.id),
+1 -1
apps/api/src/xrpc/io/pocketenv/service/startService.ts
··· 40 40 ? ctx.cfsandbox(record!.sandboxes!.base!) 41 41 : ctx.sandbox(); 42 42 43 - if (record?.sandboxes?.status === "RUNNING") { 43 + if (record?.services?.status === "RUNNING") { 44 44 consola.info("Service is already running, skipping start", { 45 45 params, 46 46 auth,
+1 -1
apps/api/src/xrpc/io/pocketenv/service/stopService.ts
··· 40 40 ? ctx.cfsandbox(record!.sandboxes!.base!) 41 41 : ctx.sandbox(); 42 42 43 - if (record?.sandboxes?.status === "STOPPED") { 43 + if (record?.services?.status === "STOPPED") { 44 44 consola.info("Service is already stopped, skipping stop", { 45 45 params, 46 46 auth,
+8
apps/cf-sandbox/src/index.ts
··· 858 858 return c.json({ error: "Service not found" }, 404); 859 859 } 860 860 861 + if (service.status === "RUNNING") { 862 + return c.json({}); 863 + } 864 + 861 865 const serviceId = await sandbox.startService(service.command); 862 866 863 867 await c.var.db ··· 908 912 909 913 if (!service) { 910 914 return c.json({ error: "Service not found" }, 404); 915 + } 916 + 917 + if (service.status !== "RUNNING" || !service.serviceId) { 918 + return c.json({}); 911 919 } 912 920 913 921 await sandbox.stopService(service.serviceId!);
+1
apps/cf-sandbox/src/providers/cloudflare/index.ts
··· 190 190 191 191 async startService(command: string): Promise<string> { 192 192 const { id } = await this.sandbox.startProcess(command); 193 + await this.sandbox.setKeepAlive(true); 193 194 return id; 194 195 } 195 196
+123
apps/web/src/api/service.ts
··· 1 + import { client } from "."; 2 + import type { Service } from "../types/service"; 3 + 4 + export const addService = ( 5 + sandboxId: string, 6 + { 7 + name, 8 + command, 9 + ports, 10 + description, 11 + }: { 12 + name: string; 13 + command: string; 14 + ports?: number[]; 15 + description?: string; 16 + }, 17 + ) => 18 + client.post( 19 + `/xrpc/io.pocketenv.service.addService`, 20 + { 21 + service: { 22 + name, 23 + command, 24 + ports, 25 + description, 26 + }, 27 + }, 28 + { 29 + params: { 30 + sandboxId, 31 + }, 32 + headers: { 33 + Authorization: `Bearer ${localStorage.getItem("token")}`, 34 + }, 35 + }, 36 + ); 37 + 38 + export const deleteService = (serviceId: string) => 39 + client.post(`/xrpc/io.pocketenv.service.deleteService`, undefined, { 40 + params: { 41 + serviceId, 42 + }, 43 + headers: { 44 + Authorization: `Bearer ${localStorage.getItem("token")}`, 45 + }, 46 + }); 47 + 48 + export const getServices = (sandboxId: string) => 49 + client.get<{ services: Service[] }>( 50 + `/xrpc/io.pocketenv.service.getServices`, 51 + { 52 + params: { 53 + sandboxId, 54 + }, 55 + headers: { 56 + Authorization: `Bearer ${localStorage.getItem("token")}`, 57 + }, 58 + }, 59 + ); 60 + 61 + export const startService = (serviceId: string) => 62 + client.post(`/xrpc/io.pocketenv.service.startService`, undefined, { 63 + params: { 64 + serviceId, 65 + }, 66 + headers: { 67 + Authorization: `Bearer ${localStorage.getItem("token")}`, 68 + }, 69 + }); 70 + 71 + export const stopService = (serviceId: string) => 72 + client.post(`/xrpc/io.pocketenv.service.stopService`, undefined, { 73 + params: { 74 + serviceId, 75 + }, 76 + headers: { 77 + Authorization: `Bearer ${localStorage.getItem("token")}`, 78 + }, 79 + }); 80 + 81 + export const updateService = ( 82 + serviceId: string, 83 + { 84 + name, 85 + command, 86 + ports, 87 + description, 88 + }: { 89 + name: string; 90 + command: string; 91 + ports?: number[]; 92 + description?: string; 93 + }, 94 + ) => 95 + client.post( 96 + `/xrpc/io.pocketenv.service.updateService`, 97 + { 98 + service: { 99 + name, 100 + command, 101 + ports, 102 + description, 103 + }, 104 + }, 105 + { 106 + params: { 107 + serviceId, 108 + }, 109 + headers: { 110 + Authorization: `Bearer ${localStorage.getItem("token")}`, 111 + }, 112 + }, 113 + ); 114 + 115 + export const restartService = (serviceId: string) => 116 + client.post(`/xrpc/io.pocketenv.service.restartService`, undefined, { 117 + params: { 118 + serviceId, 119 + }, 120 + headers: { 121 + Authorization: `Bearer ${localStorage.getItem("token")}`, 122 + }, 123 + });
+110
apps/web/src/hooks/useService.ts
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { 3 + addService, 4 + deleteService, 5 + getServices, 6 + startService, 7 + stopService, 8 + restartService, 9 + updateService, 10 + } from "../api/service"; 11 + 12 + export const useAddServiceMutation = () => { 13 + const queryClient = useQueryClient(); 14 + return useMutation({ 15 + mutationKey: ["addService"], 16 + mutationFn: async ({ 17 + sandboxId, 18 + name, 19 + command, 20 + ports, 21 + description, 22 + }: { 23 + sandboxId: string; 24 + name: string; 25 + command: string; 26 + ports?: number[]; 27 + description?: string; 28 + }) => addService(sandboxId, { name, command, ports, description }), 29 + onSuccess: () => { 30 + queryClient.invalidateQueries({ queryKey: ["services"] }); 31 + }, 32 + }); 33 + }; 34 + 35 + export const useUpdateServiceMutation = () => { 36 + const queryClient = useQueryClient(); 37 + return useMutation({ 38 + mutationKey: ["updateService"], 39 + mutationFn: async ({ 40 + serviceId, 41 + name, 42 + command, 43 + ports, 44 + description, 45 + }: { 46 + serviceId: string; 47 + name: string; 48 + command: string; 49 + ports?: number[]; 50 + description?: string; 51 + }) => updateService(serviceId, { name, command, ports, description }), 52 + onSuccess: () => { 53 + queryClient.invalidateQueries({ queryKey: ["services"] }); 54 + }, 55 + }); 56 + }; 57 + 58 + export const useDeleteServiceMutation = () => { 59 + const queryClient = useQueryClient(); 60 + return useMutation({ 61 + mutationKey: ["deleteService"], 62 + mutationFn: async (id: string) => deleteService(id), 63 + onSuccess: () => { 64 + queryClient.invalidateQueries({ queryKey: ["services"] }); 65 + }, 66 + }); 67 + }; 68 + 69 + export const useServicesQuery = (sandboxId: string) => 70 + useQuery({ 71 + queryKey: ["services", sandboxId], 72 + queryFn: () => getServices(sandboxId), 73 + select: (response) => response.data, 74 + enabled: !!sandboxId, 75 + }); 76 + 77 + export const useStartServiceMutation = () => { 78 + const queryClient = useQueryClient(); 79 + return useMutation({ 80 + mutationKey: ["startService"], 81 + mutationFn: async (id: string) => startService(id), 82 + onSuccess: () => { 83 + queryClient.invalidateQueries({ queryKey: ["services"] }); 84 + }, 85 + }); 86 + }; 87 + 88 + export const useStopServiceMutation = () => { 89 + const queryClient = useQueryClient(); 90 + return useMutation({ 91 + mutationKey: ["stopService"], 92 + mutationFn: async (id: string) => stopService(id), 93 + onSuccess: () => { 94 + queryClient.invalidateQueries({ queryKey: ["services"] }); 95 + }, 96 + }); 97 + }; 98 + 99 + export const useRestartServiceMutation = () => { 100 + const queryClient = useQueryClient(); 101 + return useMutation({ 102 + mutationKey: ["restartService"], 103 + mutationFn: async (id: string) => { 104 + await restartService(id); 105 + }, 106 + onSuccess: () => { 107 + queryClient.invalidateQueries({ queryKey: ["services"] }); 108 + }, 109 + }); 110 + };
+1
apps/web/src/layouts/Main.tsx
··· 34 34 /^\/did:plc:[a-z0-9]+\/sandbox\/[a-z0-9]+\/integrations$/.test(path) || 35 35 /^\/did:plc:[a-z0-9]+\/sandbox\/[a-z0-9]+\/repository$/.test(path) || 36 36 /^\/did:plc:[a-z0-9]+\/sandbox\/[a-z0-9]+\/ports$/.test(path) || 37 + /^\/did:plc:[a-z0-9]+\/sandbox\/[a-z0-9]+\/services/.test(path) || 37 38 /^\/did:plc:[a-z0-9]+\/sandbox\/[a-z0-9]+\/tailscale$/.test(path) 38 39 ) 39 40 return "Settings";
+8 -6
apps/web/src/pages/settings/files/Files.tsx
··· 121 121 <td className="normal-case text-[14px] text-right"> 122 122 <div className="join"> 123 123 <button 124 - className="btn btn-outline join-item" 124 + className="btn btn-outline join-item w-[72.63px]" 125 125 onClick={() => { 126 126 setSelectedFileId(file.id); 127 127 setIsOpen(true); ··· 148 148 </div> 149 149 <div className="fixed bottom-3.75 left-0 right-0"> 150 150 <div className="flex justify-center items-center"> 151 - <Pagination 152 - currentPage={currentPage} 153 - totalPages={totalPages} 154 - onPageChange={onPageChange} 155 - /> 151 + {files && files.total > 0 && ( 152 + <Pagination 153 + currentPage={currentPage} 154 + totalPages={totalPages} 155 + onPageChange={onPageChange} 156 + /> 157 + )} 156 158 </div> 157 159 </div> 158 160 </div>
+8 -6
apps/web/src/pages/settings/secrets/Secrets.tsx
··· 129 129 <td className="normal-case text-[14px] text-right"> 130 130 <div className="join"> 131 131 <button 132 - className="btn btn-outline join-item" 132 + className="btn btn-outline join-item w-[72.63px]" 133 133 onClick={() => { 134 134 setSelectedSecretId(secret.id); 135 135 setIsOpen(true); ··· 156 156 </div> 157 157 <div className="fixed bottom-3.75 left-0 right-0"> 158 158 <div className="flex justify-center items-center"> 159 - <Pagination 160 - currentPage={currentPage} 161 - totalPages={totalPages} 162 - onPageChange={onPageChange} 163 - /> 159 + {secrets && secrets.total > 0 && ( 160 + <Pagination 161 + currentPage={currentPage} 162 + totalPages={totalPages} 163 + onPageChange={onPageChange} 164 + /> 165 + )} 164 166 </div> 165 167 </div> 166 168 </div>
+119
apps/web/src/pages/settings/services/DeleteServiceModal/DeleteServiceModal.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { createPortal } from "react-dom"; 3 + import { useDeleteServiceMutation } from "../../../../hooks/useService"; 4 + 5 + export type DeleteServiceModalProps = { 6 + isOpen: boolean; 7 + onClose: () => void; 8 + serviceId: string; 9 + serviceName?: string; 10 + }; 11 + 12 + function DeleteServiceModal({ 13 + isOpen, 14 + onClose, 15 + serviceId, 16 + serviceName, 17 + }: DeleteServiceModalProps) { 18 + const [isLoading, setIsLoading] = useState(false); 19 + const { mutateAsync: deleteService } = useDeleteServiceMutation(); 20 + useEffect(() => { 21 + const handleEscapeKey = (event: KeyboardEvent) => { 22 + if (event.key === "Escape" && isOpen) { 23 + onClose(); 24 + } 25 + }; 26 + 27 + document.addEventListener("keydown", handleEscapeKey); 28 + return () => { 29 + document.removeEventListener("keydown", handleEscapeKey); 30 + }; 31 + }, [isOpen, onClose]); 32 + 33 + const onDeleteService = async (e: React.MouseEvent<HTMLButtonElement>) => { 34 + setIsLoading(true); 35 + await deleteService(serviceId); 36 + setIsLoading(false); 37 + e.stopPropagation(); 38 + onClose(); 39 + }; 40 + 41 + const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => { 42 + e.stopPropagation(); 43 + if (e.target === e.currentTarget) { 44 + onClose(); 45 + } 46 + }; 47 + 48 + const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => { 49 + e.stopPropagation(); 50 + }; 51 + 52 + const handleCloseButton = (e: React.MouseEvent<HTMLButtonElement>) => { 53 + e.stopPropagation(); 54 + onClose(); 55 + }; 56 + 57 + if (!isOpen) return null; 58 + 59 + return createPortal( 60 + <> 61 + <div 62 + className="overlay modal modal-middle overlay-open:opacity-100 overlay-open:duration-300 open opened" 63 + role="dialog" 64 + style={{ outline: "none", zIndex: 80 }} 65 + onClick={handleBackdropClick} 66 + onMouseDown={handleBackdropClick} 67 + > 68 + <div 69 + className={`overlay-animation-target modal-dialog overlay-open:duration-300 transition-all ease-out modal-dialog-md overlay-open:mt-4 mt-12`} 70 + onClick={handleContentClick} 71 + onMouseDown={handleContentClick} 72 + > 73 + <div className="modal-content"> 74 + <div className="modal-header pb-0"> 75 + <div className="flex-1">Delete "{serviceName}"</div> 76 + <button 77 + type="button" 78 + className="btn btn-text btn-circle btn-sm absolute end-3 top-3" 79 + aria-label="Close" 80 + onClick={handleCloseButton} 81 + onMouseDown={(e) => e.stopPropagation()} 82 + > 83 + <span className="icon-[tabler--x] size-4"></span> 84 + </button> 85 + </div> 86 + <div className="modal-body p-0 pl-2 h-[100px] flex flex-col justify-center"> 87 + <p className="font-semibold text-center"> 88 + Are you sure you want to delete this service? 89 + </p> 90 + <p className="text-center ">This action cannot be undone.</p> 91 + </div> 92 + <div className="modal-footer"> 93 + <button 94 + className="btn btn-error font-semibold" 95 + onClick={onDeleteService} 96 + > 97 + {isLoading && ( 98 + <span className="loading loading-spinner loading-xs mr-1.5"></span> 99 + )} 100 + Delete Service 101 + </button> 102 + </div> 103 + </div> 104 + </div> 105 + </div> 106 + 107 + <div 108 + data-overlay-backdrop-template="" 109 + style={{ zIndex: 79 }} 110 + className="overlay-backdrop transition duration-300 fixed inset-0 bg-base-300/60 overflow-y-auto opacity-75" 111 + onClick={handleBackdropClick} 112 + onMouseDown={(e) => e.stopPropagation()} 113 + ></div> 114 + </>, 115 + document.body, 116 + ); 117 + } 118 + 119 + export default DeleteServiceModal;
+3
apps/web/src/pages/settings/services/DeleteServiceModal/index.tsx
··· 1 + import DeleteServiceModal from "./DeleteServiceModal"; 2 + 3 + export default DeleteServiceModal;
+279
apps/web/src/pages/settings/services/NewServiceModal/NewServiceModal.tsx
··· 1 + import { createPortal } from "react-dom"; 2 + import { useForm } from "react-hook-form"; 3 + import { z } from "zod"; 4 + import { zodResolver } from "@hookform/resolvers/zod"; 5 + import { useEffect } from "react"; 6 + import { useAddServiceMutation, useUpdateServiceMutation } from "../../../../hooks/useService"; 7 + import type { Service } from "../../../../types/service"; 8 + 9 + const schema = z.object({ 10 + name: z.string().trim().min(1, "Service name is required"), 11 + command: z.string().trim().min(1, "Command is required"), 12 + ports: z.array( 13 + z.coerce 14 + .number() 15 + .int() 16 + .min(1025, "Port must be between 1025 and 65535") 17 + .max(65535, "Port must be between 1025 and 65535"), 18 + ), 19 + description: z.string().optional(), 20 + }); 21 + 22 + export type NewServiceModalProps = { 23 + isOpen: boolean; 24 + onClose: () => void; 25 + sandboxId: string; 26 + service?: Service; 27 + }; 28 + 29 + function NewServiceModal({ isOpen, onClose, sandboxId, service }: NewServiceModalProps) { 30 + const isEdit = !!service; 31 + const { mutateAsync: addService, isPending: isAdding } = useAddServiceMutation(); 32 + const { mutateAsync: updateService, isPending: isUpdating } = useUpdateServiceMutation(); 33 + const isLoading = isAdding || isUpdating; 34 + 35 + const { 36 + register, 37 + handleSubmit, 38 + reset, 39 + setValue, 40 + formState: { errors }, 41 + } = useForm({ 42 + resolver: zodResolver(schema), 43 + defaultValues: { ports: [] as number[] }, 44 + }); 45 + 46 + useEffect(() => { 47 + if (isOpen) { 48 + if (service) { 49 + reset({ 50 + name: service.name, 51 + command: service.command, 52 + ports: service.ports ?? [], 53 + description: service.description ?? "", 54 + }); 55 + } else { 56 + reset({ name: "", command: "", ports: [], description: "" }); 57 + } 58 + } 59 + }, [isOpen, service, reset]); 60 + 61 + const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => { 62 + e.stopPropagation(); 63 + if (e.target === e.currentTarget) { 64 + reset(); 65 + onClose(); 66 + } 67 + }; 68 + 69 + const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => { 70 + e.stopPropagation(); 71 + }; 72 + 73 + const handleCloseButton = (e: React.MouseEvent<HTMLButtonElement>) => { 74 + e.stopPropagation(); 75 + reset(); 76 + onClose(); 77 + }; 78 + 79 + const handlePortsChange = (e: React.ChangeEvent<HTMLInputElement>) => { 80 + const raw = e.target.value; 81 + const parsed = raw 82 + .split(",") 83 + .map((s) => s.trim()) 84 + .filter(Boolean) 85 + .map(Number); 86 + setValue("ports", parsed); 87 + }; 88 + 89 + const onSubmit = async (data: z.infer<typeof schema>) => { 90 + if (isEdit) { 91 + await updateService({ serviceId: service.id, ...data }); 92 + } else { 93 + await addService({ sandboxId, ...data }); 94 + } 95 + reset(); 96 + onClose(); 97 + }; 98 + 99 + useEffect(() => { 100 + const handleEscapeKey = (event: KeyboardEvent) => { 101 + if (event.key === "Escape" && isOpen) { 102 + reset(); 103 + onClose(); 104 + } 105 + }; 106 + 107 + document.addEventListener("keydown", handleEscapeKey); 108 + return () => { 109 + document.removeEventListener("keydown", handleEscapeKey); 110 + }; 111 + }, [isOpen, onClose, reset]); 112 + 113 + if (!isOpen) return null; 114 + 115 + return createPortal( 116 + <> 117 + <div 118 + className="overlay modal modal-middle overlay-open:opacity-100 overlay-open:duration-300 open opened" 119 + role="dialog" 120 + style={{ outline: "none", zIndex: 80 }} 121 + onClick={handleBackdropClick} 122 + onMouseDown={handleBackdropClick} 123 + > 124 + <div 125 + className={`overlay-animation-target modal-dialog overlay-open:duration-300 transition-all ease-out modal-dialog-lg overlay-open:mt-4 mt-12`} 126 + onClick={handleContentClick} 127 + onMouseDown={handleContentClick} 128 + > 129 + <div className="modal-content"> 130 + <div className="modal-header"> 131 + <div className="flex-1">{isEdit ? "Edit Service" : "New Service"}</div> 132 + <button 133 + type="button" 134 + className="btn btn-text btn-circle btn-sm absolute end-3 top-3" 135 + aria-label="Close" 136 + onClick={handleCloseButton} 137 + onMouseDown={(e) => e.stopPropagation()} 138 + > 139 + <span className="icon-[tabler--x] size-4"></span> 140 + </button> 141 + </div> 142 + <form onSubmit={handleSubmit(onSubmit)}> 143 + <div className="modal-body"> 144 + <div className="form-control w-full"> 145 + <label className="label"> 146 + <span className="label-text font-bold mb-1 text-[14px]"> 147 + Name 148 + </span> 149 + </label> 150 + <div 151 + className={`input input-bordered w-full input-lg text-[15px] font-semibold bg-transparent`} 152 + > 153 + <input 154 + type="text" 155 + placeholder="Service name" 156 + className={`grow`} 157 + autoComplete="off" 158 + data-1p-ignore 159 + data-lpignore="true" 160 + data-form-type="other" 161 + style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }} 162 + {...register("name")} 163 + /> 164 + </div> 165 + {errors.name && ( 166 + <span className="text-error text-[12px] mt-1"> 167 + {errors.name.message} 168 + </span> 169 + )} 170 + 171 + <div className="mt-5"> 172 + <label className="label"> 173 + <span className="label-text font-bold mb-1 text-[14px]"> 174 + Command 175 + </span> 176 + </label> 177 + <div 178 + className={`input input-bordered w-full input-lg text-[15px] font-semibold bg-transparent`} 179 + > 180 + <input 181 + type="text" 182 + placeholder="e.g. npx serve" 183 + className={`grow`} 184 + autoComplete="off" 185 + data-1p-ignore 186 + data-lpignore="true" 187 + data-form-type="other" 188 + style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }} 189 + {...register("command")} 190 + /> 191 + </div> 192 + {errors.command && ( 193 + <span className="text-error text-[12px] mt-1"> 194 + {errors.command.message} 195 + </span> 196 + )} 197 + </div> 198 + 199 + <div className="mt-5"> 200 + <label className="label"> 201 + <span className="label-text font-bold mb-1 text-[14px]"> 202 + Ports 203 + </span> 204 + </label> 205 + <div 206 + className={`input input-bordered w-full input-lg text-[15px] font-semibold bg-transparent`} 207 + > 208 + <input 209 + type="text" 210 + placeholder="e.g. 3001, 8081" 211 + className={`grow`} 212 + autoComplete="off" 213 + data-1p-ignore 214 + data-lpignore="true" 215 + data-form-type="other" 216 + style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }} 217 + key={isOpen ? (service?.id ?? "new") : "closed"} 218 + defaultValue={service?.ports?.join(", ") ?? ""} 219 + onChange={handlePortsChange} 220 + /> 221 + </div> 222 + {errors.ports && ( 223 + <span className="text-error text-[12px] mt-1"> 224 + {errors.ports.message} 225 + </span> 226 + )} 227 + </div> 228 + 229 + <div className="mt-5"> 230 + <label className="label"> 231 + <span className="label-text font-bold mb-1 text-[14px]"> 232 + Description 233 + </span> 234 + </label> 235 + <textarea 236 + className={`textarea max-w-full h-[150px] text-[14px] font-semibold`} 237 + aria-label="Textarea" 238 + placeholder="Optional description for this service" 239 + style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }} 240 + {...register("description")} 241 + ></textarea> 242 + {errors.description && ( 243 + <span className="text-error text-[12px] mt-1 block"> 244 + {errors.description.message} 245 + </span> 246 + )} 247 + </div> 248 + </div> 249 + </div> 250 + <div className="modal-footer"> 251 + <button 252 + type="submit" 253 + className="btn btn-primary w-45 font-semibold" 254 + disabled={isLoading} 255 + > 256 + {isLoading && ( 257 + <span className="loading loading-spinner loading-xs mr-1.5"></span> 258 + )} 259 + {isEdit ? "Save Changes" : "Create Service"} 260 + </button> 261 + </div> 262 + </form> 263 + </div> 264 + </div> 265 + </div> 266 + 267 + <div 268 + data-overlay-backdrop-template="" 269 + style={{ zIndex: 79 }} 270 + className="overlay-backdrop transition duration-300 fixed inset-0 bg-base-300/60 overflow-y-auto opacity-75" 271 + onClick={handleBackdropClick} 272 + onMouseDown={(e) => e.stopPropagation()} 273 + ></div> 274 + </>, 275 + document.body, 276 + ); 277 + } 278 + 279 + export default NewServiceModal;
+3
apps/web/src/pages/settings/services/NewServiceModal/index.tsx
··· 1 + import NewServiceModal from "./NewServiceModal"; 2 + 3 + export default NewServiceModal;
+174
apps/web/src/pages/settings/services/Services.tsx
··· 1 + import { useRouterState } from "@tanstack/react-router"; 2 + import { useSandboxQuery } from "../../../hooks/useSandbox"; 3 + import Main from "../../../layouts/Main"; 4 + import Sidebar from "../sidebar/Sidebar"; 5 + import { useServicesQuery } from "../../../hooks/useService"; 6 + import dayjs from "dayjs"; 7 + import relativeTime from "dayjs/plugin/relativeTime"; 8 + import ContentLoader from "react-content-loader"; 9 + import { useState } from "react"; 10 + import NewServiceModal from "./NewServiceModal"; 11 + import _ from "lodash"; 12 + import DeleteServiceModal from "./DeleteServiceModal"; 13 + import type { Service } from "../../../types/service"; 14 + 15 + dayjs.extend(relativeTime); 16 + 17 + const SKELETON_ROWS = 8; 18 + 19 + const ServicesRowSkeleton = ({ index }: { index: number }) => ( 20 + <ContentLoader 21 + speed={1.5} 22 + width="100%" 23 + height={48} 24 + backgroundColor="rgba(255,255,255,0.06)" 25 + foregroundColor="rgba(255,255,255,0.13)" 26 + style={{ width: "100%" }} 27 + uniqueKey={`secret-row-skeleton-${index}`} 28 + > 29 + {/* Name column - wide */} 30 + <rect x="16" y="16" rx="6" ry="6" width="38%" height="16" /> 31 + {/* Created At column */} 32 + <rect x="50%" y="16" rx="6" ry="6" width="22%" height="16" /> 33 + {/* Action column */} 34 + <rect x="88%" y="12" rx="6" ry="6" width="8%" height="24" /> 35 + </ContentLoader> 36 + ); 37 + 38 + function Services() { 39 + const routerState = useRouterState(); 40 + const pathname = routerState.location.pathname; 41 + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); 42 + const [selectedService, setSelectedService] = useState<string | undefined>(undefined); 43 + const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>(undefined); 44 + const [editingService, setEditingService] = useState<Service | undefined>(undefined); 45 + const [isNewServiceOpen, setIsNewServiceOpen] = useState(false); 46 + 47 + const { data } = useSandboxQuery( 48 + `at:/${pathname.replace("/services", "").replace("sandbox", "io.pocketenv.sandbox")}`, 49 + ); 50 + const { data: servicesData, isLoading } = useServicesQuery( 51 + data?.sandbox?.id || "", 52 + ); 53 + 54 + const services = servicesData?.services || []; 55 + 56 + return ( 57 + <Main 58 + sidebar={<Sidebar />} 59 + root={data?.sandbox?.name} 60 + rootLink={pathname.replace("/services", "")} 61 + > 62 + <> 63 + <div className="w-[95%] m-auto"> 64 + <div className="flex flex-row items-center"> 65 + <h1 className="mb-1 text-xl flex-1">Services</h1> 66 + <button 67 + className="btn btn-md btn-primary font-semibold" 68 + onClick={() => { setEditingService(undefined); setIsNewServiceOpen(true); }} 69 + > 70 + New Service 71 + </button> 72 + </div> 73 + <p className="opacity-60 mb-5"> 74 + Services are processes that run in the background of your Sandbox, 75 + such as databases or servers. 76 + </p> 77 + <div className="w-full overflow-x-auto"> 78 + <table className="table mb-20"> 79 + {!!services.length && ( 80 + <thead> 81 + <tr> 82 + <th className="normal-case text-[14px]">Name</th> 83 + <th className="normal-case text-[14px]">Command</th> 84 + <th className="normal-case text-[14px]">Status</th> 85 + <th className="normal-case text-[14px]">Created At</th> 86 + <th className="normal-case text-[14px]"></th> 87 + </tr> 88 + </thead> 89 + )} 90 + <tbody> 91 + {isLoading 92 + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( 93 + <tr key={`skeleton-${i}`}> 94 + <td colSpan={3} className="p-0"> 95 + <ServicesRowSkeleton index={i} /> 96 + </td> 97 + </tr> 98 + )) 99 + : services.map((service) => ( 100 + <tr key={service.id}> 101 + <td className="normal-case text-[14px] font-medium"> 102 + {service.name} 103 + </td> 104 + <td 105 + className="normal-case text-[14px] font-medium" 106 + style={{ 107 + fontFamily: 108 + "CaskaydiaNerdFontMonoRegular, monospace", 109 + }} 110 + > 111 + {service.command} 112 + </td> 113 + <td> 114 + <span 115 + className={`badge badge-soft ${service?.status === "RUNNING" ? "badge-success" : ""} rounded-full ${service.status === "RUNNING" ? "bg-green-400/10" : "bg-white/15 rounded"}`} 116 + > 117 + {_.upperFirst(_.camelCase(service.status))} 118 + </span> 119 + </td> 120 + <td className="normal-case text-[14px] font-medium"> 121 + {dayjs(service.createdAt).format( 122 + "M/D/YYYY, h:mm:ss A", 123 + )} 124 + </td> 125 + <td className="normal-case text-[14px] text-right"> 126 + <div className="join"> 127 + <button 128 + className="btn btn-outline join-item w-[72.63px]" 129 + onClick={() => { 130 + setEditingService(service); 131 + setIsNewServiceOpen(true); 132 + }} 133 + > 134 + Edit 135 + </button> 136 + <button 137 + className="btn btn-outline join-item" 138 + onClick={() => { 139 + setSelectedService(service.name); 140 + setSelectedServiceId(service.id); 141 + setConfirmDeleteOpen(true); 142 + }} 143 + > 144 + Delete 145 + </button> 146 + </div> 147 + </td> 148 + </tr> 149 + ))} 150 + </tbody> 151 + </table> 152 + </div> 153 + </div> 154 + </> 155 + <NewServiceModal 156 + isOpen={isNewServiceOpen} 157 + onClose={() => { 158 + setIsNewServiceOpen(false); 159 + setEditingService(undefined); 160 + }} 161 + sandboxId={data?.sandbox?.id || ""} 162 + service={editingService} 163 + /> 164 + <DeleteServiceModal 165 + isOpen={confirmDeleteOpen} 166 + onClose={() => setConfirmDeleteOpen(false)} 167 + serviceId={selectedServiceId || ""} 168 + serviceName={selectedService || ""} 169 + /> 170 + </Main> 171 + ); 172 + } 173 + 174 + export default Services;
+3
apps/web/src/pages/settings/services/index.tsx
··· 1 + import Services from "./Services"; 2 + 3 + export default Services;
+16
apps/web/src/pages/settings/sidebar/Sidebar.tsx
··· 186 186 </li> 187 187 <li> 188 188 <Link 189 + to={`/${did}/sandbox/${rkey}/services`} 190 + className={`${ 191 + isActive("/services") 192 + ? "active bg-white/7 text-[#00e8c6]! font-semibold rounded-full" 193 + : "rounded-full hover:text-white" 194 + } ${isCollapsed ? "justify-center px-2" : ""}`} 195 + title={isCollapsed ? "Services" : undefined} 196 + > 197 + <span 198 + className={`icon-[bi--app] size-6 ${isCollapsed ? "" : "mr-2 ml-1"}`} 199 + ></span> 200 + {!isCollapsed && "Services"} 201 + </Link> 202 + </li> 203 + <li> 204 + <Link 189 205 to={`/${did}/sandbox/${rkey}/ssh-keys`} 190 206 className={`${ 191 207 isActive("/ssh-keys")
+8 -6
apps/web/src/pages/settings/variables/Variables.tsx
··· 141 141 <td className="normal-case text-[14px] text-right"> 142 142 <div className="join"> 143 143 <button 144 - className="btn btn-outline join-item" 144 + className="btn btn-outline join-item w-[72.63px]" 145 145 onClick={() => { 146 146 setSelectedVariableId(variable.id); 147 147 setIsOpen(true); ··· 168 168 </div> 169 169 <div className="fixed bottom-3.75 left-0 right-0"> 170 170 <div className="flex justify-center items-center"> 171 - <Pagination 172 - currentPage={currentPage} 173 - totalPages={totalPages} 174 - onPageChange={onPageChange} 175 - /> 171 + {variables && variables.total > 0 && ( 172 + <Pagination 173 + currentPage={currentPage} 174 + totalPages={totalPages} 175 + onPageChange={onPageChange} 176 + /> 177 + )} 176 178 </div> 177 179 </div> 178 180 </div>
+7 -5
apps/web/src/pages/settings/volumes/Volumes.tsx
··· 160 160 </div> 161 161 <div className="fixed bottom-3.75 left-0 right-0"> 162 162 <div className="flex justify-center items-center"> 163 - <Pagination 164 - currentPage={currentPage} 165 - totalPages={totalPages} 166 - onPageChange={onPageChange} 167 - /> 163 + {volumes && volumes.total > 0 && ( 164 + <Pagination 165 + currentPage={currentPage} 166 + totalPages={totalPages} 167 + onPageChange={onPageChange} 168 + /> 169 + )} 168 170 </div> 169 171 </div> 170 172 </div>
+11 -7
apps/web/src/routes/$did.sandbox.$rkey/services.tsx
··· 1 - import { createFileRoute } from '@tanstack/react-router' 1 + import { createFileRoute, redirect } from "@tanstack/react-router"; 2 + import ServicesPage from "../../pages/settings/services/Services"; 2 3 3 - export const Route = createFileRoute('/$did/sandbox/$rkey/services')({ 4 - component: RouteComponent, 5 - }) 4 + export const Route = createFileRoute("/$did/sandbox/$rkey/services")({ 5 + beforeLoad: () => { 6 + const isAuthenticated = !!localStorage.getItem("token"); 6 7 7 - function RouteComponent() { 8 - return <div>Hello "/$did/sandbox/$rkey/services"!</div> 9 - } 8 + if (!isAuthenticated) { 9 + throw redirect({ to: "/" }); 10 + } 11 + }, 12 + component: ServicesPage, 13 + });
+9
apps/web/src/types/service.ts
··· 1 + export type Service = { 2 + id: string; 3 + name: string; 4 + ports?: number[]; 5 + command: string; 6 + description?: string; 7 + status: "RUNNING" | "STOPPED"; 8 + createdAt: string; 9 + };