Some checks failed
Test / rust-tests (pull_request) Successful in 14m38s
PR Review Automation / review (pull_request) Has been cancelled
Test / frontend-tests (pull_request) Successful in 1m50s
Test / frontend-typecheck (pull_request) Successful in 1m56s
Test / rust-fmt-check (pull_request) Successful in 11m10s
Test / rust-clippy (pull_request) Successful in 12m54s
1. kubectl credentials error (41 places in kube.rs)
Every kubectl invocation used .env("KUBERNETES_CONTEXT", context) which
is not a real kubectl environment variable — kubectl silently ignores it
and falls back to whatever current-context is set in the kubeconfig YAML.
If that context has expired or wrong credentials the auth failure occurs.
Replaced all 41 instances with .arg("--context").arg(context) so kubectl
always uses the correct context from the stored kubeconfig.
2. Cluster name still showed UUID (two causes)
a) Hotbar read from kubernetesStore.clusters (ClusterInfo[]) which is never
populated by the kubeconfig-based flow — always empty, so selectedCluster
was always undefined. Removed the Zustand cluster lookup from Hotbar and
added a clusterName prop passed from KubernetesPage.tsx (selectedConfig?.name).
b) ClusterOverview fell back to showing raw clusterId UUID when clusterName
was undefined. Changed subtitle to render conditionally so UUID never shows.
3. Bell dialog had no way to close
Custom DialogContent had no X button and no backdrop-click handler.
Added X close button (top-right) and backdrop-click-to-close.
4. Hotbar icons invisible in dark mode
variant="ghost" only styles hover state with no baseline text color.
Added className="text-foreground" to all icon-only ghost buttons.
894 lines
30 KiB
TypeScript
894 lines
30 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import {
|
|
Layers,
|
|
Network,
|
|
Database,
|
|
Shield,
|
|
Server,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
RefreshCw,
|
|
Plus,
|
|
Package,
|
|
} from "lucide-react";
|
|
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui";
|
|
import {
|
|
PodList,
|
|
DeploymentList,
|
|
DaemonSetList,
|
|
StatefulSetList,
|
|
ReplicaSetList,
|
|
JobList,
|
|
CronJobList,
|
|
ServiceList,
|
|
IngressList,
|
|
ConfigMapList,
|
|
SecretList,
|
|
HPAList,
|
|
PVCList,
|
|
PVList,
|
|
ServiceAccountList,
|
|
RoleList,
|
|
ClusterRoleList,
|
|
RoleBindingList,
|
|
ClusterRoleBindingList,
|
|
NodeList,
|
|
EventList,
|
|
ClusterOverview,
|
|
PortForwardList,
|
|
PortForwardForm,
|
|
CommandPalette,
|
|
Hotbar,
|
|
StorageClassList,
|
|
NetworkPolicyList,
|
|
ResourceQuotaList,
|
|
LimitRangeList,
|
|
} from "@/components/Kubernetes";
|
|
import type {
|
|
KubeconfigInfo,
|
|
NamespaceInfo,
|
|
PortForwardResponse,
|
|
PodInfo,
|
|
ServiceInfo,
|
|
DeploymentInfo,
|
|
StatefulSetInfo,
|
|
DaemonSetInfo,
|
|
ReplicaSetInfo,
|
|
JobInfo,
|
|
CronJobInfo,
|
|
ConfigMapInfo,
|
|
SecretInfo,
|
|
NodeInfo,
|
|
EventInfo,
|
|
IngressInfo,
|
|
PersistentVolumeClaimInfo,
|
|
PersistentVolumeInfo,
|
|
ServiceAccountInfo,
|
|
RoleInfo,
|
|
ClusterRoleInfo,
|
|
RoleBindingInfo,
|
|
ClusterRoleBindingInfo,
|
|
HorizontalPodAutoscalerInfo,
|
|
StorageClassInfo,
|
|
NetworkPolicyInfo,
|
|
ResourceQuotaInfo,
|
|
LimitRangeInfo,
|
|
} from "@/lib/tauriCommands";
|
|
import {
|
|
listKubeconfigsCmd,
|
|
activateKubeconfigCmd,
|
|
connectClusterFromKubeconfigCmd,
|
|
listNamespacesCmd,
|
|
listPortForwardsCmd,
|
|
startPortForwardCmd,
|
|
stopPortForwardCmd,
|
|
deletePortForwardCmd,
|
|
listPodsCmd,
|
|
listServicesCmd,
|
|
listDeploymentsCmd,
|
|
listStatefulsetsCmd,
|
|
listDaemonsetsCmd,
|
|
listReplicasetsCmd,
|
|
listJobsCmd,
|
|
listCronjobsCmd,
|
|
listConfigmapsCmd,
|
|
listSecretsCmd,
|
|
listNodesCmd,
|
|
listEventsCmd,
|
|
listIngressesCmd,
|
|
listPersistentvolumeclaimsCmd,
|
|
listPersistentvolumesCmd,
|
|
listServiceaccountsCmd,
|
|
listRolesCmd,
|
|
listClusterrolesCmd,
|
|
listRolebindingsCmd,
|
|
listClusterrolebindingsCmd,
|
|
listHorizontalpodautoscalersCmd,
|
|
listStorageclassesCmd,
|
|
listNetworkpoliciesCmd,
|
|
listResourcequotasCmd,
|
|
listLimitrangesCmd,
|
|
} from "@/lib/tauriCommands";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type ActiveSection =
|
|
| "overview"
|
|
| "pods"
|
|
| "deployments"
|
|
| "daemonsets"
|
|
| "statefulsets"
|
|
| "replicasets"
|
|
| "jobs"
|
|
| "cronjobs"
|
|
| "services"
|
|
| "ingresses"
|
|
| "configmaps"
|
|
| "secrets"
|
|
| "hpas"
|
|
| "pvcs"
|
|
| "pvs"
|
|
| "serviceaccounts"
|
|
| "roles"
|
|
| "clusterroles"
|
|
| "rolebindings"
|
|
| "clusterrolebindings"
|
|
| "nodes"
|
|
| "events"
|
|
| "portforwarding"
|
|
| "storageclasses"
|
|
| "networkpolicies"
|
|
| "resourcequotas"
|
|
| "limitranges";
|
|
|
|
interface NavItem {
|
|
id: ActiveSection;
|
|
label: string;
|
|
}
|
|
|
|
interface NavSection {
|
|
label: string;
|
|
icon: React.ElementType;
|
|
items: NavItem[];
|
|
}
|
|
|
|
// ─── Nav structure ────────────────────────────────────────────────────────────
|
|
|
|
const NAV_SECTIONS: NavSection[] = [
|
|
{
|
|
label: "Workloads",
|
|
icon: Layers,
|
|
items: [
|
|
{ id: "pods", label: "Pods" },
|
|
{ id: "deployments", label: "Deployments" },
|
|
{ id: "daemonsets", label: "Daemon Sets" },
|
|
{ id: "statefulsets", label: "Stateful Sets" },
|
|
{ id: "replicasets", label: "Replica Sets" },
|
|
{ id: "jobs", label: "Jobs" },
|
|
{ id: "cronjobs", label: "Cron Jobs" },
|
|
],
|
|
},
|
|
{
|
|
label: "Services & Networking",
|
|
icon: Network,
|
|
items: [
|
|
{ id: "services", label: "Services" },
|
|
{ id: "ingresses", label: "Ingresses" },
|
|
{ id: "networkpolicies", label: "Network Policies" },
|
|
],
|
|
},
|
|
{
|
|
label: "Config & Storage",
|
|
icon: Database,
|
|
items: [
|
|
{ id: "configmaps", label: "Config Maps" },
|
|
{ id: "secrets", label: "Secrets" },
|
|
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
|
|
{ id: "pvcs", label: "Persistent Volume Claims" },
|
|
{ id: "pvs", label: "Persistent Volumes" },
|
|
{ id: "storageclasses", label: "Storage Classes" },
|
|
{ id: "resourcequotas", label: "Resource Quotas" },
|
|
{ id: "limitranges", label: "Limit Ranges" },
|
|
],
|
|
},
|
|
{
|
|
label: "Access Control",
|
|
icon: Shield,
|
|
items: [
|
|
{ id: "serviceaccounts", label: "Service Accounts" },
|
|
{ id: "roles", label: "Roles" },
|
|
{ id: "clusterroles", label: "Cluster Roles" },
|
|
{ id: "rolebindings", label: "Role Bindings" },
|
|
{ id: "clusterrolebindings", label: "Cluster Role Bindings" },
|
|
],
|
|
},
|
|
{
|
|
label: "Cluster",
|
|
icon: Server,
|
|
items: [
|
|
{ id: "overview", label: "Overview" },
|
|
{ id: "nodes", label: "Nodes" },
|
|
{ id: "events", label: "Events" },
|
|
{ id: "portforwarding", label: "Port Forwarding" },
|
|
],
|
|
},
|
|
];
|
|
|
|
// ─── Resource data union ──────────────────────────────────────────────────────
|
|
|
|
interface ResourceData {
|
|
pods: PodInfo[];
|
|
services: ServiceInfo[];
|
|
deployments: DeploymentInfo[];
|
|
statefulsets: StatefulSetInfo[];
|
|
daemonsets: DaemonSetInfo[];
|
|
replicasets: ReplicaSetInfo[];
|
|
jobs: JobInfo[];
|
|
cronjobs: CronJobInfo[];
|
|
configmaps: ConfigMapInfo[];
|
|
secrets: SecretInfo[];
|
|
nodes: NodeInfo[];
|
|
events: EventInfo[];
|
|
ingresses: IngressInfo[];
|
|
pvcs: PersistentVolumeClaimInfo[];
|
|
pvs: PersistentVolumeInfo[];
|
|
serviceaccounts: ServiceAccountInfo[];
|
|
roles: RoleInfo[];
|
|
clusterroles: ClusterRoleInfo[];
|
|
rolebindings: RoleBindingInfo[];
|
|
clusterrolebindings: ClusterRoleBindingInfo[];
|
|
hpas: HorizontalPodAutoscalerInfo[];
|
|
storageclasses: StorageClassInfo[];
|
|
networkpolicies: NetworkPolicyInfo[];
|
|
resourcequotas: ResourceQuotaInfo[];
|
|
limitranges: LimitRangeInfo[];
|
|
}
|
|
|
|
const EMPTY_RESOURCES: ResourceData = {
|
|
pods: [],
|
|
services: [],
|
|
deployments: [],
|
|
statefulsets: [],
|
|
daemonsets: [],
|
|
replicasets: [],
|
|
jobs: [],
|
|
cronjobs: [],
|
|
configmaps: [],
|
|
secrets: [],
|
|
nodes: [],
|
|
events: [],
|
|
ingresses: [],
|
|
pvcs: [],
|
|
pvs: [],
|
|
serviceaccounts: [],
|
|
roles: [],
|
|
clusterroles: [],
|
|
rolebindings: [],
|
|
clusterrolebindings: [],
|
|
hpas: [],
|
|
storageclasses: [],
|
|
networkpolicies: [],
|
|
resourcequotas: [],
|
|
limitranges: [],
|
|
};
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export function KubernetesPage() {
|
|
const { selectedClusterId, selectedNamespace, setSelectedCluster, setSelectedNamespace } =
|
|
useKubernetesStore();
|
|
|
|
const [kubeconfigs, setKubeconfigs] = useState<KubeconfigInfo[]>([]);
|
|
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
|
|
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
|
const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES);
|
|
const [activeSection, setActiveSection] = useState<ActiveSection>("overview");
|
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
|
Workloads: true,
|
|
"Services & Networking": true,
|
|
"Config & Storage": true,
|
|
"Access Control": true,
|
|
Cluster: true,
|
|
});
|
|
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
|
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
|
|
|
// Track the last loaded section to avoid redundant fetches
|
|
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
|
|
|
// ── Initial data load ──────────────────────────────────────────────────────
|
|
|
|
const loadInitialData = useCallback(async () => {
|
|
try {
|
|
const [kubeconfigsData, portForwardsData] = await Promise.all([
|
|
listKubeconfigsCmd(),
|
|
listPortForwardsCmd(),
|
|
]);
|
|
setKubeconfigs(kubeconfigsData);
|
|
setPortForwards(portForwardsData);
|
|
|
|
const activeConfig = kubeconfigsData.find((c) => c.is_active);
|
|
if (activeConfig && !selectedClusterId) {
|
|
await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {});
|
|
setSelectedCluster(activeConfig.id);
|
|
} else if (selectedClusterId) {
|
|
await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load initial Kubernetes data:", err);
|
|
}
|
|
}, [selectedClusterId, setSelectedCluster]);
|
|
|
|
useEffect(() => {
|
|
loadInitialData();
|
|
}, [loadInitialData]);
|
|
|
|
// ── Load namespaces when cluster changes ──────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!selectedClusterId) return;
|
|
|
|
listNamespacesCmd(selectedClusterId)
|
|
.then(setNamespaces)
|
|
.catch((err) => console.error("Failed to load namespaces:", err));
|
|
}, [selectedClusterId]);
|
|
|
|
// ── Load resource data when section, cluster, or namespace changes ─────────
|
|
|
|
const loadResourceData = useCallback(
|
|
async (section: ActiveSection, clusterId: string, namespace: string) => {
|
|
if (section === "overview" || section === "portforwarding") return;
|
|
|
|
const ns = namespace === "all" ? "" : namespace;
|
|
|
|
setIsLoadingResources(true);
|
|
try {
|
|
switch (section) {
|
|
case "pods":
|
|
setResources((r) => ({ ...r, pods: [] }));
|
|
setResources((r) => ({ ...r }));
|
|
await listPodsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, pods: data }))
|
|
);
|
|
break;
|
|
case "deployments":
|
|
await listDeploymentsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, deployments: data }))
|
|
);
|
|
break;
|
|
case "daemonsets":
|
|
await listDaemonsetsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, daemonsets: data }))
|
|
);
|
|
break;
|
|
case "statefulsets":
|
|
await listStatefulsetsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, statefulsets: data }))
|
|
);
|
|
break;
|
|
case "replicasets":
|
|
await listReplicasetsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, replicasets: data }))
|
|
);
|
|
break;
|
|
case "jobs":
|
|
await listJobsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, jobs: data }))
|
|
);
|
|
break;
|
|
case "cronjobs":
|
|
await listCronjobsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, cronjobs: data }))
|
|
);
|
|
break;
|
|
case "services":
|
|
await listServicesCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, services: data }))
|
|
);
|
|
break;
|
|
case "ingresses":
|
|
await listIngressesCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, ingresses: data }))
|
|
);
|
|
break;
|
|
case "configmaps":
|
|
await listConfigmapsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, configmaps: data }))
|
|
);
|
|
break;
|
|
case "secrets":
|
|
await listSecretsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, secrets: data }))
|
|
);
|
|
break;
|
|
case "hpas":
|
|
await listHorizontalpodautoscalersCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, hpas: data }))
|
|
);
|
|
break;
|
|
case "pvcs":
|
|
await listPersistentvolumeclaimsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, pvcs: data }))
|
|
);
|
|
break;
|
|
case "pvs":
|
|
await listPersistentvolumesCmd(clusterId).then((data) =>
|
|
setResources((r) => ({ ...r, pvs: data }))
|
|
);
|
|
break;
|
|
case "serviceaccounts":
|
|
await listServiceaccountsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, serviceaccounts: data }))
|
|
);
|
|
break;
|
|
case "roles":
|
|
await listRolesCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, roles: data }))
|
|
);
|
|
break;
|
|
case "clusterroles":
|
|
await listClusterrolesCmd(clusterId).then((data) =>
|
|
setResources((r) => ({ ...r, clusterroles: data }))
|
|
);
|
|
break;
|
|
case "rolebindings":
|
|
await listRolebindingsCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, rolebindings: data }))
|
|
);
|
|
break;
|
|
case "clusterrolebindings":
|
|
await listClusterrolebindingsCmd(clusterId).then((data) =>
|
|
setResources((r) => ({ ...r, clusterrolebindings: data }))
|
|
);
|
|
break;
|
|
case "nodes":
|
|
await listNodesCmd(clusterId).then((data) =>
|
|
setResources((r) => ({ ...r, nodes: data }))
|
|
);
|
|
break;
|
|
case "events":
|
|
await listEventsCmd(clusterId, ns || undefined).then((data) =>
|
|
setResources((r) => ({ ...r, events: data }))
|
|
);
|
|
break;
|
|
case "storageclasses":
|
|
await listStorageclassesCmd(clusterId).then((data) =>
|
|
setResources((r) => ({ ...r, storageclasses: data }))
|
|
);
|
|
break;
|
|
case "networkpolicies":
|
|
await listNetworkpoliciesCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, networkpolicies: data }))
|
|
);
|
|
break;
|
|
case "resourcequotas":
|
|
await listResourcequotasCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, resourcequotas: data }))
|
|
);
|
|
break;
|
|
case "limitranges":
|
|
await listLimitrangesCmd(clusterId, ns).then((data) =>
|
|
setResources((r) => ({ ...r, limitranges: data }))
|
|
);
|
|
break;
|
|
}
|
|
lastLoadedRef.current = { section, clusterId, namespace };
|
|
} catch (err) {
|
|
console.error(`Failed to load ${section}:`, err);
|
|
} finally {
|
|
setIsLoadingResources(false);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedClusterId) return;
|
|
loadResourceData(activeSection, selectedClusterId, selectedNamespace);
|
|
}, [activeSection, selectedClusterId, selectedNamespace, loadResourceData]);
|
|
|
|
// ── Keyboard shortcut for CommandPalette ──────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.key === "k") {
|
|
e.preventDefault();
|
|
setIsCommandPaletteOpen((prev) => !prev);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
|
|
|
const handleClusterChange = async (id: string) => {
|
|
try {
|
|
await activateKubeconfigCmd(id);
|
|
await connectClusterFromKubeconfigCmd(id);
|
|
const updated = await listKubeconfigsCmd();
|
|
setKubeconfigs(updated);
|
|
const active = updated.find((c) => c.is_active);
|
|
if (active) {
|
|
setSelectedCluster(active.id);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to activate kubeconfig:", err);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
if (!selectedClusterId) return;
|
|
lastLoadedRef.current = null;
|
|
if (activeSection === "portforwarding") {
|
|
listPortForwardsCmd()
|
|
.then(setPortForwards)
|
|
.catch((err) => console.error("Failed to refresh port forwards:", err));
|
|
return;
|
|
}
|
|
loadResourceData(activeSection, selectedClusterId, selectedNamespace);
|
|
};
|
|
|
|
const handleStopPortForward = async (id: string) => {
|
|
try {
|
|
await stopPortForwardCmd(id);
|
|
setPortForwards((prev) => prev.filter((pf) => pf.id !== id));
|
|
} catch (err) {
|
|
console.error("Failed to stop port forward:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeletePortForward = async (id: string) => {
|
|
try {
|
|
await deletePortForwardCmd(id);
|
|
setPortForwards((prev) => prev.filter((pf) => pf.id !== id));
|
|
} catch (err) {
|
|
console.error("Failed to delete port forward:", err);
|
|
}
|
|
};
|
|
|
|
const handleStartPortForward = async (portForward: Parameters<typeof startPortForwardCmd>[0]) => {
|
|
try {
|
|
const result = await startPortForwardCmd(portForward);
|
|
setPortForwards((prev) => [...prev, result]);
|
|
} catch (err) {
|
|
console.error("Failed to start port forward:", err);
|
|
}
|
|
};
|
|
|
|
const toggleSection = (label: string) => {
|
|
setExpandedSections((prev) => ({ ...prev, [label]: !prev[label] }));
|
|
};
|
|
|
|
const handleNavigate = (section: string) => {
|
|
setActiveSection(section as ActiveSection);
|
|
};
|
|
|
|
// ── Content renderer ──────────────────────────────────────────────────────
|
|
|
|
const renderContent = () => {
|
|
if (!selectedClusterId) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-8">
|
|
<Package className="w-16 h-16 text-muted-foreground" />
|
|
<h2 className="text-2xl font-semibold">No cluster selected</h2>
|
|
<p className="text-muted-foreground max-w-sm">
|
|
Select a cluster from the dropdown above, or upload a kubeconfig file
|
|
in Settings → Kubeconfig to get started.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeSection === "overview") {
|
|
return (
|
|
<ClusterOverview
|
|
clusterId={selectedClusterId}
|
|
clusterName={selectedConfig?.name}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSection === "portforwarding") {
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<PortForwardList
|
|
portForwards={portForwards}
|
|
onStart={() => setIsPortForwardFormOpen(true)}
|
|
onStop={handleStopPortForward}
|
|
onDelete={handleDeletePortForward}
|
|
/>
|
|
<PortForwardForm
|
|
isOpen={isPortForwardFormOpen}
|
|
onClose={() => setIsPortForwardFormOpen(false)}
|
|
onStart={(pf) => {
|
|
setPortForwards((prev) => [...prev, pf]);
|
|
setIsPortForwardFormOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoadingResources) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
|
<p className="text-muted-foreground">Loading resources...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ns = selectedNamespace;
|
|
const cid = selectedClusterId;
|
|
|
|
switch (activeSection) {
|
|
case "pods":
|
|
return <PodList pods={resources.pods} clusterId={cid} namespace={ns} />;
|
|
case "deployments":
|
|
return <DeploymentList deployments={resources.deployments} clusterId={cid} namespace={ns} />;
|
|
case "daemonsets":
|
|
return <DaemonSetList daemonsets={resources.daemonsets} clusterId={cid} namespace={ns} />;
|
|
case "statefulsets":
|
|
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
|
|
case "replicasets":
|
|
return <ReplicaSetList replicaSets={resources.replicasets} _clusterId={cid} _namespace={ns} />;
|
|
case "jobs":
|
|
return <JobList jobs={resources.jobs} _clusterId={cid} _namespace={ns} />;
|
|
case "cronjobs":
|
|
return <CronJobList cronJobs={resources.cronjobs} _clusterId={cid} _namespace={ns} />;
|
|
case "services":
|
|
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
|
|
case "ingresses":
|
|
return <IngressList ingresses={resources.ingresses} _clusterId={cid} _namespace={ns} />;
|
|
case "configmaps":
|
|
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
|
|
case "secrets":
|
|
return <SecretList secrets={resources.secrets} _clusterId={cid} _namespace={ns} />;
|
|
case "hpas":
|
|
return <HPAList hpas={resources.hpas} _clusterId={cid} _namespace={ns} />;
|
|
case "pvcs":
|
|
return <PVCList pvcs={resources.pvcs} _clusterId={cid} _namespace={ns} />;
|
|
case "pvs":
|
|
return <PVList pvs={resources.pvs} _clusterId={cid} />;
|
|
case "serviceaccounts":
|
|
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} _clusterId={cid} _namespace={ns} />;
|
|
case "roles":
|
|
return <RoleList roles={resources.roles} _clusterId={cid} _namespace={ns} />;
|
|
case "clusterroles":
|
|
return <ClusterRoleList clusterRoles={resources.clusterroles} _clusterId={cid} />;
|
|
case "rolebindings":
|
|
return <RoleBindingList roleBindings={resources.rolebindings} _clusterId={cid} _namespace={ns} />;
|
|
case "clusterrolebindings":
|
|
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} _clusterId={cid} />;
|
|
case "nodes":
|
|
return <NodeList nodes={resources.nodes} clusterId={cid} />;
|
|
case "events":
|
|
return <EventList events={resources.events} clusterId={cid} namespace={ns} />;
|
|
case "storageclasses":
|
|
return <StorageClassList storageclasses={resources.storageclasses} clusterId={cid} namespace={ns} />;
|
|
case "networkpolicies":
|
|
return <NetworkPolicyList networkpolicies={resources.networkpolicies} clusterId={cid} namespace={ns} />;
|
|
case "resourcequotas":
|
|
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
|
|
case "limitranges":
|
|
return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
const selectedConfig = kubeconfigs.find((c) => c.id === selectedClusterId);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background">
|
|
{/* Hotbar */}
|
|
<Hotbar
|
|
onRefresh={handleRefresh}
|
|
onAddResource={() => setIsCommandPaletteOpen(true)}
|
|
onSettings={() => {}}
|
|
onNotifications={() => setIsNotificationsOpen(true)}
|
|
clusterName={selectedConfig?.name}
|
|
/>
|
|
|
|
{/* Top bar: cluster selector + namespace selector */}
|
|
<div className="flex items-center gap-4 px-4 py-2 border-b bg-card">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
<Select
|
|
value={selectedClusterId ?? ""}
|
|
onValueChange={handleClusterChange}
|
|
>
|
|
<SelectTrigger className="w-52 h-8 text-sm">
|
|
<SelectValue placeholder="Select cluster" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{kubeconfigs.length === 0 ? (
|
|
<SelectItem value="__none__">No kubeconfigs available</SelectItem>
|
|
) : (
|
|
kubeconfigs.map((kc) => (
|
|
<SelectItem key={kc.id} value={kc.id}>
|
|
{kc.name}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedClusterId && (
|
|
<>
|
|
<div className="h-4 w-px bg-border" />
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">Namespace:</span>
|
|
<Select
|
|
value={selectedNamespace}
|
|
onValueChange={setSelectedNamespace}
|
|
>
|
|
<SelectTrigger className="w-44 h-8 text-sm">
|
|
<SelectValue placeholder="All namespaces" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Namespaces</SelectItem>
|
|
{namespaces.map((ns) => (
|
|
<SelectItem key={ns.name} value={ns.name}>
|
|
{ns.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{selectedConfig && (
|
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span className="font-medium">Context:</span>
|
|
<span>{selectedConfig.context}</span>
|
|
{selectedConfig.cluster_url && (
|
|
<>
|
|
<span className="text-border">|</span>
|
|
<span className="font-mono truncate max-w-48">{selectedConfig.cluster_url}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main layout: sidebar + content */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Sidebar */}
|
|
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
|
{NAV_SECTIONS.map((section) => {
|
|
const Icon = section.icon;
|
|
const isExpanded = expandedSections[section.label] ?? true;
|
|
|
|
return (
|
|
<div key={section.label}>
|
|
<button
|
|
onClick={() => toggleSection(section.label)}
|
|
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="w-3.5 h-3.5" />
|
|
<span>{section.label}</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3 h-3" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="pb-1">
|
|
{section.items.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => setActiveSection(item.id)}
|
|
aria-label={item.label}
|
|
className={`flex items-center w-full px-5 py-1.5 text-sm transition-colors ${
|
|
activeSection === item.id
|
|
? "bg-primary/10 text-primary font-medium border-l-2 border-primary"
|
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
}`}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Add resource shortcut at bottom of sidebar */}
|
|
<div className="mt-auto border-t p-3">
|
|
<button
|
|
onClick={() => setIsCommandPaletteOpen(true)}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>Command Palette</span>
|
|
<kbd className="ml-auto text-[10px] bg-muted border rounded px-1 py-0.5">⌃K</kbd>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 overflow-y-auto bg-background">
|
|
{renderContent()}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Command Palette */}
|
|
<CommandPalette
|
|
isOpen={isCommandPaletteOpen}
|
|
onClose={() => setIsCommandPaletteOpen(false)}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
|
|
{/* Notifications panel */}
|
|
<Dialog open={isNotificationsOpen} onOpenChange={setIsNotificationsOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Notifications</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
{selectedConfig ? (
|
|
<div className="text-sm">
|
|
<p className="font-medium mb-1">Active cluster</p>
|
|
<p className="text-muted-foreground">{selectedConfig.context}</p>
|
|
{selectedConfig.cluster_url && (
|
|
<p className="font-mono text-xs text-muted-foreground mt-0.5 truncate">
|
|
{selectedConfig.cluster_url}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No cluster connected.</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground pt-2 border-t">
|
|
Navigate to <strong>Cluster → Events</strong> to view live cluster events.
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Port Forward Form (only rendered outside portforwarding section via global trigger) */}
|
|
{activeSection !== "portforwarding" && (
|
|
<PortForwardForm
|
|
isOpen={isPortForwardFormOpen}
|
|
onClose={() => setIsPortForwardFormOpen(false)}
|
|
onStart={(pf) => {
|
|
void handleStartPortForward({
|
|
cluster_id: pf.cluster_id,
|
|
namespace: pf.namespace,
|
|
pod: pf.pod,
|
|
container_port: pf.container_ports[0] ?? 80,
|
|
});
|
|
setIsPortForwardFormOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|