Skip to content

Commit 2adeb31

Browse files
committed
Add guarded workload scaling
1 parent bb1381c commit 2adeb31

6 files changed

Lines changed: 257 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ The product moat is the interface: a simple native control-plane lens, macOS gla
2727
- Live Kubernetes resource inventory with namespace, status, label, search, sorting, and pressure signals.
2828
- Persistent pinned resources for fast returns to active incidents.
2929
- Grouped drilldowns from namespaces, services, workloads, and pods.
30-
- Pod debugging workspace with status, containers, mounted dependencies, events, searchable source- and level-filtered logs, exec and port-forward handoff, guarded restart, and guarded delete.
30+
- Pod debugging workspace with status, containers, mounted dependencies, events, searchable source- and level-filtered logs, exec and port-forward handoff, guarded restart/scale, and guarded delete.
3131
- Local-first architecture with no cluster-side agent.
3232
- Guarded write model for risky Kubernetes actions.
3333

dev/kubeSnapshot.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,25 @@ export async function runPodAction(input: {
427427
.then((output) => podActionResult(action, "executed", "Action completed.", output, command))
428428
.catch((error) => podActionResult(action, "failed", errorMessage(error), "", command));
429429
}
430+
if (actionName === "scale" && isScalableWorkloadKind(target.kind)) {
431+
const replicas = requestedReplicasForScaleAction(action);
432+
if (replicas === null) {
433+
return podActionResult(action, "blocked", "Scale replicas must be a whole number.");
434+
}
435+
if (!isLocalContext(target.cluster)) {
436+
return podActionResult(action, "blocked", `${target.cluster} is not recognized as a local context.`);
437+
}
438+
439+
const args = scaleArgs(target.kind, target.name, target.namespace, replicas);
440+
const command = displayKubectlCommand(args, target.cluster);
441+
if (!input.confirmed) {
442+
return podActionResult(action, "blocked", `Confirm to scale ${target.namespace}/${target.name} to ${replicas} replicas.`, "", command, true);
443+
}
444+
445+
return kubectlText(args, target.cluster)
446+
.then((output) => podActionResult(action, "executed", "Scale completed.", output, command))
447+
.catch((error) => podActionResult(action, "failed", errorMessage(error), "", command));
448+
}
430449

431450
return podActionResult(action, "blocked", "This action only runs against pods.");
432451
}
@@ -524,6 +543,21 @@ function requestedContainerForExecAction(action: string) {
524543
return container ? container : null;
525544
}
526545

546+
function requestedReplicasForScaleAction(action: string) {
547+
const delimiter = action.indexOf(":");
548+
if (delimiter === -1) {
549+
return null;
550+
}
551+
552+
const value = action.slice(delimiter + 1);
553+
if (!/^\d+$/.test(value)) {
554+
return null;
555+
}
556+
557+
const replicas = Number(value);
558+
return Number.isInteger(replicas) && replicas >= 0 ? replicas : null;
559+
}
560+
527561
function execOpenedMessage(container: string | undefined) {
528562
return container
529563
? `Opened Terminal with an interactive shell in container ${container}.`
@@ -1334,10 +1368,18 @@ function isRestartableWorkloadKind(kind: string) {
13341368
return ["Deployment", "StatefulSet", "DaemonSet"].includes(kind);
13351369
}
13361370

1371+
function isScalableWorkloadKind(kind: string) {
1372+
return ["Deployment", "StatefulSet", "ReplicaSet"].includes(kind);
1373+
}
1374+
13371375
function rolloutRestartArgs(kind: string, name: string, namespace: string) {
13381376
return ["rollout", "restart", `${kind.toLowerCase()}/${name}`, "-n", namespace];
13391377
}
13401378

1379+
function scaleArgs(kind: string, name: string, namespace: string, replicas: number) {
1380+
return ["scale", `${kind.toLowerCase()}/${name}`, `--replicas=${replicas}`, "-n", namespace];
1381+
}
1382+
13411383
async function firstPodPort(target: { name: string; namespace: string; cluster: string }) {
13421384
const pod = await kubectlJson<KubeItem>(["get", "pod", target.name, "-n", target.namespace, "-o", "json"], target.cluster);
13431385
const ports = [

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Kite is a native Kubernetes GUI focused on interface quality, speed, and local-f
55
## What
66

77
- Tauri 2 desktop app with Rust backend and React frontend.
8-
- Multi-cluster Kubernetes cockpit with kubeconfig context switching, scoped resource exploration, pinned resources, keyboard-operable windowed sortable signal-aware inventory with selected-pod log jump actions, inline pod and service backend diagnostics, namespace ResourceQuota and LimitRange constraint visibility, HPA scale-target links, pod ServiceAccount identity links, ConfigMap/Secret/PVC/ServiceAccount consumer links, PriorityClass and RuntimeClass placement links, PodDisruptionBudget availability links, NetworkPolicy-to-pod visibility, exact Ingress/HTTPRoute-to-service topology links, CRD group/scope/version inspection, pod-to-node and node-to-pod debugging jumps, node condition/capacity inspection, pod-level status diagnostics, pod placement constraints, pod condition diagnostics, newest-first warning-prioritized pod events, exec and port-forward handoff, guarded pod actions, guarded workload rollout restart, searchable log workflows, YAML/diff inspection, and a simple visual control-plane map.
8+
- Multi-cluster Kubernetes cockpit with kubeconfig context switching, scoped resource exploration, pinned resources, keyboard-operable windowed sortable signal-aware inventory with selected-pod log jump actions, inline pod and service backend diagnostics, namespace ResourceQuota and LimitRange constraint visibility, HPA scale-target links, pod ServiceAccount identity links, ConfigMap/Secret/PVC/ServiceAccount consumer links, PriorityClass and RuntimeClass placement links, PodDisruptionBudget availability links, NetworkPolicy-to-pod visibility, exact Ingress/HTTPRoute-to-service topology links, CRD group/scope/version inspection, pod-to-node and node-to-pod debugging jumps, node condition/capacity inspection, pod-level status diagnostics, pod placement constraints, pod condition diagnostics, newest-first warning-prioritized pod events, exec and port-forward handoff, guarded pod actions, guarded workload rollout restart and scaling, searchable log workflows, YAML/diff inspection, and a simple visual control-plane map.
99
- UI-first design system inspired by Endex's dense enterprise sections: dark green-black glass, thin grid lines, pale telemetry surfaces, emerald accents, and precise motion.
1010

1111
## Why

src-tauri/src/kube_commands.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,16 @@ pub async fn pod_action(action: String, target: ActionTarget, confirmed: bool) -
353353
let is_local = is_local_context(&target.cluster);
354354
return guarded_workload_restart(normalized, target, confirmed, is_local).await;
355355
}
356+
if action_name == "scale" && is_scalable_workload_kind(&target.kind) {
357+
let replicas = match requested_replicas_for_scale_action(&normalized) {
358+
Ok(replicas) => replicas,
359+
Err(error) => {
360+
return pod_action_result(normalized, PodActionStatus::Blocked, error, String::new(), String::new(), false);
361+
}
362+
};
363+
let is_local = is_local_context(&target.cluster);
364+
return guarded_workload_scale(normalized, target, replicas, confirmed, is_local).await;
365+
}
356366
if action_name == "trigger-cronjob" && target.kind == "CronJob" {
357367
let is_local = is_local_context(&target.cluster);
358368
return guarded_cronjob_trigger(normalized, target, confirmed, is_local).await;
@@ -473,6 +483,19 @@ fn requested_port_for_action(action: &str) -> Result<Option<u16>, String> {
473483
.ok_or_else(|| format!("Invalid pod port for {action}."))
474484
}
475485

486+
fn requested_replicas_for_scale_action(action: &str) -> Result<u32, String> {
487+
let Some((name, replicas)) = action.split_once(':') else {
488+
return Err("Scale requires a target replica count.".to_string());
489+
};
490+
if name != "scale" {
491+
return Err("Scale requires a target replica count.".to_string());
492+
}
493+
494+
replicas
495+
.parse::<u32>()
496+
.map_err(|_| "Scale replicas must be a whole number.".to_string())
497+
}
498+
476499
fn requested_container_for_exec_action(action: &str) -> Result<Option<String>, String> {
477500
let Some((name, container)) = action.split_once(':') else {
478501
return Ok(None);
@@ -1093,7 +1116,8 @@ fn resource_quantity(value: &serde_json::Value) -> Option<String> {
10931116
}
10941117

10951118
fn classify_action(action: &str) -> ActionRisk {
1096-
match action {
1119+
let action_name = action.split_once(':').map(|(name, _)| name).unwrap_or(action);
1120+
match action_name {
10971121
"delete" | "apply" | "edit" | "scale" | "set-image" | "rollback" => ActionRisk::High,
10981122
"restart" | "debug" | "exec" | "node-shell" | "port-forward" | "trigger-cronjob" => ActionRisk::Medium,
10991123
_ => ActionRisk::Low,
@@ -1179,6 +1203,38 @@ async fn guarded_workload_restart(action: String, target: ActionTarget, confirme
11791203
}
11801204
}
11811205

1206+
async fn guarded_workload_scale(action: String, target: ActionTarget, replicas: u32, confirmed: bool, is_local: bool) -> PodActionResult {
1207+
if !is_local {
1208+
return pod_action_result(
1209+
action,
1210+
PodActionStatus::Blocked,
1211+
format!("{} is blocked because {} is not recognized as a local context.", target.name, target.cluster),
1212+
String::new(),
1213+
String::new(),
1214+
false,
1215+
);
1216+
}
1217+
1218+
let command = scale_args(&target.kind, &target.name, &target.namespace, replicas);
1219+
let display_command = display_kubectl_command(&target, &command);
1220+
1221+
if !confirmed {
1222+
return pod_action_result(
1223+
action,
1224+
PodActionStatus::Blocked,
1225+
format!("Confirm to scale {}/{} to {replicas} replicas on {}.", target.namespace, target.name, target.cluster),
1226+
String::new(),
1227+
display_command,
1228+
true,
1229+
);
1230+
}
1231+
1232+
match kubectl(kubectl_target_args(&target, command)).await {
1233+
Ok(output) => pod_action_result(action, PodActionStatus::Executed, "Scale completed.".to_string(), output, display_command, false),
1234+
Err(error) => pod_action_result(action, PodActionStatus::Failed, error, String::new(), display_command, false),
1235+
}
1236+
}
1237+
11821238
async fn guarded_cronjob_trigger(action: String, target: ActionTarget, confirmed: bool, is_local: bool) -> PodActionResult {
11831239
if !is_local {
11841240
return pod_action_result(
@@ -1282,6 +1338,10 @@ fn is_restartable_workload_kind(kind: &str) -> bool {
12821338
matches!(kind, "Deployment" | "StatefulSet" | "DaemonSet")
12831339
}
12841340

1341+
fn is_scalable_workload_kind(kind: &str) -> bool {
1342+
matches!(kind, "Deployment" | "StatefulSet" | "ReplicaSet")
1343+
}
1344+
12851345
fn rollout_restart_args_for_owner(owner: &OwnerRef, namespace: &str) -> Result<Vec<String>, String> {
12861346
if matches!(owner.kind.as_str(), "Deployment" | "StatefulSet" | "DaemonSet") {
12871347
return Ok(rollout_restart_args(&owner.kind, &owner.name, namespace));
@@ -1300,6 +1360,16 @@ fn rollout_restart_args(kind: &str, name: &str, namespace: &str) -> Vec<String>
13001360
]
13011361
}
13021362

1363+
fn scale_args(kind: &str, name: &str, namespace: &str, replicas: u32) -> Vec<String> {
1364+
vec![
1365+
"scale".to_string(),
1366+
format!("{}/{}", kind.to_lowercase(), name),
1367+
format!("--replicas={replicas}"),
1368+
"-n".to_string(),
1369+
namespace.to_string(),
1370+
]
1371+
}
1372+
13031373
fn cronjob_trigger_args(name: &str, namespace: &str, timestamp_seconds: u64) -> Vec<String> {
13041374
let job_name = cronjob_manual_job_name(name, timestamp_seconds);
13051375
vec![
@@ -4889,6 +4959,14 @@ mod tests {
48894959
assert!(requested_port_for_action("port-forward:http").is_err());
48904960
}
48914961

4962+
#[test]
4963+
fn requested_replicas_for_scale_action_reads_replica_count() {
4964+
assert_eq!(requested_replicas_for_scale_action("scale:3"), Ok(3));
4965+
assert_eq!(requested_replicas_for_scale_action("scale:0"), Ok(0));
4966+
assert!(requested_replicas_for_scale_action("scale").is_err());
4967+
assert!(requested_replicas_for_scale_action("scale:two").is_err());
4968+
}
4969+
48924970
#[test]
48934971
fn requested_container_for_exec_action_reads_container_name() {
48944972
assert_eq!(requested_container_for_exec_action("exec:sidecar"), Ok(Some("sidecar".to_string())));
@@ -4939,6 +5017,25 @@ mod tests {
49395017
);
49405018
}
49415019

5020+
#[test]
5021+
fn scale_args_support_scalable_workloads() {
5022+
assert!(is_scalable_workload_kind("Deployment"));
5023+
assert!(is_scalable_workload_kind("StatefulSet"));
5024+
assert!(is_scalable_workload_kind("ReplicaSet"));
5025+
assert!(!is_scalable_workload_kind("DaemonSet"));
5026+
5027+
assert_eq!(
5028+
scale_args("Deployment", "api", "default", 4),
5029+
vec![
5030+
"scale".to_string(),
5031+
"deployment/api".to_string(),
5032+
"--replicas=4".to_string(),
5033+
"-n".to_string(),
5034+
"default".to_string(),
5035+
]
5036+
);
5037+
}
5038+
49425039
#[test]
49435040
fn cronjob_trigger_args_create_timestamped_manual_job() {
49445041
assert_eq!(

src/components/ResourceDetail.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type ResourceDetailProps = {
3737
};
3838
type CopyStatus = "idle" | "copied" | "failed";
3939
const restartableWorkloadKinds = new Set(["Deployment", "StatefulSet", "DaemonSet"]);
40+
const scalableWorkloadKinds = new Set(["Deployment", "StatefulSet", "ReplicaSet"]);
4041

4142
export function ResourceDetail({
4243
allResources,
@@ -59,6 +60,8 @@ export function ResourceDetail({
5960
const forwardedPorts = useMemo(() => podPorts(details), [details]);
6061
const terminalRef = useRef<HTMLElement>(null);
6162
const [terminalRequest, setTerminalRequest] = useState({ id: 0, mode: "current" as LogMode, source: "" });
63+
const [replicaInput, setReplicaInput] = useState("");
64+
const replicaCount = parseReplicaInput(replicaInput);
6265

6366
useEffect(() => {
6467
if (!isPod) {
@@ -108,6 +111,10 @@ export function ResourceDetail({
108111
scrollToTerminal();
109112
}, [initialFocus, isPod, resource.id]);
110113

114+
useEffect(() => {
115+
setReplicaInput(defaultReplicaInput(resource));
116+
}, [resource.id]);
117+
111118
return (
112119
<section className="detail-workspace">
113120
<header className="detail-hero">
@@ -198,12 +205,26 @@ export function ResourceDetail({
198205
</>
199206
) : null}
200207

201-
{!isPod && restartableWorkloadKinds.has(resource.kind) ? (
202-
<div className="pod-actions" aria-label={`${resource.kind} actions`}>
203-
<button type="button" onClick={() => onRunPodAction("restart")}>
204-
<RotateCw size={15} />
205-
Restart
206-
</button>
208+
{!isPod && (restartableWorkloadKinds.has(resource.kind) || scalableWorkloadKinds.has(resource.kind)) ? (
209+
<div className="pod-actions workload-actions" aria-label={`${resource.kind} actions`}>
210+
{restartableWorkloadKinds.has(resource.kind) ? (
211+
<button type="button" onClick={() => onRunPodAction("restart")}>
212+
<RotateCw size={15} />
213+
Restart
214+
</button>
215+
) : null}
216+
{scalableWorkloadKinds.has(resource.kind) ? (
217+
<ScaleControl
218+
replicaCount={replicaCount}
219+
value={replicaInput}
220+
onChange={setReplicaInput}
221+
onScale={() => {
222+
if (replicaCount !== null) {
223+
onRunPodAction(`scale:${replicaCount}`);
224+
}
225+
}}
226+
/>
227+
) : null}
207228
</div>
208229
) : null}
209230

@@ -673,6 +694,54 @@ function resourceName(name: string) {
673694
return name.includes("/") ? name.split("/").pop() ?? name : name;
674695
}
675696

697+
function ScaleControl({
698+
onChange,
699+
onScale,
700+
replicaCount,
701+
value,
702+
}: {
703+
onChange: (value: string) => void;
704+
onScale: () => void;
705+
replicaCount: number | null;
706+
value: string;
707+
}) {
708+
return (
709+
<span className="scale-control">
710+
<Gauge size={15} />
711+
<input
712+
aria-label="Target replicas"
713+
inputMode="numeric"
714+
min={0}
715+
pattern="[0-9]*"
716+
placeholder="replicas"
717+
type="number"
718+
value={value}
719+
onChange={(event) => onChange(event.target.value)}
720+
/>
721+
<button disabled={replicaCount === null} type="button" onClick={onScale}>
722+
Scale
723+
</button>
724+
</span>
725+
);
726+
}
727+
728+
function parseReplicaInput(value: string) {
729+
if (!/^\d+$/.test(value.trim())) {
730+
return null;
731+
}
732+
733+
const replicas = Number(value);
734+
return Number.isSafeInteger(replicas) ? replicas : null;
735+
}
736+
737+
function defaultReplicaInput(resource: ResourceRow) {
738+
if (!scalableWorkloadKinds.has(resource.kind)) {
739+
return "";
740+
}
741+
742+
return resource.diagnostic.match(/^\d+\/(\d+) ready$/)?.[1] ?? "";
743+
}
744+
676745
function ContainerStateFact({
677746
hint,
678747
label,
@@ -803,7 +872,7 @@ function actionMessage(result: PodActionResult) {
803872

804873
function actionRisk(action: string) {
805874
const [base] = action.split(":", 1);
806-
if (base === "delete" || base === "kill") {
875+
if (base === "delete" || base === "kill" || base === "scale") {
807876
return "high";
808877
}
809878
if (base === "restart" || base === "exec" || base === "port-forward" || base === "trigger-cronjob") {

0 commit comments

Comments
 (0)