mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.18
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"/home/jorge/git/fidelizacion/docker-compose.prod.yml": ".env.prod",
|
||||
"/home/jorge/git/fidelizacion/api/docker-compose.dev.yml": ".env",
|
||||
"/home/jorge/git/ninjasagacw/docker-compose.infra.yml": ".env"
|
||||
}
|
||||
@@ -90,6 +90,7 @@ function Dashboard({ token }: { token: string }) {
|
||||
const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null);
|
||||
const [envFiles, setEnvFiles] = useState<Record<string, string>>({});
|
||||
|
||||
// Close filter dropdown on outside click
|
||||
useEffect(() => {
|
||||
@@ -102,6 +103,28 @@ function Dashboard({ token }: { token: string }) {
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Fetch env-file overrides
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch("/api/env-files", { headers })
|
||||
.then((r) => r.ok ? r.json() : {})
|
||||
.then((data: Record<string, string>) => setEnvFiles(data))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
const handleEnvFileChange = useCallback((composeFile: string, envFile: string | null) => {
|
||||
setEnvFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
if (envFile) next[composeFile] = envFile;
|
||||
else delete next[composeFile];
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch("/api/env-files", { method: "PUT", headers, body: JSON.stringify(next) }).catch(() => {});
|
||||
return next;
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const NODE_W = NODE_WIDTH;
|
||||
const NODE_H = NODE_HEIGHT;
|
||||
const G_PAD = GROUP_PADDING;
|
||||
@@ -686,6 +709,8 @@ function Dashboard({ token }: { token: string }) {
|
||||
services={filteredServices}
|
||||
getLogsSince={getLogsSince}
|
||||
initialLogsFullscreen={openLogsFullscreen}
|
||||
envFiles={envFiles}
|
||||
onEnvFileChange={handleEnvFileChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react";
|
||||
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink } from "lucide-react";
|
||||
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react";
|
||||
import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types";
|
||||
|
||||
type Tab = "info" | "config" | "env" | "stats";
|
||||
@@ -21,8 +21,8 @@ const SYSTEM_ENV_KEYS = new Set([
|
||||
"MONGO_VERSION", "MONGO_MAJOR", "MONGO_PACKAGE", "MONGO_REPO",
|
||||
]);
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: typeof Info }[] = [
|
||||
{ id: "info", label: "Info", icon: Info },
|
||||
const TABS: { id: Tab; label: string; icon: typeof InfoIcon }[] = [
|
||||
{ id: "info", label: "Info", icon: InfoIcon },
|
||||
{ id: "stats", label: "Stats", icon: Activity },
|
||||
{ id: "env", label: "Env", icon: Variable },
|
||||
{ id: "config", label: "Config", icon: Settings },
|
||||
@@ -42,9 +42,11 @@ interface DetailPanelProps {
|
||||
services: Service[];
|
||||
getLogsSince: (uid: string) => number | undefined;
|
||||
initialLogsFullscreen?: boolean;
|
||||
envFiles: Record<string, string>;
|
||||
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
|
||||
}
|
||||
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen }: DetailPanelProps) {
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange }: DetailPanelProps) {
|
||||
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -58,6 +60,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const [copiedEnvIdx, setCopiedEnvIdx] = useState<number | null>(null);
|
||||
const [logsModal, setLogsModal] = useState(!!initialLogsFullscreen);
|
||||
const modalScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [envFileEditing, setEnvFileEditing] = useState(false);
|
||||
const [envFileOptions, setEnvFileOptions] = useState<string[]>([]);
|
||||
const [envFileSelected, setEnvFileSelected] = useState<string>("");
|
||||
|
||||
// Scroll modal to bottom when opened or when logs arrive
|
||||
useEffect(() => {
|
||||
@@ -435,6 +440,73 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<DetailRow label="Compose" value={service.compose_file} mono />
|
||||
)}
|
||||
|
||||
{service.compose_file && (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 mb-0.5 flex items-center gap-1">
|
||||
Env File
|
||||
<span className="relative group/tip">
|
||||
<HelpCircle size={11} className="text-slate-600 hover:text-slate-400 cursor-help transition-colors" />
|
||||
<span className="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 px-2.5 py-1.5 bg-slate-700 text-slate-200 text-[11px] normal-case tracking-normal rounded-md shadow-lg whitespace-nowrap opacity-0 pointer-events-none group-hover/tip:opacity-100 transition-opacity z-10">
|
||||
Only files starting with .env are detected
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{envFileEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={envFileSelected}
|
||||
onChange={(e) => setEnvFileSelected(e.target.value)}
|
||||
className="flex-1 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm font-mono text-slate-200 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="">Auto (detect)</option>
|
||||
{envFileOptions.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
onEnvFileChange(service.compose_file!, envFileSelected || null);
|
||||
setEnvFileEditing(false);
|
||||
}}
|
||||
className="p-1 rounded text-emerald-400 hover:bg-emerald-400/10 transition-colors"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEnvFileEditing(false)}
|
||||
className="p-1 rounded text-slate-400 hover:bg-slate-700 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-sm break-all ${envFiles[service.compose_file!] ? "font-mono text-slate-200" : "text-slate-500 italic"}`}>
|
||||
{envFiles[service.compose_file!] || "Auto"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEnvFileSelected(envFiles[service.compose_file!] || "");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`/api/env-files/detect/${service.id}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : { files: [] })
|
||||
.then((data: { files: string[] }) => setEnvFileOptions(data.files))
|
||||
.catch(() => setEnvFileOptions([]));
|
||||
setEnvFileEditing(true);
|
||||
}}
|
||||
className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
||||
title="Edit env file"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.ports.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Ports</span>
|
||||
|
||||
@@ -8,8 +8,31 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream
|
||||
import { pollStats, watchDockerEvents } from "./watcher";
|
||||
import type { Service, WSMessage } from "../shared/types";
|
||||
|
||||
/** Env-file overrides per compose file (persisted to file) */
|
||||
const ENV_FILES_FILE = path.join(process.cwd(), ".dockerflow-env-files.json");
|
||||
|
||||
function loadEnvFiles(): Record<string, string> {
|
||||
try {
|
||||
if (fs.existsSync(ENV_FILES_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(ENV_FILES_FILE, "utf-8"));
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Build env-file args for docker compose by detecting .env files next to the compose file */
|
||||
function findEnvFileArgs(composeFile: string): string[] {
|
||||
// Check for user override first
|
||||
const overrides = loadEnvFiles();
|
||||
const override = overrides[composeFile];
|
||||
if (override) {
|
||||
const resolved = path.isAbsolute(override) ? override : path.join(path.dirname(composeFile), override);
|
||||
if (fs.existsSync(resolved)) {
|
||||
return ["--env-file", resolved];
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect heuristic
|
||||
const dir = path.dirname(composeFile);
|
||||
const baseName = path.basename(composeFile, path.extname(composeFile)); // e.g. "docker-compose.prod"
|
||||
const candidates: string[] = [];
|
||||
@@ -233,6 +256,40 @@ app.put("/api/positions", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Env-file overrides (persisted to file) ──
|
||||
app.get("/api/env-files", (c) => {
|
||||
return c.json(loadEnvFiles());
|
||||
});
|
||||
|
||||
app.put("/api/env-files", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
fs.writeFileSync(ENV_FILES_FILE, JSON.stringify(body, null, 2));
|
||||
return c.json({ ok: true });
|
||||
} catch {
|
||||
return c.json({ error: "Failed to save" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/env-files/detect/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
const info = await container.inspect();
|
||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||
if (!composeFile) return c.json({ files: [], composeFile: null });
|
||||
const dir = path.dirname(composeFile);
|
||||
const entries = fs.readdirSync(dir);
|
||||
const envFiles = entries.filter((e: string) => e.startsWith(".env") && !e.endsWith(".example") && !e.endsWith(".sample"))
|
||||
.map((e: string) => e)
|
||||
.sort();
|
||||
return c.json({ files: envFiles, composeFile });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to detect env files" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cache headers for static assets ──
|
||||
app.use("/*", async (c, next) => {
|
||||
await next();
|
||||
|
||||
Reference in New Issue
Block a user