feat(ui): enhance RepoConfig with new sections and improved state management
This commit is contained in:
+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