Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better Clusters/Contracts List #76

Merged
merged 11 commits into from
Feb 19, 2025
3 changes: 2 additions & 1 deletion .cursor/rules/cli.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ globs: *.(ts|tsx)
- This is a CLI program written in Deno using the `commander` library and `@commander-js/extra-typings`.

- When using node globals like `console.log`, `setTimeout`, `process`, etc, import them from the corresponding explicit `node:console`, `node:timers`, and `node:process`, etc.
- Where possible, use the `openapi-typescript`/`openapi-fetch` client instead of writing raw fetch calls.
- Where possible, use the `openapi-typescript`/`openapi-fetch` client instead of writing raw fetch calls.
- <Badge> renders the content as uppercase so tests should assert on the uppercase text.
59 changes: 57 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"dependencies": {
"@commander-js/extra-typings": "^12.1.0",
"@inkjs/ui": "^2.0.0",
"@inquirer/prompts": "^5.1.2",
"@types/ms": "^0.7.34",
"axios": "^1.7.2",
Expand All @@ -22,8 +23,10 @@
"ink": "^5.0.1",
"ink-confirm-input": "^2.0.0",
"ink-spinner": "^5.0.0",
"ink-testing-library": "^4.0.0",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: ink-testing-library should be in devDependencies since it's only used for testing

Suggested change
"ink-testing-library": "^4.0.0",
"ink-testing-library": "^4.0.0",

"ink-text-input": "^6.0.0",
"inquirer": "^10.1.2",
"little-date": "^1.0.0",
"ms": "^2.1.3",
"node-fetch": "^3.3.2",
"openapi-fetch": "^0.11.1",
Expand Down
151 changes: 125 additions & 26 deletions src/lib/clusters/clusters.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import type { Command } from "@commander-js/extra-typings";
import * as console from "node:console";
import { clearInterval, setInterval, setTimeout } from "node:timers";
import { Badge } from "@inkjs/ui";
import { Box, render, Text, useApp } from "ink";
import Spinner from "ink-spinner";
import * as console from "node:console";
import { clearInterval, setInterval, setTimeout } from "node:timers";
// biome-ignore lint/style/useImportType: <explanation>
import React, { useEffect, useState } from "react";
import yaml from "yaml";
import { apiClient } from "../../apiClient.ts";
import { logAndQuit } from "../../helpers/errors.ts";
import { Row } from "../Row.tsx";
import {
createIntervalData,
IntervalDisplay,
} from "../contracts/ContractDisplay.tsx";
import { decryptSecret, getKeys, regenerateKeys } from "./keys.tsx";
import {
createKubeconfig,
KUBECONFIG_PATH,
syncKubeconfig,
} from "./kubeconfig.ts";
import { MOCK_CLUSTERS } from "./mock.ts";
import type { UserFacingCluster } from "./types.ts";

export function registerClusters(program: Command) {
const clusters = program
Expand Down Expand Up @@ -96,33 +104,124 @@ export function registerClusters(program: Command) {
function ClusterDisplay({
clusters,
}: {
clusters: Array<{
name: string;
kubernetes_api_url: string;
kubernetes_namespace: string;
}>;
clusters: Array<UserFacingCluster>;
}) {
return (
<Box flexDirection="column">
<Box flexDirection="column" gap={2}>
{clusters.map((cluster, index) => (
<Box key={`${cluster.name}-${index}`} flexDirection="column">
<Row headWidth={11} head="name" value={cluster.name} />
<Row
headWidth={11}
head="k8s api"
value={cluster.kubernetes_api_url}
/>
<Row
headWidth={11}
head="namespace"
value={cluster.kubernetes_namespace}
/>
</Box>
<ClusterRow cluster={cluster} key={`${cluster.name}-${index}`} />
))}
</Box>
);
}

const ClusterRow = ({ cluster }: { cluster: UserFacingCluster }) => {
if (cluster.contract) {
return <ClusterRowWithContracts cluster={cluster} />;
}

return (
<Box flexDirection="column">
<Row headWidth={11} head="name" value={cluster.name} />
<Row
headWidth={11}
head="k8s api"
value={cluster.kubernetes_api_url || ""}
/>
<Row
headWidth={11}
head="namespace"
value={cluster.kubernetes_namespace}
/>
</Box>
);
};

const COLUMN_WIDTH = 11;

const ClusterRowWithContracts = (
{ cluster }: {
cluster: UserFacingCluster;
},
) => {
if (!cluster.contract) {
return null;
}

const startsAt = new Date(cluster.contract.shape.intervals[0]);
const endsAt = new Date(
cluster.contract.shape
.intervals[cluster.contract.shape.intervals.length - 1],
);
const now = new Date();
let color: React.ComponentProps<typeof Badge>["color"] | undefined;
let statusIcon: React.ReactNode;
if (startsAt > now) {
statusIcon = <Badge color="green">Upcoming</Badge>;
color = "green";
} else if (endsAt < now) {
color = "gray";
statusIcon = <Badge color="gray">Expired</Badge>;
} else {
color = "cyan";
statusIcon = <Badge color="cyan">Active</Badge>;
}

const intervalData = createIntervalData(
cluster.contract?.shape,
cluster.contract?.instance_type,
);

return (
<Box flexDirection="column" gap={1}>
<Box gap={1}>
<Text>{statusIcon}</Text>
<Text color={color}>{cluster.contract.id}</Text>
</Box>

<Box flexDirection="column">
<Row headWidth={COLUMN_WIDTH} head="Name" value={cluster.name} />
<Row
headWidth={COLUMN_WIDTH}
head="K8s API"
value={cluster.kubernetes_api_url || ""}
/>
<Row
headWidth={COLUMN_WIDTH}
head="Namespace"
value={cluster.kubernetes_namespace}
/>

<Row
headWidth={COLUMN_WIDTH}
head="Add User"
value={`sf clusters users add --cluster ${cluster.name} --user myuser`}
/>

<Box flexDirection="column">
{intervalData.map((data, index) => {
return (
<Box
key={`${index}-${data.quantity}`}
paddingLeft={index === 0 ? 0 : COLUMN_WIDTH}
>
{index === 0 && (
<Box paddingRight={5}>
<Text dimColor>Orders</Text>
</Box>
)}
<IntervalDisplay
data={data}
/>
</Box>
);
})}
</Box>
</Box>
</Box>
);
};

async function listClustersAction({
returnJson,
token,
Expand Down Expand Up @@ -156,11 +255,11 @@ async function listClustersAction({
} else {
render(
<ClusterDisplay
clusters={data.data.map((cluster) => ({
name: cluster.name,
kubernetes_api_url: cluster.kubernetes_api_url || "",
kubernetes_namespace: cluster.kubernetes_namespace || "",
}))}
clusters={[...data.data, ...MOCK_CLUSTERS].filter((
cluster,
): cluster is UserFacingCluster =>
cluster.contract?.status === "active" || !cluster.contract
)}
/>,
);
}
Expand Down
Loading