Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

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

40 changes: 40 additions & 0 deletions src-tauri/src/frontend_commands/discover_contexts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub async fn discover_contexts(
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
warn!(
"Failed to scan directory {:?} for kubeconfigs: {e}",
Expand All @@ -80,3 +81,42 @@ pub async fn discover_contexts(

Ok(contexts)
}

#[tauri::command]
#[tracing::instrument(skip_all, fields(request_id = tracing::field::Empty))]
pub async fn get_kubeconfig_yaml(context_source: KubeContextSource) -> Result<String, String> {
crate::internal::tracing::set_span_request_id();

if context_source.provider != "file" {
return Err("Unsupport provider".to_owned());
}

let path = context_source.source;

let file_contents = tokio::fs::read_to_string(path)
.await
.map_err(|e| e.to_string())?;

Ok(file_contents)
}

#[tauri::command]
#[tracing::instrument(skip_all, fields(request_id = tracing::field::Empty))]
pub async fn write_kubeconfig_yaml(
context_source: KubeContextSource,
yaml: String,
) -> Result<(), String> {
crate::internal::tracing::set_span_request_id();

if context_source.provider != "file" {
return Err("Unsupport provider".to_owned());
}

let path = context_source.source;

tokio::fs::write(path, yaml)
.await
.map_err(|e| e.to_string())?;

Ok(())
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub fn run() {
frontend_commands::watch_namespaces,
frontend_commands::cleanup_channel,
frontend_commands::discover_contexts,
frontend_commands::get_kubeconfig_yaml,
frontend_commands::write_kubeconfig_yaml,
frontend_commands::delete_resource,
frontend_commands::list_resource_views,
frontend_commands::pod_exec_start_session,
Expand Down
4 changes: 3 additions & 1 deletion src/components/ClusterCard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SettingOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { CSSProperties, forwardRef, ReactNode } from 'react';
import StatusBox from '../StatusBox';
import styles from './styles.module.css';
Expand Down Expand Up @@ -42,7 +44,7 @@ const ClusterCard = forwardRef<HTMLDivElement, ClusterCardProps>(function Cluste
{
onSettingsClicked && (
<div className={styles.buttonArea}>
<button className={styles.settingsButton} onClick={onSettingsClicked}>⚙️</button>
<Button variant="dashed" className={styles.settingsButton} onClick={onSettingsClicked} icon={<SettingOutlined />} />
</div>
)
}
Expand Down
10 changes: 10 additions & 0 deletions src/components/ClusterSettings/KubeconfigEditor.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.kubeconfigEditorContainer {
display: flex;
flex-direction: column;
height: 100%;
}

.editorWrapper {
min-height: 0;
flex-grow: 1;
}
66 changes: 66 additions & 0 deletions src/components/ClusterSettings/KubeconfigEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { KubeContextSource } from "../../hooks/useContextDiscovery";
import EditorWithToolbar from "../EditorWithToolbar";
import styles from './KubeconfigEditor.module.css';

export interface KubeconfigEditorProps {
contextSource: KubeContextSource,
onDirty?: () => void,
onDirtyCleared?: () => void,
}

export default function KubeconfigEditor({
contextSource,
onDirty,
onDirtyCleared
}: KubeconfigEditorProps) {
const queryClient = useQueryClient();

const kubeconfig = useQuery({
queryKey: ['get_kubeconfig_yaml', contextSource],
queryFn: () => invoke<string>('get_kubeconfig_yaml', { contextSource }),
});

const kubeconfigMutation = useMutation({
mutationFn: (newYaml: string) => {
return invoke('write_kubeconfig_yaml', { contextSource, yaml: newYaml });
},
onSuccess: async () => {
// Invalidate all queries because kubeconfig might be shared!
await queryClient.invalidateQueries({ queryKey: ['getApiServerGitVersion'] });
}
});

async function discard() {
const refetched = await kubeconfig.refetch();

if (refetched.isSuccess) {
return refetched.data;
}

throw new Error("Refetch failed");
}

if (kubeconfig.isPending || kubeconfig.data === undefined) return;

return (
<div className={styles.kubeconfigEditorContainer}>
<h2>Edit Kubeconfig - {contextSource.source}</h2>
<div className={styles.editorWrapper}>
<EditorWithToolbar
language="yaml"
value={kubeconfig.data}
withSaveButton
withDiscardButton
withWordWrapToggle
withWhiteSpaceToggle
onSave={(contents) => kubeconfigMutation.mutate(contents)}
onDiscard={discard}
onDirty={onDirty}
onDirtyCleared={onDirtyCleared}
/>
</div>
</div>
);
}
47 changes: 47 additions & 0 deletions src/components/ClusterSettings/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Activity } from "react";
import { KubeContextSource } from "../../hooks/useContextDiscovery";
import { TabIdentifier, useHeadlessTabs } from "../../hooks/useHeadlessTabs";
import KubeconfigEditor from "./KubeconfigEditor";
import styles from './styles.module.css';

export interface ClusterSettingsProps {
contextSource: KubeContextSource,
onDirty?: () => void,
onDirtyCleared?: () => void,
}

export default function ClusterSettings({
contextSource,
onDirty,
onDirtyCleared
}: ClusterSettingsProps) {
const tabs = useHeadlessTabs([
{ meta: { title: '📝 Kubeconfig' }, render: () => <KubeconfigEditor contextSource={contextSource} onDirty={onDirty} onDirtyCleared={onDirtyCleared} /> },
]);

return (
<div className={styles.clusterSettingsContainer}>
<nav>
<ul>
{
Object.entries(tabs.tabState).map(([id, tab]) => (
<li key={id}
onClick={() => tabs.switchTab(id as TabIdentifier)}
className={tabs.activeTab === id ? styles.active : ''}
>
{tab.meta.title}
</li>
))
}
</ul>
</nav>
<main>{
Object.entries(tabs.tabState).map(([id, tab]) => (
<Activity key={id} mode={id === tabs.activeTab ? 'visible' : 'hidden'}>
{tab.render()}
</Activity>
))
}</main>
</div >
);
}
47 changes: 47 additions & 0 deletions src/components/ClusterSettings/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.clusterSettingsContainer {
height: 100%;
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
}

.clusterSettingsContainer nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}

.clusterSettingsContainer nav ul li {
padding: 0.4em 0.8em;
padding-right: 3em;
margin-top: 0.5em;
margin-bottom: 0.5em;
border-right: 5px transparent solid;
}

.clusterSettingsContainer nav ul li:not(.active):hover {
background-color: rgba(255, 255, 255, 0.075);
}

.clusterSettingsContainer nav ul li:first-child {
margin-top: 0;
}

.clusterSettingsContainer nav ul li:last-child {
margin-bottom: 0;
}

.clusterSettingsContainer nav ul li.active {
border-right-color: dodgerblue;
background-color: rgba(255, 255, 255, 0.1);
}

.clusterSettingsContainer main {
max-height: 100%;
overflow: scroll;
}

.clusterSettingsContainer h2 {
margin: 0;
margin-bottom: 0.2em;
}
116 changes: 116 additions & 0 deletions src/components/EditorWithToolbar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Editor } from '@monaco-editor/react';
import { Button, Checkbox, Space } from 'antd';
import { editor } from 'monaco-editor';
import { useRef, useState } from 'react';
import styles from './styles.module.css';

export interface EditorWithToolbarProps {
value: string,
language: string,
readOnly?: boolean,
withSaveButton?: boolean,
withDiscardButton?: boolean,
withWordWrapToggle?: boolean,
withWhiteSpaceToggle?: boolean,
onSave?: (contents: string) => void,
onDiscard?: () => Promise<string>,
onDirty?: () => void,
onDirtyCleared?: () => void,
}

export default function EditorWithToolbar({
value,
language,
readOnly = false,
withSaveButton = false,
withDiscardButton = false,
withWordWrapToggle = false,
withWhiteSpaceToggle = false,
onSave = () => undefined,
onDiscard,
onDirty,
onDirtyCleared
}: EditorWithToolbarProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor>(null);
const modelRef = useRef<string | undefined>(undefined);

const [whiteSpace, setWhiteSpace] = useState(true);
const [wordWrap, setWordWrap] = useState(false);

const [dirty, setDirty] = useState(false);

function handleFileDirty() {
onDirty?.();
setDirty(true);
}

function handleFileDirtyCleared() {
onDirtyCleared?.();
setDirty(false);
}

return (
<div className={styles.editorContainer}>
<div className={styles.editorToolbar}>
<Space>
{
withSaveButton && (
<Button size="small" disabled={!dirty} onClick={() => {
if (modelRef.current) onSave(modelRef.current);
handleFileDirtyCleared();
}}>💾 Save</Button>
)
}
{
withDiscardButton && (
<Button size="small" variant="dashed" color="danger" disabled={!dirty} onClick={() => {
void (async () => {
if (!onDiscard) return;
editorRef.current?.setValue(await onDiscard());
handleFileDirtyCleared();
})();
}}>⬇️ Reset</Button>
)
}
</Space>
<Space>
{
withWhiteSpaceToggle && (
<Checkbox checked={whiteSpace} onChange={(e) => setWhiteSpace(e.target.checked)}>Show whitespace</Checkbox>
)
}
{
withWordWrapToggle && (
<Checkbox checked={wordWrap} onChange={(e) => setWordWrap(e.target.checked)}>Word-wrap</Checkbox>
)
}
</Space>
</div>
<Editor keepCurrentModel
theme="vs-dark"
className={styles.editor}
defaultLanguage={language}
value={value}
options={{
renderWhitespace: whiteSpace ? "all" : "selection",
wordWrap: wordWrap ? 'on' : 'off',
readOnly: readOnly,
scrollBeyondLastLine: false,
minimap: {
enabled: false
}
}}
onMount={(editor) => {
editorRef.current = editor;
if (modelRef.current) {
editor.setValue(modelRef.current);
}
}}
onChange={(x) => {
modelRef.current = x;
if (!dirty) handleFileDirty();
}}
/>
</div>
);
}
Loading