diff --git a/TODO.md b/TODO.md index fcd0eada..df910cfd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,11 @@ ## Logic Ish, General TODOS - [ ] openapi docs for the api based on `../shsf`'s implementation +- [ ] Loosen lock down on docker, make startup, update and setup more customizable; When docker is enabled, startup commands are filled ONCE then the user can edit them. Filled in the backend, ui is told to reload, ui shows the commands. ## UI Related - [ ] Fix Theming to be more professional (use frontend themeing skill & make it look like a professional product) - [x] replace emoji in the top left with the actual icon +- [x] Build Repo Settings ui from scratch, is aweful to use and not intuitive, needs to be more like a settings page with a list of settings and a way to edit them. ## Future BS - [ ] CPU, MEM, NET, DISK sentinal first-installed on EC2s to expose a backend for the user's ui to hit, THROUGH A BACKEND PROXY ROUTE so we can cache and rate limit the requests. This will allow for a better dashboard and better stats for the user to see. We'll use an obscure port for the sentinal so that we dont hit any other services the user may have. Sentinal is our own little C program exposing the stats we need, seperate repo for that thing tho! diff --git a/frontend/src/pages/RepoConfig.tsx b/frontend/src/pages/RepoConfig.tsx index 15798ca8..ebadf829 100644 --- a/frontend/src/pages/RepoConfig.tsx +++ b/frontend/src/pages/RepoConfig.tsx @@ -1,32 +1,65 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useParams, useNavigate, Link } from "react-router-dom"; import { api } from "../services/api"; import { toast } from "react-toastify"; -import { ArrowLeft, X, Plus } from "lucide-react"; +import { + ArrowLeft, + ChevronRight, + Container, + KeyRound, + MessageSquare, + Package, + Plus, + Save, + Server, + Shield, + Terminal, + X, +} from "lucide-react"; import { DEFAULT_INSTANCE_TYPE, INSTANCE_TYPES, isPresetInstanceType, normalizeInstanceType } from "../lib/instanceTypes"; import { usePageTitle } from "../hooks/usePageTitle"; -function defaultConfig() { +type RepoConfigState = { + instanceType: string; + inactivityHours: number; + port: number; + denyList: string[]; + disabledCommands: string[]; + envVars: Record; + preinstallTools: string[]; + nodeVersion: string; + useDockerCompose: boolean; + composeFilePath: string; + aptPackages: string[]; + setupCommands: string[]; + buildCommands: string[]; + postBuildCommands: string[]; + runCommand: string; +}; + +type SectionKey = "compute" | "runtime" | "docker" | "commands" | "environment" | "access" | "commentCommands"; + +function defaultConfig(): RepoConfigState { return { instanceType: DEFAULT_INSTANCE_TYPE, inactivityHours: 12, port: 3000, - denyList: [] as string[], - disabledCommands: [] as string[], - envVars: {} as Record, - preinstallTools: [] as string[], + denyList: [], + disabledCommands: [], + envVars: {}, + preinstallTools: [], nodeVersion: "lts/*", useDockerCompose: false, composeFilePath: "docker-compose.yml", - aptPackages: [] as string[], - setupCommands: [] as string[], - buildCommands: [] as string[], - postBuildCommands: [] as string[], + aptPackages: [], + setupCommands: [], + buildCommands: [], + postBuildCommands: [], runCommand: "", }; } -function normalizeConfig(raw: any) { +function normalizeConfig(raw: any): RepoConfigState { const base = defaultConfig(); if (!raw) return base; return { @@ -55,8 +88,9 @@ function parseAptPackages(value: string) { export function RepoConfig() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const navigate = useNavigate(); - const [config, setConfig] = useState(defaultConfig()); + const [config, setConfig] = useState(defaultConfig()); const [aptPackagesInput, setAptPackagesInput] = useState(""); + const [activeSection, setActiveSection] = useState("compute"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); usePageTitle(owner && repo ? `Configure ${owner}/${repo}` : "Configure Repo"); @@ -73,7 +107,6 @@ export function RepoConfig() { setConfig(nextConfig); setAptPackagesInput(nextConfig.aptPackages.join(" ")); } else if (res.status === 404) { - // No config saved yet — start from defaults. setConfig(defaultConfig()); setAptPackagesInput(""); } else { @@ -84,22 +117,22 @@ export function RepoConfig() { return () => { active = false; }; }, [owner, repo]); - const update = (field: string, value: any) => { - setConfig((prev: any) => ({ ...prev, [field]: value })); + const update = (field: K, value: RepoConfigState[K]) => { + setConfig(prev => ({ ...prev, [field]: value })); }; const togglePreinstall = (tool: string, checked: boolean) => { - setConfig((prev: any) => { + setConfig(prev => { const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : []; const next = checked ? [...new Set([...current, tool])] - : current.filter((item: string) => item !== tool); + : current.filter(item => item !== tool); return { ...prev, preinstallTools: next }; }); }; const setDockerCompose = (checked: boolean) => { - setConfig((prev: any) => { + setConfig(prev => { const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : []; return { ...prev, @@ -112,209 +145,414 @@ export function RepoConfig() { const handleSave = async () => { if (!owner || !repo) return; setSaving(true); + const nextAptPackages = parseAptPackages(aptPackagesInput); const res = await api.repos.saveConfig({ owner, repo, ...config, - aptPackages: parseAptPackages(aptPackagesInput), + aptPackages: nextAptPackages, }); setSaving(false); if (res.ok) { - toast.success("Config saved"); + setConfig(prev => ({ ...prev, aptPackages: nextAptPackages })); + toast.success("Repo settings saved"); } else { - toast.error(res.message || "Failed to save config"); + toast.error(res.message || "Failed to save settings"); } }; + const sections = useMemo(() => buildSections(config), [config]); + const currentSection = sections.find(section => section.key === activeSection) ?? sections[0]; + if (loading) { return ( -
+
- {[...Array(4)].map((_, i) =>
)} +
+
+ {[...Array(7)].map((_, i) =>
)} +
+
+
); } return ( -
+
- Back to Repos + + Repositories +
-
-
-

Configure Repo

-

{owner}/{repo}

+
+
+

Repo Settings

+

{owner}/{repo}

-
- -
-
-
- - - {!isPresetInstanceType(config.instanceType) && ( - update("instanceType", normalizeInstanceType(e.target.value, ""))} /> - )} -
-
- - update("port", Number(e.target.value))} className={inputCls} min={1} max={65535} /> -
-
- -
- - update("inactivityHours", Number(e.target.value))} - className="w-full mt-1" /> -
- 0.5h12h72h -
-
- -
- - update("denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))} - className={inputCls} - placeholder="dependabot, renovate-bot" - /> -
- -
- -
- {PREINSTALL_OPTIONS.map(option => { - const checked = (config.preinstallTools || []).includes(option.value); - const locked = option.value === "docker" && config.useDockerCompose; - return ( - - ); - })} -
- {(config.preinstallTools || []).includes("node") && ( -
- - update("nodeVersion", e.target.value)} - className={inputCls} - placeholder="lts/*, 22, 20.11.1" - /> -
- )} -
- -
- - { - setAptPackagesInput(e.target.value); - update("aptPackages", parseAptPackages(e.target.value)); - }} - className={inputCls} - placeholder="ffmpeg libpq-dev" - /> -
- -
-
- setDockerCompose(e.target.checked)} className="rounded" /> - -
- - {config.useDockerCompose ? ( -
- - update("composeFilePath", e.target.value)} - className={inputCls} placeholder="docker-compose.yml" /> -
- ) : ( -
- update("buildCommands", v)} /> - update("postBuildCommands", v)} /> -
- - update("runCommand", e.target.value)} - className={inputCls} placeholder="python app.py, ./server, pnpm start" /> -
-
- )} -
- - update("setupCommands", v)} /> - - update("envVars", v)} /> - - update("disabledCommands", v)} /> - -
+
+ +
+
+ {sections.map(section => { + const selected = section.key === activeSection; + return ( + + ); + })} +
+ +
+
+ + {currentSection.icon} + +
+

{currentSection.title}

+

{currentSection.description}

+
+
+ + {activeSection === "compute" && ( +
+ + + {!isPresetInstanceType(config.instanceType) && ( + update("instanceType", normalizeInstanceType(e.target.value, ""))} + /> + )} + + + update("inactivityHours", Number(e.target.value))} + className="w-full" + /> +
+ 0.5h12h72h +
+
+ + update("port", Number(e.target.value))} + className={inputCls} + min={1} + max={65535} + /> + +
+ )} + + {activeSection === "runtime" && ( +
+ +
+ {PREINSTALL_OPTIONS.map(option => { + const checked = config.preinstallTools.includes(option.value); + const locked = option.value === "docker" && config.useDockerCompose; + return ( + + ); + })} +
+
+ {config.preinstallTools.includes("node") && ( + + update("nodeVersion", e.target.value)} + className={inputCls} + placeholder="lts/*, 22, 20.11.1" + /> + + )} + + { + setAptPackagesInput(e.target.value); + update("aptPackages", parseAptPackages(e.target.value)); + }} + className={inputCls} + placeholder="ffmpeg libpq-dev" + /> + +
+ )} + + {activeSection === "docker" && ( +
+ + {config.useDockerCompose && ( + + update("composeFilePath", e.target.value)} + className={inputCls} + placeholder="docker-compose.yml" + /> + + )} +
+ )} + + {activeSection === "commands" && ( +
+ update("setupCommands", v)} placeholder="apt-get update" /> + {!config.useDockerCompose && ( + <> + update("buildCommands", v)} placeholder="pnpm install && pnpm build" /> + update("postBuildCommands", v)} placeholder="prisma migrate deploy" /> + + update("runCommand", e.target.value)} + className={inputCls} + placeholder="python app.py, ./server, pnpm start" + /> + + + )} + {config.useDockerCompose && ( +

+ Docker Compose mode uses the compose file path from the Docker section. +

+ )} +
+ )} + + {activeSection === "environment" && ( + update("envVars", v)} /> + )} + + {activeSection === "access" && ( + + update("denyList", e.target.value.split(",").map(s => s.trim()).filter(Boolean))} + className={inputCls} + placeholder="dependabot, renovate-bot" + /> + + )} + + {activeSection === "commentCommands" && ( + update("disabledCommands", v)} /> + )} +
+
); } -function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) { +function buildSections(config: RepoConfigState) { + return [ + { + key: "compute" as const, + title: "Compute", + description: "Instance size, exposed port, and automatic idle shutdown.", + summary: `${config.instanceType || "Custom instance"} on port ${config.port}; stops after ${config.inactivityHours}h idle`, + icon: , + }, + { + key: "runtime" as const, + title: "Runtime Packages", + description: "Tools installed on the preview instance before app setup.", + summary: summarizeList([...config.preinstallTools, ...config.aptPackages], "No runtime packages selected"), + icon: , + }, + { + key: "docker" as const, + title: "Docker Compose", + description: "Choose whether the preview starts from a compose file.", + summary: config.useDockerCompose ? `Enabled: ${config.composeFilePath || "docker-compose.yml"}` : "Disabled", + icon: , + }, + { + key: "commands" as const, + title: "Deploy Commands", + description: "Commands used to prepare, build, and start the preview.", + summary: config.useDockerCompose + ? "Compose file controls startup" + : `${config.buildCommands.length} build, ${config.postBuildCommands.length} post-build, ${config.runCommand ? "run set" : "no run command"}`, + icon: , + }, + { + key: "environment" as const, + title: "Environment Variables", + description: "Variables passed to the preview app.", + summary: `${Object.keys(config.envVars || {}).filter(Boolean).length} variables`, + icon: , + }, + { + key: "access" as const, + title: "Access Controls", + description: "Users that are blocked from creating previews for this repo.", + summary: summarizeList(config.denyList, "No users blocked"), + icon: , + }, + { + key: "commentCommands" as const, + title: "PR Comment Commands", + description: "Reviewer commands available from pull request comments.", + summary: `${CONFIGURABLE_COMMANDS.length - config.disabledCommands.length} of ${CONFIGURABLE_COMMANDS.length} commands enabled`, + icon: , + }, + ]; +} + +function summarizeList(values: string[], empty: string) { + const clean = values.filter(Boolean); + if (clean.length === 0) return empty; + if (clean.length <= 3) return clean.join(", "); + return `${clean.slice(0, 3).join(", ")} +${clean.length - 3} more`; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
-
+ {children} +
+ ); +} + +function CommandList({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string[]; + onChange: (v: string[]) => void; + placeholder: string; +}) { + return ( + +
+ {value.length === 0 && ( +
+ No commands configured. +
+ )} {value.map((cmd, i) => (
- { const n = [...value]; n[i] = e.target.value; onChange(n); }} - className={inputCls} placeholder="make build" /> - + { + const next = [...value]; + next[i] = e.target.value; + onChange(next); + }} + className={inputCls} + placeholder={placeholder} + /> +
))} -
-
+ ); } @@ -327,14 +565,12 @@ const PREINSTALL_OPTIONS: { value: string; label: string }[] = [ { value: "build-essential", label: "Build tools" }, ]; -// Configurable `/pp` commands. `help` is intentionally omitted — it can never be -// disabled so reviewers always have a way to discover command state. const CONFIGURABLE_COMMANDS: { name: string; description: string }[] = [ - { name: "rebuild", description: "Rebuild the preview (reuse instance, or re-provision if stopped)." }, + { name: "rebuild", description: "Rebuild the preview." }, { name: "stop", description: "Stop and terminate the preview instance." }, - { name: "start", description: "Start a stopped/ignored preview, or create one." }, - { name: "logs", description: "Post the last 50 lines of logs as a comment." }, - { name: "ignore", description: "Ignore this PR — skip future pushes and commands." }, + { name: "start", description: "Start a stopped or ignored preview." }, + { name: "logs", description: "Post the last 50 log lines." }, + { name: "ignore", description: "Ignore future pushes and commands for this PR." }, ]; function CommandToggles({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) { @@ -343,27 +579,27 @@ function CommandToggles({ value, onChange }: { value: string[]; onChange: (v: st else if (!value.includes(name)) onChange([...value, name]); }; return ( -
- -

- Control which /pp commands reviewers can run from PR comments. /pp help is always available. +

+ {CONFIGURABLE_COMMANDS.map(cmd => { + const enabled = !value.includes(cmd.name); + return ( + + ); + })} +

+ /pp help is always enabled.

-
- {CONFIGURABLE_COMMANDS.map(cmd => { - const enabled = !value.includes(cmd.name); - return ( - - ); - })} -
); } @@ -379,34 +615,55 @@ function EnvVarsEditor({ value, onChange }: { value: Record; onC } onChange(next); }; - const removeEntry = (k: string) => { + const removeEntry = (key: string) => { const next = { ...value }; - delete next[k]; + delete next[key]; onChange(next); }; return ( -
- -
- {entries.map(([k, v], i) => ( -
- updateEntry(k, e.target.value, v)} - className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} /> - updateEntry(k, k, e.target.value)} - className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} /> - -
- ))} - -
+
+ {entries.length === 0 && ( +
+ No environment variables configured. +
+ )} + {entries.map(([key, val], i) => ( +
+ updateEntry(key, e.target.value, val)} + className={`${inputCls} font-mono text-xs`} + /> + updateEntry(key, key, e.target.value)} + className={`${inputCls} font-mono text-xs`} + /> + +
+ ))} +
); } -const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1"; +const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1.5"; const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";