feat: add environment labels (local/dev/stage/prod) for connections
Visual badges help distinguish environments across the UI to prevent running dangerous queries on production. Environment color takes priority over custom connection color for the toolbar border. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ pub struct ConnectionConfig {
|
|||||||
pub database: String,
|
pub database: String,
|
||||||
pub ssl_mode: Option<String>,
|
pub ssl_mode: Option<String>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub environment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionConfig {
|
impl ConnectionConfig {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
|
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
import { ENVIRONMENTS } from "@/lib/environment";
|
||||||
import { Loader2, X } from "lucide-react";
|
import { Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
const CONNECTION_COLORS = [
|
const CONNECTION_COLORS = [
|
||||||
@@ -47,6 +48,7 @@ const emptyConfig: ConnectionConfig = {
|
|||||||
database: "postgres",
|
database: "postgres",
|
||||||
ssl_mode: "prefer",
|
ssl_mode: "prefer",
|
||||||
color: undefined,
|
color: undefined,
|
||||||
|
environment: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
||||||
@@ -184,6 +186,38 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-3">
|
||||||
|
<label className="text-right text-sm text-muted-foreground">
|
||||||
|
Environment
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={form.environment ?? "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
environment: v === "__none__" ? undefined : v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{ENVIRONMENTS.map((env) => (
|
||||||
|
<SelectItem key={env.value} value={env.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: env.color }}
|
||||||
|
/>
|
||||||
|
{env.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-3">
|
<div className="grid grid-cols-4 items-center gap-3">
|
||||||
<label className="text-right text-sm text-muted-foreground">
|
<label className="text-right text-sm text-muted-foreground">
|
||||||
Color
|
Color
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
import { ENVIRONMENTS } from "@/lib/environment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -80,14 +82,56 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="overflow-y-auto overflow-x-hidden" style={{ height: "calc(100vh - 80px)" }}>
|
<div className="overflow-y-auto overflow-x-hidden" style={{ height: "calc(100vh - 80px)" }}>
|
||||||
<div className="space-y-2 px-4 pb-4">
|
<div className="space-y-2 px-4 pb-4">
|
||||||
{connections?.map((conn) => {
|
{(() => {
|
||||||
|
if (!connections || connections.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No saved connections.
|
||||||
|
<br />
|
||||||
|
Click "New" to add one.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOrder = ["prod", "stage", "dev", "local"] as const;
|
||||||
|
const grouped = new Map<string, ConnectionConfig[]>();
|
||||||
|
for (const conn of connections) {
|
||||||
|
const key = conn.environment ?? "";
|
||||||
|
const list = grouped.get(key);
|
||||||
|
if (list) list.push(conn);
|
||||||
|
else grouped.set(key, [conn]);
|
||||||
|
}
|
||||||
|
const sortedKeys = [...grouped.keys()].sort((a, b) => {
|
||||||
|
const ai = a ? envOrder.indexOf(a as typeof envOrder[number]) : envOrder.length;
|
||||||
|
const bi = b ? envOrder.indexOf(b as typeof envOrder[number]) : envOrder.length;
|
||||||
|
return (ai === -1 ? envOrder.length : ai) - (bi === -1 ? envOrder.length : bi);
|
||||||
|
});
|
||||||
|
const showHeaders = sortedKeys.length > 1;
|
||||||
|
|
||||||
|
return sortedKeys.map((key) => {
|
||||||
|
const group = grouped.get(key)!;
|
||||||
|
const envDef = ENVIRONMENTS.find((e) => e.value === key);
|
||||||
|
return (
|
||||||
|
<div key={key || "__none__"}>
|
||||||
|
{showHeaders && (
|
||||||
|
<div className="mb-1 mt-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase text-muted-foreground first:mt-0">
|
||||||
|
{envDef ? (
|
||||||
|
<>
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: envDef.color }} />
|
||||||
|
{envDef.label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Other"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.map((conn) => {
|
||||||
const isConnected = connectedIds.has(conn.id);
|
const isConnected = connectedIds.has(conn.id);
|
||||||
const isActive = activeConnectionId === conn.id;
|
const isActive = activeConnectionId === conn.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={conn.id}
|
key={conn.id}
|
||||||
className={`flex items-center gap-2 overflow-hidden rounded-md border p-3 ${
|
className={`flex items-center gap-2 overflow-hidden rounded-md border p-3 mb-2 last:mb-0 ${
|
||||||
isActive ? "border-primary bg-accent" : ""
|
isActive ? "border-primary bg-accent" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -100,8 +144,9 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">
|
<div className="flex items-center gap-1.5 truncate text-sm font-medium">
|
||||||
{conn.name}
|
{conn.name}
|
||||||
|
<EnvironmentBadge environment={conn.environment} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{conn.host}:{conn.port}/{conn.database}
|
{conn.host}:{conn.port}/{conn.database}
|
||||||
@@ -154,13 +199,10 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(!connections || connections.length === 0) && (
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No saved connections.
|
|
||||||
<br />
|
|
||||||
Click "New" to add one.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
|
||||||
export function ConnectionSelector() {
|
export function ConnectionSelector() {
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
@@ -37,6 +38,7 @@ export function ConnectionSelector() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SelectValue placeholder="Select connection" />
|
<SelectValue placeholder="Select connection" />
|
||||||
|
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -50,6 +52,7 @@ export function ConnectionSelector() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{conn.name}
|
{conn.name}
|
||||||
|
<EnvironmentBadge environment={conn.environment} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
26
src/components/connections/EnvironmentBadge.tsx
Normal file
26
src/components/connections/EnvironmentBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { getEnvironment } from "@/lib/environment";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environment?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentBadge({ environment, size = "sm" }: Props) {
|
||||||
|
const env = getEnvironment(environment);
|
||||||
|
if (!env) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded border font-semibold uppercase leading-none",
|
||||||
|
env.bgClass,
|
||||||
|
env.textClass,
|
||||||
|
env.borderClass,
|
||||||
|
size === "sm" ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{env.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { Circle } from "lucide-react";
|
import { Circle } from "lucide-react";
|
||||||
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rowCount?: number | null;
|
rowCount?: number | null;
|
||||||
@@ -31,6 +32,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeConn ? activeConn.name : "No connection"}
|
{activeConn ? activeConn.name : "No connection"}
|
||||||
|
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
||||||
</span>
|
</span>
|
||||||
{isConnected && activeConnectionId && (
|
{isConnected && activeConnectionId && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAppStore } from "@/stores/app-store";
|
|||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { Database, Plus } from "lucide-react";
|
import { Database, Plus } from "lucide-react";
|
||||||
import type { ConnectionConfig, Tab } from "@/types";
|
import type { ConnectionConfig, Tab } from "@/types";
|
||||||
|
import { getEnvironment } from "@/lib/environment";
|
||||||
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
@@ -16,7 +18,9 @@ export function Toolbar() {
|
|||||||
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
||||||
const { activeConnectionId, addTab } = useAppStore();
|
const { activeConnectionId, addTab } = useAppStore();
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
const activeColor = connections?.find((c) => c.id === activeConnectionId)?.color;
|
const activeConn = connections?.find((c) => c.id === activeConnectionId);
|
||||||
|
const activeEnv = getEnvironment(activeConn?.environment);
|
||||||
|
const activeColor = activeEnv?.color ?? activeConn?.color;
|
||||||
|
|
||||||
const handleNewQuery = () => {
|
const handleNewQuery = () => {
|
||||||
if (!activeConnectionId) return;
|
if (!activeConnectionId) return;
|
||||||
@@ -54,6 +58,13 @@ export function Toolbar() {
|
|||||||
|
|
||||||
<ReadOnlyToggle />
|
<ReadOnlyToggle />
|
||||||
|
|
||||||
|
{activeConn?.environment && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-5" />
|
||||||
|
<EnvironmentBadge environment={activeConn.environment} size="sm" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<Separator orientation="vertical" className="h-5" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
13
src/lib/environment.ts
Normal file
13
src/lib/environment.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const ENVIRONMENTS = [
|
||||||
|
{ value: "local", label: "LOCAL", color: "#6b7280", bgClass: "bg-gray-500/20", textClass: "text-gray-400", borderClass: "border-gray-500/40" },
|
||||||
|
{ value: "dev", label: "DEV", color: "#3b82f6", bgClass: "bg-blue-500/20", textClass: "text-blue-400", borderClass: "border-blue-500/40" },
|
||||||
|
{ value: "stage", label: "STG", color: "#f59e0b", bgClass: "bg-amber-500/20", textClass: "text-amber-400", borderClass: "border-amber-500/40" },
|
||||||
|
{ value: "prod", label: "PROD", color: "#ef4444", bgClass: "bg-red-500/20", textClass: "text-red-400", borderClass: "border-red-500/40" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EnvironmentValue = (typeof ENVIRONMENTS)[number]["value"];
|
||||||
|
|
||||||
|
export function getEnvironment(value?: string) {
|
||||||
|
if (!value) return null;
|
||||||
|
return ENVIRONMENTS.find((e) => e.value === value) ?? null;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export interface ConnectionConfig {
|
|||||||
database: string;
|
database: string;
|
||||||
ssl_mode?: string;
|
ssl_mode?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
environment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryResult {
|
export interface QueryResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user