diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts index 264174c..363c38f 100644 --- a/apps/agent/src/config.ts +++ b/apps/agent/src/config.ts @@ -24,4 +24,4 @@ export const config = { export const requireControlPlaneUrl = () => { if (!config.controlPlaneUrl) throw new Error("DEQUEL_CONTROL_PLANE is required"); return config.controlPlaneUrl; -}; \ No newline at end of file +}; diff --git a/apps/agent/src/executor.ts b/apps/agent/src/executor.ts index ccf478e..89da0a6 100644 --- a/apps/agent/src/executor.ts +++ b/apps/agent/src/executor.ts @@ -417,4 +417,4 @@ const applyRoute = async (payload: RemoteRoutePayload, signal: AbortSignal): Pro return { routeFile: payload.routeFile, status: payload.action === "add" ? "active" : "removed" }; }; -export const validateRoutePayload = validateRoutePayloadImpl; \ No newline at end of file +export const validateRoutePayload = validateRoutePayloadImpl; diff --git a/apps/agent/src/protocol.ts b/apps/agent/src/protocol.ts index 59f14e4..dfad720 100644 --- a/apps/agent/src/protocol.ts +++ b/apps/agent/src/protocol.ts @@ -55,4 +55,4 @@ export const parseP2PResponse = (raw: unknown): P2PResponse | null => { } catch { return null; } -}; \ No newline at end of file +}; diff --git a/apps/agent/src/stats.ts b/apps/agent/src/stats.ts index a3737d0..5f90c07 100644 --- a/apps/agent/src/stats.ts +++ b/apps/agent/src/stats.ts @@ -55,4 +55,4 @@ const parseMemToMb = (mem: string): number => { case "KiB": case "KB": return val / 1024; default: return val; } -}; \ No newline at end of file +}; diff --git a/apps/api/src/agents/job-channel.ts b/apps/api/src/agents/job-channel.ts index 1f8c50d..aa7e84d 100644 --- a/apps/api/src/agents/job-channel.ts +++ b/apps/api/src/agents/job-channel.ts @@ -119,4 +119,4 @@ export const processAgentJobUpdate = async (serverId: string, update: Exclude { const job = await leaseNextAgentJob(serverId); return { jobs: job ? [job] : [], cancelJobIds: await listCancelledJobIds(serverId) }; -}; \ No newline at end of file +}; diff --git a/apps/api/src/agents/stats-cache.ts b/apps/api/src/agents/stats-cache.ts index ebe84e2..9574e2d 100644 --- a/apps/api/src/agents/stats-cache.ts +++ b/apps/api/src/agents/stats-cache.ts @@ -48,4 +48,4 @@ class AgentStatsCache { } } -export const agentStatsCache = new AgentStatsCache(); \ No newline at end of file +export const agentStatsCache = new AgentStatsCache(); diff --git a/apps/api/src/executors/agent.ts b/apps/api/src/executors/agent.ts index 2d38968..c19487c 100644 --- a/apps/api/src/executors/agent.ts +++ b/apps/api/src/executors/agent.ts @@ -100,4 +100,4 @@ export const agentExecutor: DeploymentExecutor = { await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); await appendLog(deployment.id, "system", "Deployment cancelled by user"); }, -}; \ No newline at end of file +}; diff --git a/apps/api/src/executors/ssh.ts b/apps/api/src/executors/ssh.ts index 5d097af..333fb8b 100644 --- a/apps/api/src/executors/ssh.ts +++ b/apps/api/src/executors/ssh.ts @@ -177,4 +177,4 @@ export const sshExecutor: DeploymentExecutor = { await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); await emitLog(deployment.id, "system", "Deployment cancelled by user (remote build may continue on the server)"); }, -}; \ No newline at end of file +}; diff --git a/apps/api/src/utils/validate.ts b/apps/api/src/utils/validate.ts index 6d7ee93..c35bce7 100644 --- a/apps/api/src/utils/validate.ts +++ b/apps/api/src/utils/validate.ts @@ -33,7 +33,7 @@ export const validateComposeServiceMapping = (mapping: unknown): string | null = if (typeof m.serviceName !== "string" || !SERVICE_NAME_RE.test(m.serviceName)) { return "serviceName may only contain letters, numbers, underscores and hyphens"; } - if (m.subdomain !== undefined && m.subdomain !== null) { + if (m.subdomain !== undefined && m.subdomain !== null && m.subdomain !== "") { if (typeof m.subdomain !== "string" || !SUBDOMAIN_RE.test(m.subdomain)) { return "subdomain must be a valid hostname label (lowercase letters, numbers, hyphens)"; } diff --git a/apps/web/src/components/databases/DatabaseCard.tsx b/apps/web/src/components/databases/DatabaseCard.tsx index 5be4db1..ce9c422 100644 --- a/apps/web/src/components/databases/DatabaseCard.tsx +++ b/apps/web/src/components/databases/DatabaseCard.tsx @@ -67,7 +67,7 @@ export function DatabaseCard({ database, project, onChanged, onDelete }: Databas -
+

Internal endpoint

{database.internalHost}:{database.internalPort}

@@ -92,20 +92,20 @@ export function DatabaseCard({ database, project, onChanged, onDelete }: Databas
-

+

{credentials?.externalConnectionString ?? credentials?.internalConnectionString ?? "Credentials hidden"}

- - {credentials && } + {credentials && }
{credentialsError &&

{credentialsError}

}
-
+
{database.status === "stopped" ? ( ) : database.status === "failed" ? ( diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index cce94ce..d1d09ba 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -15,37 +15,39 @@ export function Header({ setSidebarOpen, }: HeaderProps) { return ( -
- +
+
+ - - Workspace - - - {currentProject ? ( - <> - - {currentProject.name} - - - - {new URLSearchParams(location.search).get("tab") || "deployments"} - - - ) : ( - Dashboard - )} + + Workspace + + + {currentProject ? ( + <> + + {currentProject.name} + + + + {new URLSearchParams(location.search).get("tab") || "deployments"} + + + ) : ( + Dashboard + )} +
); } diff --git a/apps/web/src/components/project/alerts/AlertsTab.tsx b/apps/web/src/components/project/alerts/AlertsTab.tsx index 1f5b2b0..85f6bda 100644 --- a/apps/web/src/components/project/alerts/AlertsTab.tsx +++ b/apps/web/src/components/project/alerts/AlertsTab.tsx @@ -117,7 +117,7 @@ export function AlertsTab({ projectId }: AlertsTabProps) {
) : (
-
+

Alert Rules @@ -129,7 +129,7 @@ export function AlertsTab({ projectId }: AlertsTabProps) {

@@ -141,15 +141,15 @@ export function AlertsTab({ projectId }: AlertsTabProps) { return (
-
-
+
+
-
+
- + {a.type.replace( "_", " ", @@ -162,7 +162,7 @@ export function AlertsTab({ projectId }: AlertsTabProps) { {a.channel}
-

+

Triggers when{" "} {a.type.replace( @@ -178,7 +178,7 @@ export function AlertsTab({ projectId }: AlertsTabProps) {

-
+
diff --git a/apps/web/src/components/project/deployments/DeploymentsTab.tsx b/apps/web/src/components/project/deployments/DeploymentsTab.tsx index 3787779..58d6465 100644 --- a/apps/web/src/components/project/deployments/DeploymentsTab.tsx +++ b/apps/web/src/components/project/deployments/DeploymentsTab.tsx @@ -16,6 +16,7 @@ import { Rocket, Play, Webhook } from "lucide-react"; import { ManualDeployDialog } from "./manual-deploy-dialog"; import { DeploymentHistory } from "./deployment-history"; import { ClearCacheToggle } from "./clear-cache-toggle"; +import { SwitchToGitCard } from "./SwitchToGitCard"; const PAGE_SIZE = 5; @@ -280,12 +281,12 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { {sourceType === "git" ? (
-
-
+
+
Repository URL
-
{project?.repoUrl || "No repository configured"}
+
{project?.repoUrl || "No repository configured"}
-
+
Branch
{project?.repoBranch || "main"} @@ -295,7 +296,7 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { {webhookError && (

{webhookError}

)} -
+
{webhookChecked && ( @@ -331,7 +332,7 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { onSubmit={handleDeploy} className="space-y-3" > -
+
{( [ "git", @@ -380,7 +381,7 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { onChange={setClearCache} id="clearCacheUpload" /> -
+
{createDeployment.isPending || isAutoDeploying ? ( @@ -428,71 +430,15 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { {showGitSwitch && ( - - - - Switch deployment - source to Git? - - - -

- Enter the git repository - URL to create a new - deployment from source. -

- - setSwitchGitUrl( - e.target.value, - ) - } - className="text-sm" - /> - - setSwitchBranch( - e.target.value, - ) - } - className="text-sm" - /> -
- - -
-
-
+ setShowGitSwitch(false)} + onSwitch={handleSwitchToGit} + isPending={createDeployment.isPending || isAutoDeploying} + /> )} {totalDeployments > 0 && ( diff --git a/apps/web/src/components/project/deployments/SwitchToGitCard.tsx b/apps/web/src/components/project/deployments/SwitchToGitCard.tsx new file mode 100644 index 0000000..12ba840 --- /dev/null +++ b/apps/web/src/components/project/deployments/SwitchToGitCard.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; + +interface SwitchToGitCardProps { + switchGitUrl: string; + setSwitchGitUrl: (v: string) => void; + switchBranch: string; + setSwitchBranch: (v: string) => void; + onCancel: () => void; + onSwitch: () => void; + isPending: boolean; +} + +export function SwitchToGitCard({ + switchGitUrl, + setSwitchGitUrl, + switchBranch, + setSwitchBranch, + onCancel, + onSwitch, + isPending, +}: SwitchToGitCardProps) { + return ( + + + + Switch deployment source to Git? + + + +

+ Enter the git repository URL to create a new deployment from source. +

+ setSwitchGitUrl(e.target.value)} + className="text-sm" + /> + setSwitchBranch(e.target.value)} + className="text-sm" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/project/deployments/deployment-history.tsx b/apps/web/src/components/project/deployments/deployment-history.tsx index d3e8d3c..498df71 100644 --- a/apps/web/src/components/project/deployments/deployment-history.tsx +++ b/apps/web/src/components/project/deployments/deployment-history.tsx @@ -51,112 +51,208 @@ export function DeploymentHistory({ return ( <> -
- - - - Status - Deployment - Source - Branch - Duration - Age - - - - - {deployments.map((dep) => ( - - onSelect(selectedId === dep.id ? null : dep.id) - } - > - -
- - {dep.status === "running" && ( - - Active - - )} -
-
- +
+ {/* Mobile Card List View (< md) */} +
+ {deployments.map((dep) => ( +
+ onSelect(selectedId === dep.id ? null : dep.id) + } + className={cn( + "p-3.5 space-y-2.5 cursor-pointer transition-all active:bg-primary/10", + selectedId === dep.id && "bg-primary/5 border-l-2 border-l-amber-500" + )} + > +
+
+ + {dep.status === "running" && ( + + Active + + )} +
+
e.stopPropagation()}> + {dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( + + )} + {(dep.status === "pending" || dep.status === "building") && ( + + )} + {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( + + )} + {dep.status !== "running" && ( + + )} +
+
+ +
+ {depDisplayName(projectName, dep.id)} - - - {dep.sourceType} - - + + + {formatTimeAgo(dep.createdAt)} + +
+ +
+
{dep.branch ? ( - {dep.branch} - ) : ( - + {dep.branch} + ) : null} + {dep.sourceType} +
+ +
+
+ ))} +
+ + {/* Desktop Table View (>= md) */} +
+
+ + + Status + Deployment + Source + Branch + Duration + Age + + + + + {deployments.map((dep) => ( + - - - - - {formatTimeAgo(dep.createdAt)} - - -
- {dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( - - )} - {(dep.status === "pending" || dep.status === "building") && ( - + onClick={() => + onSelect(selectedId === dep.id ? null : dep.id) + } + > + +
+ + {dep.status === "running" && ( + + Active + + )} +
+
+ + {depDisplayName(projectName, dep.id)} + + + {dep.sourceType} + + + {dep.branch ? ( + {dep.branch} + ) : ( + )} - {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( - - )} - {dep.status !== "running" && ( - - )} -
-
-
- ))} -
-
+ + + + + + {formatTimeAgo(dep.createdAt)} + + +
+ {dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( + + )} + {(dep.status === "pending" || dep.status === "building") && ( + + )} + {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( + + )} + {dep.status !== "running" && ( + + )} +
+
+ + ))} + + +
+ {totalPages > 1 && (
diff --git a/apps/web/src/components/project/deployments/deployment-logs.tsx b/apps/web/src/components/project/deployments/deployment-logs.tsx index 1cc2bb3..48d1ffa 100644 --- a/apps/web/src/components/project/deployments/deployment-logs.tsx +++ b/apps/web/src/components/project/deployments/deployment-logs.tsx @@ -144,26 +144,37 @@ export function DeploymentLogs({ }); }, [logs]); + const [copied, setCopied] = useState(false); + const copyAllLogs = () => { + const fullText = logs.map(l => `[${l.stage}]-[${fmtLogTs((l as any).timestamp || l.createdAt)}] ${l.message}`).join("\n"); + navigator.clipboard.writeText(fullText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return ( - - - - Build Logs —{" "} - {deployment.id.slice( - 0, - 8, - )} - - - Duration: - + + + + Build Logs — {deployment.id.slice(0, 8)} +
+ + Duration: + + + {logs.length > 0 && ( + + )} +
diff --git a/apps/web/src/components/project/domains/AddDomainDialog.tsx b/apps/web/src/components/project/domains/AddDomainDialog.tsx new file mode 100644 index 0000000..ca719a2 --- /dev/null +++ b/apps/web/src/components/project/domains/AddDomainDialog.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "../../ui/dialog"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; + +interface AddDomainDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + domain: string; + setDomain: (v: string) => void; + targetService: string; + setTargetService: (v: string) => void; + targetPort: string; + setTargetPort: (v: string) => void; + isCompose: boolean; + isAdding: boolean; + onAdd: (e: React.FormEvent) => Promise; +} + +export function AddDomainDialog({ + open, + onOpenChange, + domain, + setDomain, + targetService, + setTargetService, + targetPort, + setTargetPort, + isCompose, + isAdding, + onAdd, +}: AddDomainDialogProps) { + return ( + + + + + Add Custom Domain + + + Attach domain endpoints to route external web requests to your container proxy. + + +
+
+ + setDomain(e.target.value)} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg font-mono" + required + /> +
+ + {isCompose && ( +
+
+ + setTargetService(e.target.value)} + className="h-9 bg-[#0d0d11] border-input text-xs font-mono rounded-lg" + /> +
+
+ + setTargetPort(e.target.value)} + className="h-9 bg-[#0d0d11] border-input text-xs font-mono rounded-lg" + /> +
+
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/project/domains/DeleteDomainDialog.tsx b/apps/web/src/components/project/domains/DeleteDomainDialog.tsx new file mode 100644 index 0000000..1ae53dc --- /dev/null +++ b/apps/web/src/components/project/domains/DeleteDomainDialog.tsx @@ -0,0 +1,50 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../../ui/dialog"; +import { Button } from "../../ui/button"; + +interface DeleteDomainDialogProps { + domain: string | undefined; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => Promise; +} + +export function DeleteDomainDialog({ + domain, + open, + onOpenChange, + onConfirm, +}: DeleteDomainDialogProps) { + return ( + + + + + Remove Domain + + + Are you sure you want to remove domain{" "} + + {domain} + + ? This will stop routing incoming traffic from this path to your running container service. + + + + + + + + + ); +} diff --git a/apps/web/src/components/project/domains/DnsInstructionsCard.tsx b/apps/web/src/components/project/domains/DnsInstructionsCard.tsx new file mode 100644 index 0000000..0368a9d --- /dev/null +++ b/apps/web/src/components/project/domains/DnsInstructionsCard.tsx @@ -0,0 +1,161 @@ +import { Card, CardContent } from "../../ui/card"; +import { Button } from "../../ui/button"; +import { X, Globe, Copy, Check } from "lucide-react"; + +interface DnsInstructionsCardProps { + lastAdded: string; + onDismiss: () => void; + hasBaseDomain: boolean; + dnsName: string; + baseDomain: string; + serverIp: { ip: string } | undefined; + copiedTarget: boolean; + onCopy: (text: string) => void; +} + +export function DnsInstructionsCard({ + lastAdded, + onDismiss, + hasBaseDomain, + dnsName, + baseDomain, + serverIp, + copiedTarget, + onCopy, +}: DnsInstructionsCardProps) { + return ( + + + +
+ +
+

+ DNS Configuration Required for{" "} + + {lastAdded} + +

+ {hasBaseDomain ? ( + <> +

+ Add a CNAME record at your DNS domain registrar: +

+
+ + + + + + + + + + + + + + + +
+ Type + + Host/Name + + Target/Value +
+ CNAME + + {dnsName} + + + {baseDomain} + + +
+
+

+ Or point an A record directly to target IP:{" "} + + {serverIp?.ip ?? "../.."} + +

+ + ) : ( + <> +

+ Add an A record at your DNS domain registrar: +

+
+ + + + + + + + + + + + + + + +
+ Type + + Host/Name + + Value +
+ A + + {dnsName} + + + {serverIp?.ip ?? "../.."} + + {serverIp?.ip && ( + + )} +
+
+ + )} +

+ DNS changes can take up to 48 hours to propagate globally. Dequel will validate SSL cert status automatically. +

+
+
+
+
+ ); +} diff --git a/apps/web/src/components/project/domains/DomainsTab.tsx b/apps/web/src/components/project/domains/DomainsTab.tsx index 229d074..71d90d9 100644 --- a/apps/web/src/components/project/domains/DomainsTab.tsx +++ b/apps/web/src/components/project/domains/DomainsTab.tsx @@ -11,7 +11,6 @@ import { TableHeader, TableRow, } from "../../ui/table"; -import { Input } from "../../ui/input"; import { Button } from "../../ui/button"; import { Badge } from "../../ui/badge"; import { StatusBadge } from "../../StatusBadge"; @@ -23,14 +22,9 @@ import { Copy, Check, } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "../../ui/dialog"; +import { AddDomainDialog } from "./AddDomainDialog"; +import { DeleteDomainDialog } from "./DeleteDomainDialog"; +import { DnsInstructionsCard } from "./DnsInstructionsCard"; interface DomainsTabProps { projectId: string; @@ -155,23 +149,20 @@ export function DomainsTab({
) : (
-
+

Domains

- Domains and - subdomains linked - to project - endpoints. + Domains and subdomains linked to project endpoints.

{lastAdded && ( - - - -
- -
-

- DNS - Configuration - Required - for{" "} - - { - lastAdded - } - -

- {hasBaseDomain ? ( - <> -

- Add - a - CNAME - record - at - your - DNS - domain - registrar: -

-
- - - - - - - - - - - - - - - -
- Type - - Host/Name - - Target/Value -
- CNAME - - { - dnsName - } - - - { - baseDomain - } - - -
-
-

- Or - point - an - A - record - directly - to - target - IP:{" "} - - {serverIp?.ip ?? - "../.."} - -

- - ) : ( - <> -

- Add - an - A - record - at - your - DNS - domain - registrar: -

-
- - - - - - - - - - - - - - - -
- Type - - Host/Name - - Value -
- A - - { - dnsName - } - - - {serverIp?.ip ?? - "../.."} - - {serverIp?.ip && ( - - )} -
-
- + setLastAdded("")} + hasBaseDomain={hasBaseDomain} + dnsName={dnsName} + baseDomain={baseDomain} + serverIp={serverIp} + copiedTarget={copiedTarget} + onCopy={copyText} + /> + )} + +
+ {/* Mobile Card List View (< md) */} +
+ {domains.map((d) => { + const st = domainStatuses?.find((s) => s.domain === d.domain); + return ( +
+
+ + {d.domain} + + +
+
+ + {d.type} + + + +
+ {st && ( +
+ + + + DNS {st.dnsOk ? "Resolved" : "Unresolved"} + + + + + + TLS {st.tlsOk ? "Secured" : "Pending"} + + +
)} -

- DNS - changes - can - take - up to - 48 - hours - to - propagate - globally. - Dequel - will - validate - ssl - cert - status - automatically. -

-
- - - )} + ); + })} +
-
- - - - - Domain - - - Type - - - Validation - - - SSL - - - DNS - - - TLS - - - - - - {domains.map( - (d) => ( - - - { - d.domain + {/* Desktop Table View (>= md) */} +
+
+ + + + Domain + + + Type + + + Validation + + + SSL + + + DNS + + + TLS + + + + + + {domains.map( + (d) => ( + - - + className="border-border hover:bg-[#121217]/30 group transition-all" + > + { - d.type - } - - - - - - - - - {(() => { - const st = domainStatuses?.find( - (s) => s.domain === d.domain, - ); - return ( - <> - - {st ? ( - - - - {st.dnsOk ? "Resolved" : "Unresolved"} + + + + { + d.type + } + + + + + + + + + {(() => { + const st = domainStatuses?.find( + (s) => s.domain === d.domain, + ); + return ( + <> + + {st ? ( + + + + {st.dnsOk ? "Resolved" : "Unresolved"} + - - ) : ( - - )} - - - {st ? ( - - - - {st.tlsOk ? "Secured" : "Pending"} + ) : ( + + )} + + + {st ? ( + + + + {st.tlsOk ? "Secured" : "Pending"} + - - ) : ( - - )} - - - ); - })()} - - - - - ), - )} - -
+ ) : ( + + )} + + + ); + })()} + + + + + ), + )} + + +
)} {/* Add Domain Dialog */} - - - - - Add Custom Domain - - - Attach domain - endpoints to route - external web requests - to your container - proxy. - - -
-
- - - setDomain( - e.target - .value, - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg font-mono" - required - /> -
- - {(project as any)?.buildType === "compose" && ( -
-
- - setTargetService(e.target.value)} - className="h-9 bg-[#0d0d11] border-input text-xs font-mono rounded-lg" - /> -
-
- - setTargetPort(e.target.value)} - className="h-9 bg-[#0d0d11] border-input text-xs font-mono rounded-lg" - /> -
-
- )} - -
- - -
-
-
-
+ domain={domain} + setDomain={setDomain} + targetService={targetService} + setTargetService={setTargetService} + targetPort={targetPort} + setTargetPort={setTargetPort} + isCompose={(project as any)?.buildType === "compose"} + isAdding={isAdding} + onAdd={add} + /> {/* Delete Domain Confirmation Dialog */} - - !open && - setDeletingDomId(null) - } - > - - - - Remove Domain - - - Are you sure you want - to remove domain{" "} - - { - selectedDomToDelete?.domain - } - - ? This will stop - routing incoming - traffic from this path - to your running - container service. - - - - - - - - + onOpenChange={(open) => { + if (!open) setDeletingDomId(null); + }} + onConfirm={handleDeleteDomain} + />
); } diff --git a/apps/web/src/components/project/envtab/EnvVarTable.tsx b/apps/web/src/components/project/envtab/EnvVarTable.tsx index 2c5dd58..452a0d5 100644 --- a/apps/web/src/components/project/envtab/EnvVarTable.tsx +++ b/apps/web/src/components/project/envtab/EnvVarTable.tsx @@ -1,6 +1,8 @@ import { Button } from "../../ui/button"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "../../ui/table"; -import { Plus, Upload } from "lucide-react"; +import { Plus, Upload, Lock, Pencil, Eye, EyeOff, Copy, Check, Trash2 } from "lucide-react"; +import { Badge } from "../../ui/badge"; +import { Input } from "../../ui/input"; import { EnvVarRow } from "./EnvVarRow"; interface EnvVar { @@ -51,7 +53,7 @@ export function EnvVarTable({ }: EnvVarTableProps) { return (
-
+

Environment Variables @@ -60,63 +62,176 @@ export function EnvVarTable({ Secure, encrypted settings injected into deployments.

-
+
-
- - - - - Key - - - Value - - - Environment - - - - - - {envVars.map((ev) => ( - - ))} - -
+
+ {/* Mobile Card List View (< md) */} +
+ {envVars.map((ev) => { + const isEditing = editingId === ev.id; + const isRevealed = revealValues[ev.id]; + const isRevealing = !!revealing[ev.id]; + const isSaving = !!savingEdit[ev.id]; + const isCopied = copiedId === ev.id; + + return ( +
+
+
+ + + {ev.key} + +
+ + {ev.environment || "all"} + +
+ +
+ {isEditing ? ( + onEditingValueChange(e.target.value)} + className="h-8 bg-[#09090c] border-input text-xs font-mono w-full" + autoFocus + /> + ) : ( + + {isRevealed ?? "••••••••"} + + )} +
+ +
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + )} +
+ +
+
+ ); + })} +
+ + {/* Desktop Table View (>= md) */} +
+ + + + + Key + + + Value + + + Environment + + + + + + {envVars.map((ev) => ( + + ))} + +
+
); diff --git a/apps/web/src/components/project/logs/LogDetailSheet.tsx b/apps/web/src/components/project/logs/LogDetailSheet.tsx new file mode 100644 index 0000000..d6b4742 --- /dev/null +++ b/apps/web/src/components/project/logs/LogDetailSheet.tsx @@ -0,0 +1,128 @@ +import { X } from "lucide-react"; +import { cn } from "../../../lib/utils"; + +interface LogDetailSheetProps { + selectedLog: any; + onClose: () => void; + logSource: "runtime" | "request"; +} + +export function LogDetailSheet({ + selectedLog, + onClose, + logSource, +}: LogDetailSheetProps) { + if (!selectedLog) return null; + + return ( + <> +
+
+
+

+ Log Event Details +

+ +
+ +
+ + {selectedLog.level} + +
+ +
+
+
+ Timestamp +
+
+ {new Date( + selectedLog.timestamp || selectedLog.createdAt, + ).toLocaleString()} +
+
+ + {logSource === "request" && selectedLog.status && ( +
+
+ Status +
+
+ {selectedLog.status} +
+
+ )} + + {logSource === "request" && selectedLog.request && ( +
+
+ Request +
+
+ {selectedLog.request} +
+
+ )} + + {logSource === "request" && selectedLog.duration && ( +
+
+ Duration +
+
+ {selectedLog.duration} +
+
+ )} + + {logSource === "request" && selectedLog.size && ( +
+
+ Size +
+
+ {selectedLog.size} +
+
+ )} + +
+
+ Message +
+
+ {selectedLog.parsedMessage} +
+
+ +
+
+ Raw JSON payload +
+
+							{selectedLog.raw}
+						
+
+
+
+ + ); +} diff --git a/apps/web/src/components/project/logs/LogsEventsTable.tsx b/apps/web/src/components/project/logs/LogsEventsTable.tsx new file mode 100644 index 0000000..13d452d --- /dev/null +++ b/apps/web/src/components/project/logs/LogsEventsTable.tsx @@ -0,0 +1,120 @@ +import { cn } from "../../../lib/utils"; + +interface LogsEventsTableProps { + logSource: "runtime" | "request"; + isLoading: boolean; + filteredLogs: any[]; + selectedLog: any; + onSelectLog: (log: any) => void; +} + +export function LogsEventsTable({ + logSource, + isLoading, + filteredLogs, + selectedLog, + onSelectLog, +}: LogsEventsTableProps) { + return ( +
+
+ + + + + + {logSource === "request" && ( + <> + + + + + )} + + + + + {isLoading ? ( + + + + ) : filteredLogs.length === 0 ? ( + + + + ) : ( + filteredLogs.map((log, idx) => ( + onSelectLog(log)} + className={cn( + "hover:bg-[#141418] cursor-pointer transition-colors border-l-2", + log.level === "error" + ? "border-l-red-500 hover:border-l-red-400" + : log.level === "warning" + ? "border-l-amber-500 hover:border-l-amber-400" + : "border-l-transparent hover:border-l-zinc-700", + selectedLog?.id === log.id && "bg-[#16161b] hover:bg-[#16161b]", + )} + > + + + {logSource === "request" && ( + <> + + + + + )} + + + )) + )} + +
TimeLevelStatusHostRequestMessage
+ Loading logs... +
+ {logSource === "request" ? "No request logs found." : "No runtime logs found."} +
+ {new Date(log.timestamp || log.createdAt).toLocaleTimeString()} + + + {log.level} + + + {log.status ? ( + = 500 + ? "bg-red-500/10 text-red-400 border border-red-500/20" + : Number(log.status) >= 400 + ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" + : "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20", + )} + > + {log.status} + + ) : ( + + )} + + {log.host} + + {log.request} + + {log.parsedMessage} +
+
+
+ ); +} diff --git a/apps/web/src/components/project/logs/LogsTab.tsx b/apps/web/src/components/project/logs/LogsTab.tsx index c22a962..92328bf 100644 --- a/apps/web/src/components/project/logs/LogsTab.tsx +++ b/apps/web/src/components/project/logs/LogsTab.tsx @@ -12,17 +12,10 @@ import { X, } from "lucide-react"; import { cn } from "../../../lib/utils"; - -function stripAnsi(str: string): string { - // Strip full ANSI escape sequences (ESC + bracket + params + terminator) - let s = str.replace( - /[\u001b\u009b]\[[\d;]*[A-Za-z]/g, - "", - ); - // Strip orphaned bracket sequences where ESC byte was already removed (e.g. "[35m", "[0m") - s = s.replace(/\[(\d+;)*\d*m/g, ""); - return s; -} +import { LogsTimelineDistribution } from "./LogsTimelineDistribution"; +import { LogsEventsTable } from "./LogsEventsTable"; +import { LogDetailSheet } from "./LogDetailSheet"; +import { parseLogEntry } from "./parseLogEntry"; interface LogsTabProps { projectId: string; @@ -95,97 +88,10 @@ export function LogsTab({ const [showError, setShowError] = useState(true); - // Drawer state const [selectedLog, setSelectedLog] = useState(null); - // Parse logs - const parsedLogs = logs.map((log) => { - let message = stripAnsi(log.message); - let level = "info"; - let status = ""; - let host = "localhost"; - let request = ""; - let duration: string | null = null; - let size: string | null = null; - let raw = log.message; - - if ( - message.startsWith("{") && - message.endsWith("}") - ) { - try { - const obj = JSON.parse(message); - if (obj.level) - level = - obj.level.toLowerCase(); - if (obj.status) { - status = String(obj.status); - const statusNum = Number( - obj.status, - ); - if (statusNum >= 500) - level = "error"; - else if (statusNum >= 400) - level = "warning"; - } - if (obj.request) { - host = - obj.request.host || host; - request = `${obj.request.method || ""} ${obj.request.uri || ""}`; - duration = obj.duration - ? `${(obj.duration * 1000).toFixed(2)}ms` - : null; - size = obj.size - ? `${obj.size} B` - : null; - message = - obj.msg || - obj.message || - obj.error || - message; - if ( - !message || - message === '""' - ) { - message = `${obj.request.method || ""} ${obj.request.uri || ""}`; - } - } else { - message = - obj.msg || - obj.message || - message; - } - raw = JSON.stringify( - obj, - null, - 2, - ); - } catch {} - } else { - const upper = message.toUpperCase(); - if ( - upper.includes("ERROR") || - upper.includes("CRITICAL") || - upper.includes("FAIL") - ) - level = "error"; - else if (upper.includes("WARN")) - level = "warning"; - } - - return { - ...log, - parsedMessage: message, - level, - status, - host, - request, - duration, - size, - raw, - }; - }); + const parsedLogs = logs.map(parseLogEntry); const filteredLogs = parsedLogs.filter( (log) => { @@ -258,10 +164,10 @@ export function LogsTab({ return (
{/* Top Filters Header */} -
-
+
+
{/* Log Source Selector */} -
+
{/* Search message */} -
+
-
+
- -
-

- Log Event Details -

- - { - selectedLog.level - } - -
- -
-
-
- Timestamp -
-
- {new Date( - ( - selectedLog as any - ) - .timestamp || - selectedLog.createdAt, - ).toLocaleString()} -
-
- - {logSource === "request" && selectedLog.status && ( -
-
- Status -
-
- {selectedLog.status} -
-
- )} - - {logSource === "request" && selectedLog.request && ( -
-
- Request -
-
- {selectedLog.request} -
-
- )} - - {logSource === "request" && selectedLog.duration && ( -
-
- Duration -
-
- {selectedLog.duration} -
-
- )} - - {logSource === "request" && selectedLog.size && ( -
-
- Size -
-
- {selectedLog.size} -
-
- )} - -
-
- Message -
-
- { - selectedLog.parsedMessage - } -
-
- -
-
- Raw JSON - payload -
-
-									{
-										selectedLog.raw
-									}
-								
-
-
-
- )} + setSelectedLog(log)} + /> + + setSelectedLog(null)} + logSource={logSource} + />
); diff --git a/apps/web/src/components/project/logs/LogsTimelineDistribution.tsx b/apps/web/src/components/project/logs/LogsTimelineDistribution.tsx new file mode 100644 index 0000000..325b30b --- /dev/null +++ b/apps/web/src/components/project/logs/LogsTimelineDistribution.tsx @@ -0,0 +1,43 @@ +interface Bin { + start: number; + end: number; + count: number; +} + +interface LogsTimelineDistributionProps { + logSource: "runtime" | "request"; + bins: Bin[]; + maxCount: number; +} + +export function LogsTimelineDistribution({ + logSource, + bins, + maxCount, +}: LogsTimelineDistributionProps) { + return ( +
+
+ {logSource === "request" + ? "Request Count Distribution (Last 30 Minutes)" + : "Log Count Distribution (Last 30 Minutes)"} +
+
+ {bins.map((bin, idx) => ( +
+
+ {bin.count} logs +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/project/logs/parseLogEntry.ts b/apps/web/src/components/project/logs/parseLogEntry.ts new file mode 100644 index 0000000..f463097 --- /dev/null +++ b/apps/web/src/components/project/logs/parseLogEntry.ts @@ -0,0 +1,61 @@ +export function stripAnsi(str: string): string { + let s = str.replace(/[\u001b\u009b]\[[\d;]*[A-Za-z]/g, ""); + s = s.replace(/\[(\d+;)*\d*m/g, ""); + return s; +} + +export function parseLogEntry(log: { id?: string; message: string; createdAt?: string; timestamp?: string }) { + let message = stripAnsi(log.message); + let level = "info"; + let status = ""; + let host = "localhost"; + let request = ""; + let duration: string | null = null; + let size: string | null = null; + let raw = log.message; + + if (message.startsWith("{") && message.endsWith("}")) { + try { + const obj = JSON.parse(message); + if (obj.level) level = obj.level.toLowerCase(); + if (obj.status) { + status = String(obj.status); + const statusNum = Number(obj.status); + if (statusNum >= 500) level = "error"; + else if (statusNum >= 400) level = "warning"; + } + if (obj.request) { + host = obj.request.host || host; + request = `${obj.request.method || ""} ${obj.request.uri || ""}`; + duration = obj.duration ? `${(obj.duration * 1000).toFixed(2)}ms` : null; + size = obj.size ? `${obj.size} B` : null; + message = obj.msg || obj.message || obj.error || message; + if (!message || message === '""') { + message = `${obj.request.method || ""} ${obj.request.uri || ""}`; + } + } else { + message = obj.msg || obj.message || message; + } + raw = JSON.stringify(obj, null, 2); + } catch {} + } else { + const upper = message.toUpperCase(); + if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FAIL")) { + level = "error"; + } else if (upper.includes("WARN")) { + level = "warning"; + } + } + + return { + ...log, + parsedMessage: message, + level, + status, + host, + request, + duration, + size, + raw, + }; +} diff --git a/apps/web/src/components/project/observability/ObservabilityTab.tsx b/apps/web/src/components/project/observability/ObservabilityTab.tsx index 0e05536..c3abd86 100644 --- a/apps/web/src/components/project/observability/ObservabilityTab.tsx +++ b/apps/web/src/components/project/observability/ObservabilityTab.tsx @@ -71,15 +71,15 @@ function AreaChart({ width = 500, height = 180, }: AreaChartProps) { - const padding = { top: 20, right: 20, bottom: 30, left: 90 }; + const padding = { top: 20, right: 15, bottom: 30, left: 55 }; if (isLoading || points.length === 0) { return ( - + {title} - +
Analyzing metrics... @@ -123,13 +123,13 @@ function AreaChart({ return ( - + {title} {formatter(points[points.length - 1]?.y ?? 0)} - + diff --git a/apps/web/src/components/project/scaling/AutoscalingPolicyCard.tsx b/apps/web/src/components/project/scaling/AutoscalingPolicyCard.tsx new file mode 100644 index 0000000..7cd0126 --- /dev/null +++ b/apps/web/src/components/project/scaling/AutoscalingPolicyCard.tsx @@ -0,0 +1,246 @@ +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { TrendingUp, Sliders, Trash2 } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../../ui/dialog"; + +interface ScalingPolicy { + minReplicas: number; + maxReplicas: number; + cpuThresholdPercent: number; + cooldownSeconds?: number; +} + +interface AutoscalingPolicyCardProps { + policy: ScalingPolicy | null | undefined; + isConfiguring: boolean; + setIsConfiguring: (v: boolean) => void; + minR: number; + setMinR: (v: number) => void; + maxR: number; + setMaxR: (v: number) => void; + cpuT: number; + setCpuT: (v: number) => void; + isSavingPolicy: boolean; + onSavePolicy: () => Promise; + onDeletePolicy: () => Promise; + isDisableOpen: boolean; + setIsDisableOpen: (v: boolean) => void; +} + +export function AutoscalingPolicyCard({ + policy, + isConfiguring, + setIsConfiguring, + minR, + setMinR, + maxR, + setMaxR, + cpuT, + setCpuT, + isSavingPolicy, + onSavePolicy, + onDeletePolicy, + isDisableOpen, + setIsDisableOpen, +}: AutoscalingPolicyCardProps) { + return ( + <> + + + + Auto-scaling Policy + + + + {!policy && !isConfiguring ? ( +
+
+ +
+

+ Auto-scaling is not configured +

+

+ Automatically adjust replica counts between min/max limits based on average CPU usage. Perfect for load spikes. +

+ +
+ ) : isConfiguring || !policy ? ( +
+
+
+ + setMinR(Number(e.target.value))} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" + /> +
+
+ + setMaxR(Number(e.target.value))} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" + /> +
+
+ +
+ setCpuT(Number(e.target.value))} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-8 text-sm font-semibold rounded-lg" + /> + + % + +
+
+
+ +
+ {policy && ( + + )} + +
+
+ ) : ( +
+
+
+
+ Status +
+
+ + Active +
+
+
+
+ Scale Range +
+
+ {policy.minReplicas} – {policy.maxReplicas} replicas +
+
+
+
+ CPU Threshold +
+
+ {policy.cpuThresholdPercent}% utilization +
+
+
+
+ Cooldown +
+
+ {policy.cooldownSeconds || 60}s between actions +
+
+
+ +
+
+ Min Scale ({policy.minReplicas} Container) + Trigger > {policy.cpuThresholdPercent}% CPU + Max Scale ({policy.maxReplicas} Containers) +
+
+
+
+
+
+ +
+ + +
+
+ )} + + + + + + + + Disable Auto-scaling + + + Are you sure you want to disable auto-scaling for this project? This will stop adjusting container replicas automatically based on CPU usage. + + + + + + + + + + ); +} diff --git a/apps/web/src/components/project/scaling/ResourceLimitsCard.tsx b/apps/web/src/components/project/scaling/ResourceLimitsCard.tsx new file mode 100644 index 0000000..55e890c --- /dev/null +++ b/apps/web/src/components/project/scaling/ResourceLimitsCard.tsx @@ -0,0 +1,170 @@ +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { Input } from "../../ui/input"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { Cpu } from "lucide-react"; + +export const PRESETS = [ + { + name: "Starter", + cpu: "0.25", + memory: "256", + desc: "Shared CPU / 256MB", + }, + { + name: "Standard", + cpu: "1", + memory: "512", + desc: "1 Core / 512MB", + }, + { + name: "Professional", + cpu: "2", + memory: "1024", + desc: "2 Cores / 1GB", + }, +]; + +interface ResourceLimitsCardProps { + cpuLimit: string; + setCpuLimit: (v: string) => void; + memoryLimitMb: string; + setMemoryLimitMb: (v: string) => void; + isSavingLimits: boolean; + onSaveLimits: () => Promise; + hasLimits: boolean; +} + +export function ResourceLimitsCard({ + cpuLimit, + setCpuLimit, + memoryLimitMb, + setMemoryLimitMb, + isSavingLimits, + onSaveLimits, + hasLimits, +}: ResourceLimitsCardProps) { + const matchedPreset = PRESETS.find( + (p) => p.cpu === cpuLimit && p.memory === memoryLimitMb, + ); + + return ( + + +
+ + Resource Allocation + +

+ Set custom limits or select a package for containers. +

+
+ {hasLimits ? ( + + {matchedPreset ? matchedPreset.name : "Custom Limit"} + + ) : ( + + Unlimited + + )} +
+ +
+ {PRESETS.map((p) => { + const isSelected = cpuLimit === p.cpu && memoryLimitMb === p.memory; + return ( + + ); + })} +
+ +
+
+ +
+ setCpuLimit(e.target.value)} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-14 text-sm font-semibold rounded-lg" + /> + + cores + +
+
+
+ +
+ setMemoryLimitMb(e.target.value)} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-12 text-sm font-semibold rounded-lg" + /> + + MB + +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/project/scaling/ScalingTab.tsx b/apps/web/src/components/project/scaling/ScalingTab.tsx index 3a357ad..9581ca5 100644 --- a/apps/web/src/components/project/scaling/ScalingTab.tsx +++ b/apps/web/src/components/project/scaling/ScalingTab.tsx @@ -1,105 +1,39 @@ -import React, { - useState, - useEffect, -} from "react"; +import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useProject } from "../../../hooks/useProjects"; import * as api from "../../../api/client"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../ui/card"; -import { Input } from "../../ui/input"; -import { Button } from "../../ui/button"; -import { Badge } from "../../ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "../../ui/dialog"; -import { - Cpu, - Database, - TrendingUp, - Sliders, - Trash2, - Check, - Sparkles, -} from "lucide-react"; +import { ResourceLimitsCard } from "./ResourceLimitsCard"; +import { AutoscalingPolicyCard } from "./AutoscalingPolicyCard"; interface ScalingTabProps { projectId: string; } -const PRESETS = [ - { - name: "Starter", - cpu: "0.25", - memory: "256", - desc: "Shared CPU / 256MB", - }, - { - name: "Standard", - cpu: "1", - memory: "512", - desc: "1 Core / 512MB", - }, - { - name: "Professional", - cpu: "2", - memory: "1024", - desc: "2 Cores / 1GB", - }, -]; - -export function ScalingTab({ - projectId, -}: ScalingTabProps) { +export function ScalingTab({ projectId }: ScalingTabProps) { const { data: project, refetch: refetchProject, } = useProject(projectId); const { data: policy, refetch } = useQuery({ queryKey: ["scaling", projectId], - queryFn: () => - api - .getScalingPolicy(projectId) - .catch(() => null), + queryFn: () => api.getScalingPolicy(projectId).catch(() => null), }); const [cpuLimit, setCpuLimit] = useState(""); - const [memoryLimitMb, setMemoryLimitMb] = - useState(""); + const [memoryLimitMb, setMemoryLimitMb] = useState(""); const [minR, setMinR] = useState(1); const [maxR, setMaxR] = useState(5); const [cpuT, setCpuT] = useState(70); - const [isConfiguring, setIsConfiguring] = - useState(false); - const [isSavingLimits, setIsSavingLimits] = - useState(false); - const [isSavingPolicy, setIsSavingPolicy] = - useState(false); - const [ - isDisableScalingOpen, - setIsDisableScalingOpen, - ] = useState(false); + const [isConfiguring, setIsConfiguring] = useState(false); + const [isSavingLimits, setIsSavingLimits] = useState(false); + const [isSavingPolicy, setIsSavingPolicy] = useState(false); + const [isDisableScalingOpen, setIsDisableScalingOpen] = useState(false); useEffect(() => { if (project) { - setCpuLimit( - project.cpuLimit?.toString() ?? - "", - ); - setMemoryLimitMb( - project.memoryLimitMb?.toString() ?? - "", - ); + setCpuLimit(project.cpuLimit?.toString() ?? ""); + setMemoryLimitMb(project.memoryLimitMb?.toString() ?? ""); } }, [project]); @@ -117,13 +51,8 @@ export function ScalingTab({ setIsSavingLimits(true); try { await api.updateProject(projectId, { - cpuLimit: cpuLimit.trim() - ? Number(cpuLimit) - : null, - memoryLimitMb: - memoryLimitMb.trim() - ? Number(memoryLimitMb) - : null, + cpuLimit: cpuLimit.trim() ? Number(cpuLimit) : null, + memoryLimitMb: memoryLimitMb.trim() ? Number(memoryLimitMb) : null, }); refetchProject(); } finally { @@ -134,14 +63,11 @@ export function ScalingTab({ const savePolicy = async () => { setIsSavingPolicy(true); try { - await api.upsertScalingPolicy( - projectId, - { - minReplicas: minR, - maxReplicas: maxR, - cpuThresholdPercent: cpuT, - } as any, - ); + await api.upsertScalingPolicy(projectId, { + minReplicas: minR, + maxReplicas: maxR, + cpuThresholdPercent: cpuT, + } as any); refetch(); setIsConfiguring(false); } finally { @@ -156,505 +82,34 @@ export function ScalingTab({ setIsDisableScalingOpen(false); }; - const matchedPreset = PRESETS.find( - (p) => - p.cpu === cpuLimit && - p.memory === memoryLimitMb, - ); - return (
- {/* Resource Limits Card */} - - -
- - {" "} - Resource Allocation - -

- Set custom limits or - select a package for - containers. -

-
- {project?.cpuLimit || - project?.memoryLimitMb ? ( - - {matchedPreset - ? matchedPreset.name - : "Custom Limit"} - - ) : ( - - Unlimited - - )} -
- - {/* Preset selection grid */} -
- {PRESETS.map((p) => { - const isSelected = - cpuLimit === - p.cpu && - memoryLimitMb === - p.memory; - return ( - - ); - })} -
- -
-
- -
- - setCpuLimit( - e - .target - .value, - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-14 text-sm font-semibold rounded-lg" - /> - - cores - -
-
-
- -
- - setMemoryLimitMb( - e - .target - .value, - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-12 text-sm font-semibold rounded-lg" - /> - - MB - -
-
-
- -
- - -
-
-
- - {/* Auto-scaling Policy Card */} - - - - {" "} - Auto-scaling Policy - - - - {!policy && !isConfiguring ? ( -
-
- -
-

- Auto-scaling is - not configured -

-

- Automatically - adjust replica - counts between - min/max limits - based on average - CPU usage. Perfect - for load spikes. -

- -
- ) : isConfiguring || - !policy ? ( -
-
-
- - - setMinR( - Number( - e - .target - .value, - ), - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" - /> -
-
- - - setMaxR( - Number( - e - .target - .value, - ), - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" - /> -
-
- -
- - setCpuT( - Number( - e - .target - .value, - ), - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary pr-8 text-sm font-semibold rounded-lg" - /> - - % - -
-
-
- -
- {policy && ( - - )} - -
-
- ) : ( -
-
-
-
- Status -
-
- - Active -
-
-
-
- Scale - Range -
-
- { - policy.minReplicas - }{" "} - –{" "} - { - policy.maxReplicas - }{" "} - replicas -
-
-
-
- CPU - Threshold -
-
- { - policy.cpuThresholdPercent - } - % - utilization -
-
-
-
- Cooldown -
-
- { - policy.cooldownSeconds - } - s between - actions -
-
-
- - {/* Visual scale range gauge */} -
-
- - Min Scale - ( - { - policy.minReplicas - }{" "} - Container) - - - Trigger - >{" "} - { - policy.cpuThresholdPercent - } - % CPU - - - Max Scale - ( - { - policy.maxReplicas - }{" "} - Containers) - -
-
-
-
-
-
- -
- - -
-
- )} - - - - - - - - Disable Auto-scaling - - - Are you sure you want - to disable - auto-scaling for this - project? This will - stop adjusting - container replicas - automatically based on - CPU usage. - - - - - - - - + + +
); } diff --git a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx index 8d9306c..3ec5924 100644 --- a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx +++ b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx @@ -126,24 +126,24 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
-
+

Build & Execution Settings

Configure how Dequel resolves, builds, and launches your codebase.

-
+
{saveSuccess && (
- + Settings saved successfully
)}
) : (
-
+

Persistent Volumes

- Disk volumes - mounted to - container - runtimes. + Disk volumes mounted to container runtimes.

-
- - - - - Mount Path - - - Docker - Volume - Name - - - Created - - - - - - {volumes.map( - (v) => ( - + {/* Mobile Card List View (< md) */} +
+ {volumes.map((v) => ( +
+
+ + {v.mountPath} + + +
+
+ {v.dockerVolumeName ?? "—"} + {new Date(v.createdAt).toLocaleDateString()} +
+
+ ))} +
+ + {/* Desktop Table View (>= md) */} +
+
+ + + + Mount Path + + + Docker Volume Name + + + Created + + + + + + {volumes.map( + (v) => ( + - - {v.dockerVolumeName ?? - "—"} - - - {new Date( - v.createdAt, - ).toLocaleDateString( - undefined, + className="border-border hover:bg-[#121217]/30 group transition-all" + > + { - year: "numeric", - month: "short", - day: "numeric", - }, - )} - - - - - - ), - )} - -
+ + + {v.dockerVolumeName ?? + "—"} + + + {new Date( + v.createdAt, + ).toLocaleDateString( + undefined, + { + year: "numeric", + month: "short", + day: "numeric", + }, + )} + + + + + + ), + )} + + +
)} diff --git a/apps/web/src/components/settings/ApiKeysSection.tsx b/apps/web/src/components/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..d9c587c --- /dev/null +++ b/apps/web/src/components/settings/ApiKeysSection.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; +import { Trash2, Key } from 'lucide-react'; +import * as api from '../../api/client'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../ui/dialog'; + +export function ApiKeysSection() { + const { data: apiKeys = [], refetch } = useQuery({ + queryKey: ['api-keys'], + queryFn: () => api.listApiKeys().catch(() => []), + }); + const [name, setName] = useState(''); + const [newKey, setNewKey] = useState(''); + const [deletingKeyId, setDeletingKeyId] = useState(null); + + const handleDeleteKey = async () => { + if (!deletingKeyId) return; + await api.deleteApiKey(deletingKeyId); + setDeletingKeyId(null); + refetch(); + }; + + const add = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + const result = await api.createApiKey({ name: name.trim() }); + setNewKey(result.rawKey || ''); + setName(''); + refetch(); + }; + + return ( + + +
+ + API Keys +
+
+ + {newKey && ( +
+ API Key created — copy it now: + {newKey} +
+ )} + +
+ + setName(e.target.value)} /> +
+ + + + {apiKeys.length > 0 && ( +
+
+ {apiKeys.map((k) => ( +
+
+

{k.name}

+

{k.keyHash?.slice(0, 12)}...

+

{new Date(k.createdAt).toLocaleDateString()}

+
+ +
+ ))} +
+ +
+ + + + Name + Key Hash + Created + + + + + {apiKeys.map((k) => ( + + {k.name} + {k.keyHash?.slice(0, 12)}... + {new Date(k.createdAt).toLocaleDateString()} + + + + + ))} + +
+
+
+ )} +
+ + { if (!open) setDeletingKeyId(null); }}> + + + Delete API Key + + Are you sure you want to delete this API key? Any services using it will lose access immediately. + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/GithubIntegrationSection.tsx b/apps/web/src/components/settings/GithubIntegrationSection.tsx new file mode 100644 index 0000000..930d14f --- /dev/null +++ b/apps/web/src/components/settings/GithubIntegrationSection.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import * as api from '../../api/client'; + +export function GithubIntegrationSection() { + const { data, refetch } = useQuery({ + queryKey: ['github-integration'], + queryFn: () => api.getGithubIntegration(), + }); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [appName, setAppName] = useState(''); + const [webhookSecret, setWebhookSecret] = useState(''); + const [saveResult, setSaveResult] = useState(null); + + useEffect(() => { + if (data?.configured) { + setClientId(data.clientId || ''); + setAppName(data.appName || ''); + } + }, [data]); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveResult(null); + try { + await api.setGithubIntegration({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + appName: appName.trim() || undefined, + webhookSecret: webhookSecret.trim() || undefined, + }); + setClientSecret(''); + setWebhookSecret(''); + refetch(); + setSaveResult('Settings saved'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setSaveResult('error: ' + message); + } + }; + + const icon = ( + + + + ); + + return ( + + +
+ {icon} + GitHub Integration +
+
+ +
+
+
+ + setClientId(e.target.value)} /> +
+
+ + setClientSecret(e.target.value)} /> +
+
+ + setAppName(e.target.value)} /> +
+
+ + setWebhookSecret(e.target.value)} /> +
+
+
+ +
+
+ {!data?.configured && ( +

GitHub is not configured. Add your OAuth App credentials to enable the repo picker.

+ )} + {saveResult && ( +

{saveResult}

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/ServersSection.tsx b/apps/web/src/components/settings/ServersSection.tsx new file mode 100644 index 0000000..ec472f4 --- /dev/null +++ b/apps/web/src/components/settings/ServersSection.tsx @@ -0,0 +1,321 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; +import { StatusBadge } from '../StatusBadge'; +import { Trash2, Server, Copy } from 'lucide-react'; +import * as api from '../../api/client'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../ui/dialog'; + +export function ServersSection() { + const { data: servers = [], refetch } = useQuery({ + queryKey: ['servers'], + queryFn: () => api.listServers().catch(() => []), + }); + const [name, setName] = useState(''); + const [host, setHost] = useState(''); + const [port, setPort] = useState('22'); + const [sshUser, setSshUser] = useState('root'); + const [sshKey, setSshKey] = useState(''); + const [agentName, setAgentName] = useState(''); + const [registrationCommand, setRegistrationCommand] = useState(''); + const [registrationError, setRegistrationError] = useState(''); + + const [deletingServerId, setDeletingServerId] = useState(null); + const [preparingId, setPreparingId] = useState(null); + const [prepareLogs, setPrepareLogs] = useState<{ stage: string; message: string }[]>([]); + const [prepareDone, setPrepareDone] = useState(false); + const [prepareError, setPrepareError] = useState(null); + + const handlePrepare = async (serverId: string) => { + setPreparingId(serverId); + setPrepareLogs([]); + setPrepareDone(false); + setPrepareError(null); + try { + await api.prepareServer(serverId); + } catch (err) { + setPrepareError(err instanceof Error ? err.message : 'Could not start preparation'); + } + }; + + useEffect(() => { + if (!preparingId) return; + const source = new EventSource(api.serverPrepareStreamUrl(preparingId)); + source.addEventListener('log', (e) => { + try { + const event = JSON.parse((e as MessageEvent).data); + setPrepareLogs((prev) => [...prev, { stage: event.stage, message: event.message }]); + } catch {} + }); + source.addEventListener('done', (e) => { + try { + const event = JSON.parse((e as MessageEvent).data); + setPrepareDone(true); + setPrepareError(event.ok ? null : event.error || 'Preparation failed'); + setPreparingId(null); + refetch(); + } catch {} + }); + source.addEventListener('error', () => { + setPrepareDone(true); + setPreparingId(null); + }); + return () => source.close(); + }, [preparingId, refetch]); + + const handleDeleteServer = async () => { + if (!deletingServerId) return; + await api.deleteServer(deletingServerId); + setDeletingServerId(null); + refetch(); + }; + + const addSshServer = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !host.trim()) return; + await api.createServer({ + name: name.trim(), + host: host.trim(), + port: Number(port) || 22, + mode: 'ssh', + sshUser: sshUser.trim() || 'root', + sshKey: sshKey.trim() || undefined, + }); + setName(''); + setHost(''); + setPort('22'); + setSshUser('root'); + setSshKey(''); + refetch(); + }; + + const createRegistration = async (e: React.FormEvent) => { + e.preventDefault(); + if (!agentName.trim()) return; + setRegistrationError(''); + try { + const result = await api.createAgentRegistrationToken({ name: agentName.trim() }); + const controlPlane = window.location.origin; + setRegistrationCommand( + `docker run -d --name dequel-agent --cap-add=NET_ADMIN --device /dev/net/tun --restart unless-stopped -e DEQUEL_CONTROL_PLANE=${controlPlane} -e DEQUEL_REGISTRATION_TOKEN=${result.token} -v dequel-agent-data:/root/.dequel -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/lftobs/dequel/agent:latest`, + ); + } catch (err) { + setRegistrationError(err instanceof Error ? err.message : 'Could not create registration token'); + } + }; + + return ( + + +
+ + Servers +
+
+ +
+
+ +
+

Connect a Server (Direct SSH)

+

+ Add any remote cloud VPS (Hetzner, DigitalOcean, AWS). Dequel deploys over SSH directly without installing software on the target server. +

+
+
+
+
+ + setName(e.target.value)} /> +
+
+ + setHost(e.target.value)} /> +
+
+ + setPort(e.target.value)} /> +
+
+ + setSshUser(e.target.value)} /> +
+
+
+ +