feat(ui): enhance RepoConfig with new sections and improved state management
This commit is contained in:
@@ -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!
|
||||
|
||||
+477
-220
@@ -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<string, string>;
|
||||
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<string, string>,
|
||||
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<any>(defaultConfig());
|
||||
const [config, setConfig] = useState<RepoConfigState>(defaultConfig());
|
||||
const [aptPackagesInput, setAptPackagesInput] = useState("");
|
||||
const [activeSection, setActiveSection] = useState<SectionKey>("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 = <K extends keyof RepoConfigState>(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 (
|
||||
<div className="max-w-3xl space-y-3">
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<div className="h-8 w-64 bg-gray-100 dark:bg-slate-800 rounded animate-pulse" />
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.85fr)]">
|
||||
<div className="space-y-2">
|
||||
{[...Array(7)].map((_, i) => <div key={i} className="h-20 bg-gray-100 dark:bg-slate-800 rounded-lg animate-pulse" />)}
|
||||
</div>
|
||||
<div className="h-96 bg-gray-100 dark:bg-slate-800 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="max-w-6xl space-y-5">
|
||||
<div>
|
||||
<Link to="/repos" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"><ArrowLeft size={14} /> Back to Repos</Link>
|
||||
<Link to="/repos" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<ArrowLeft size={14} /> Repositories
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Configure Repo</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono">{owner}/{repo}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold">Repo Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono truncate">{owner}/{repo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Instance Type</label>
|
||||
<select
|
||||
value={isPresetInstanceType(config.instanceType) ? config.instanceType : "custom"}
|
||||
onChange={e => update("instanceType", e.target.value === "custom" ? "" : e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} — {t.cost}</option>)}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
{!isPresetInstanceType(config.instanceType) && (
|
||||
<input type="text" value={config.instanceType} placeholder="Custom instance type" className={`${inputCls} mt-1`}
|
||||
onChange={e => update("instanceType", normalizeInstanceType(e.target.value, ""))} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>App Port (default: 3000)</label>
|
||||
<input type="number" value={config.port} onChange={e => update("port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
|
||||
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
|
||||
onChange={e => update("inactivityHours", Number(e.target.value))}
|
||||
className="w-full mt-1" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>0.5h</span><span>12h</span><span>72h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>Deny List (comma-separated usernames)</label>
|
||||
<input type="text"
|
||||
value={config.denyList.join(", ")}
|
||||
onChange={e => update("denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
|
||||
className={inputCls}
|
||||
placeholder="dependabot, renovate-bot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>Preinstall Options</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{PREINSTALL_OPTIONS.map(option => {
|
||||
const checked = (config.preinstallTools || []).includes(option.value);
|
||||
const locked = option.value === "docker" && config.useDockerCompose;
|
||||
return (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-slate-700 px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={locked}
|
||||
onChange={e => togglePreinstall(option.value, e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(config.preinstallTools || []).includes("node") && (
|
||||
<div className="mt-3">
|
||||
<label className={labelCls}>Node Version</label>
|
||||
<input type="text"
|
||||
value={config.nodeVersion || "lts/*"}
|
||||
onChange={e => update("nodeVersion", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="lts/*, 22, 20.11.1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>Additional Apt Packages (space-separated)</label>
|
||||
<input type="text"
|
||||
value={aptPackagesInput}
|
||||
onChange={e => {
|
||||
setAptPackagesInput(e.target.value);
|
||||
update("aptPackages", parseAptPackages(e.target.value));
|
||||
}}
|
||||
className={inputCls}
|
||||
placeholder="ffmpeg libpq-dev"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input type="checkbox" id="compose" checked={config.useDockerCompose}
|
||||
onChange={e => setDockerCompose(e.target.checked)} className="rounded" />
|
||||
<label htmlFor="compose" className="text-sm font-medium">Use Docker Compose</label>
|
||||
</div>
|
||||
|
||||
{config.useDockerCompose ? (
|
||||
<div>
|
||||
<label className={labelCls}>Compose File Path</label>
|
||||
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
|
||||
onChange={e => update("composeFilePath", e.target.value)}
|
||||
className={inputCls} placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<CommandList label="Build Commands" value={config.buildCommands}
|
||||
onChange={v => update("buildCommands", v)} />
|
||||
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
|
||||
onChange={v => update("postBuildCommands", v)} />
|
||||
<div>
|
||||
<label className={labelCls}>Run Command</label>
|
||||
<input type="text" value={config.runCommand || ""}
|
||||
onChange={e => update("runCommand", e.target.value)}
|
||||
className={inputCls} placeholder="python app.py, ./server, pnpm start" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommandList label="Setup Commands (run once on first provision)"
|
||||
value={config.setupCommands}
|
||||
onChange={v => update("setupCommands", v)} />
|
||||
|
||||
<EnvVarsEditor value={config.envVars || {}}
|
||||
onChange={v => update("envVars", v)} />
|
||||
|
||||
<CommandToggles value={config.disabledCommands || []}
|
||||
onChange={v => update("disabledCommands", v)} />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate("/repos")}
|
||||
className="px-5 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
|
||||
className="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Config"}
|
||||
<Save size={16} /> {saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.85fr)]">
|
||||
<section className="space-y-2">
|
||||
{sections.map(section => {
|
||||
const selected = section.key === activeSection;
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.key)}
|
||||
className={`w-full rounded-lg border p-4 text-left transition-colors ${
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-800/80"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 rounded-md border p-2 ${
|
||||
selected
|
||||
? "border-blue-200 bg-white text-blue-600 dark:border-blue-800 dark:bg-slate-900 dark:text-blue-300"
|
||||
: "border-gray-200 bg-gray-50 text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
|
||||
}`}>
|
||||
{section.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-sm">{section.title}</span>
|
||||
<ChevronRight size={16} className={selected ? "text-blue-500" : "text-gray-400"} />
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-gray-500 dark:text-slate-400">{section.summary}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<span className="rounded-md border border-gray-200 bg-gray-50 p-2 text-gray-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
{currentSection.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-semibold">{currentSection.title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">{currentSection.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSection === "compute" && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Instance type">
|
||||
<select
|
||||
value={isPresetInstanceType(config.instanceType) ? config.instanceType : "custom"}
|
||||
onChange={e => update("instanceType", e.target.value === "custom" ? "" : e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} - {t.cost}</option>)}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
{!isPresetInstanceType(config.instanceType) && (
|
||||
<input
|
||||
type="text"
|
||||
value={config.instanceType}
|
||||
placeholder="Custom instance type"
|
||||
className={`${inputCls} mt-2`}
|
||||
onChange={e => update("instanceType", normalizeInstanceType(e.target.value, ""))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={`Inactivity timer: ${config.inactivityHours}h`}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={72}
|
||||
step={0.5}
|
||||
value={config.inactivityHours}
|
||||
onChange={e => update("inactivityHours", Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<span>0.5h</span><span>12h</span><span>72h</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Preview port">
|
||||
<input
|
||||
type="number"
|
||||
value={config.port}
|
||||
onChange={e => update("port", Number(e.target.value))}
|
||||
className={inputCls}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "runtime" && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Preinstall tools">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{PREINSTALL_OPTIONS.map(option => {
|
||||
const checked = config.preinstallTools.includes(option.value);
|
||||
const locked = option.value === "docker" && config.useDockerCompose;
|
||||
return (
|
||||
<label key={option.value} className="flex min-h-11 items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={locked}
|
||||
onChange={e => togglePreinstall(option.value, e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
{config.preinstallTools.includes("node") && (
|
||||
<Field label="Node version">
|
||||
<input
|
||||
type="text"
|
||||
value={config.nodeVersion || "lts/*"}
|
||||
onChange={e => update("nodeVersion", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="lts/*, 22, 20.11.1"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Additional apt packages">
|
||||
<input
|
||||
type="text"
|
||||
value={aptPackagesInput}
|
||||
onChange={e => {
|
||||
setAptPackagesInput(e.target.value);
|
||||
update("aptPackages", parseAptPackages(e.target.value));
|
||||
}}
|
||||
className={inputCls}
|
||||
placeholder="ffmpeg libpq-dev"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "docker" && (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border border-gray-200 p-3 dark:border-slate-700">
|
||||
<span>
|
||||
<span className="block text-sm font-medium">Use Docker Compose</span>
|
||||
<span className="block text-sm text-gray-500 dark:text-slate-400">Run the preview with compose instead of build and run commands.</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.useDockerCompose}
|
||||
onChange={e => setDockerCompose(e.target.checked)}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
</label>
|
||||
{config.useDockerCompose && (
|
||||
<Field label="Compose file path">
|
||||
<input
|
||||
type="text"
|
||||
value={config.composeFilePath || "docker-compose.yml"}
|
||||
onChange={e => update("composeFilePath", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="docker-compose.yml"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "commands" && (
|
||||
<div className="space-y-4">
|
||||
<CommandList label="Setup commands" value={config.setupCommands} onChange={v => update("setupCommands", v)} placeholder="apt-get update" />
|
||||
{!config.useDockerCompose && (
|
||||
<>
|
||||
<CommandList label="Build commands" value={config.buildCommands} onChange={v => update("buildCommands", v)} placeholder="pnpm install && pnpm build" />
|
||||
<CommandList label="Post-build commands" value={config.postBuildCommands} onChange={v => update("postBuildCommands", v)} placeholder="prisma migrate deploy" />
|
||||
<Field label="Run command">
|
||||
<input
|
||||
type="text"
|
||||
value={config.runCommand || ""}
|
||||
onChange={e => update("runCommand", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="python app.py, ./server, pnpm start"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{config.useDockerCompose && (
|
||||
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300">
|
||||
Docker Compose mode uses the compose file path from the Docker section.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "environment" && (
|
||||
<EnvVarsEditor value={config.envVars || {}} onChange={v => update("envVars", v)} />
|
||||
)}
|
||||
|
||||
{activeSection === "access" && (
|
||||
<Field label="Deny list">
|
||||
<input
|
||||
type="text"
|
||||
value={config.denyList.join(", ")}
|
||||
onChange={e => update("denyList", e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
|
||||
className={inputCls}
|
||||
placeholder="dependabot, renovate-bot"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{activeSection === "commentCommands" && (
|
||||
<CommandToggles value={config.disabledCommands || []} onChange={v => update("disabledCommands", v)} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: <Server size={18} />,
|
||||
},
|
||||
{
|
||||
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: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
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: <Container size={18} />,
|
||||
},
|
||||
{
|
||||
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: <Terminal size={18} />,
|
||||
},
|
||||
{
|
||||
key: "environment" as const,
|
||||
title: "Environment Variables",
|
||||
description: "Variables passed to the preview app.",
|
||||
summary: `${Object.keys(config.envVars || {}).filter(Boolean).length} variables`,
|
||||
icon: <KeyRound size={18} />,
|
||||
},
|
||||
{
|
||||
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: <Shield size={18} />,
|
||||
},
|
||||
{
|
||||
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: <MessageSquare size={18} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className={labelCls}>{label}</label>
|
||||
<div className="space-y-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="space-y-2">
|
||||
{value.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-sm text-gray-500 dark:border-slate-600 dark:text-slate-400">
|
||||
No commands configured.
|
||||
</div>
|
||||
)}
|
||||
{value.map((cmd, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input type="text" value={cmd}
|
||||
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
|
||||
className={inputCls} placeholder="make build" />
|
||||
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
|
||||
<input
|
||||
type="text"
|
||||
value={cmd}
|
||||
onChange={e => {
|
||||
const next = [...value];
|
||||
next[i] = e.target.value;
|
||||
onChange(next);
|
||||
}}
|
||||
className={inputCls}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter((_, j) => j !== i))}
|
||||
className="rounded-lg px-2 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
|
||||
title="Remove command"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => onChange([...value, ""])} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...value, ""])}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Plus size={14} /> Add command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label className={labelCls}>PR Comment Commands</label>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mb-2">
|
||||
Control which <code>/pp</code> commands reviewers can run from PR comments. <code>/pp help</code> is always available.
|
||||
<div className="space-y-2">
|
||||
{CONFIGURABLE_COMMANDS.map(cmd => {
|
||||
const enabled = !value.includes(cmd.name);
|
||||
return (
|
||||
<label key={cmd.name} className="flex items-start justify-between gap-4 rounded-lg border border-gray-200 p-3 dark:border-slate-700">
|
||||
<span className="min-w-0">
|
||||
<code className="font-mono text-sm">/pp {cmd.name}</code>
|
||||
<span className="mt-1 block text-sm text-gray-500 dark:text-slate-400">{cmd.description}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => toggle(cmd.name, e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
<code>/pp help</code> is always enabled.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{CONFIGURABLE_COMMANDS.map(cmd => {
|
||||
const enabled = !value.includes(cmd.name);
|
||||
return (
|
||||
<label key={cmd.name} className="flex items-start gap-2 py-1 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled}
|
||||
onChange={e => toggle(cmd.name, e.target.checked)}
|
||||
className="rounded mt-0.5" />
|
||||
<span className="text-sm">
|
||||
<code className="font-mono">/pp {cmd.name}</code>
|
||||
<span className="text-gray-500 dark:text-slate-400"> — {cmd.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -379,34 +615,55 @@ function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onC
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
const removeEntry = (k: string) => {
|
||||
const removeEntry = (key: string) => {
|
||||
const next = { ...value };
|
||||
delete next[k];
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>Environment Variables</label>
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, v], i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input type="text" value={k} placeholder="KEY"
|
||||
onChange={e => updateEntry(k, e.target.value, v)}
|
||||
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
|
||||
<input type="password" value={v} placeholder="value"
|
||||
onChange={e => updateEntry(k, k, e.target.value)}
|
||||
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
|
||||
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addEntry} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<Plus size={14} /> Add variable
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{entries.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-sm text-gray-500 dark:border-slate-600 dark:text-slate-400">
|
||||
No environment variables configured.
|
||||
</div>
|
||||
)}
|
||||
{entries.map(([key, val], i) => (
|
||||
<div key={i} className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
placeholder="KEY"
|
||||
onChange={e => updateEntry(key, e.target.value, val)}
|
||||
className={`${inputCls} font-mono text-xs`}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={val}
|
||||
placeholder="value"
|
||||
onChange={e => updateEntry(key, key, e.target.value)}
|
||||
className={`${inputCls} font-mono text-xs`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(key)}
|
||||
className="rounded-lg px-2 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
|
||||
title="Remove variable"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEntry}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Plus size={14} /> Add variable
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user