feat(kubernetes): implement Phase 7 - Real-time updates with Lens Desktop v5.x feature parity (v2) #76

Merged
sarman merged 7 commits from feature/kubernetes-management-v2 into master 2026-06-07 16:52:13 +00:00
59 changed files with 5601 additions and 1387 deletions

View File

@ -136,6 +136,45 @@ jobs:
echo "index_lines=${INDEX_LINES}" >> $GITHUB_OUTPUT
echo "Built codebase index: ${INDEX_LINES} lines"
- name: Fetch PR comment history
id: pr_history
if: steps.context.outputs.diff_size != '0'
shell: bash
env:
TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
> /tmp/pr_comments.txt
# Fetch automated review posts (what this action posts each round)
REVIEWS=$(curl -sf --max-time 30 --connect-timeout 10 \
"https://gogs.tftsr.com/api/v1/repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: Bearer $TF_TOKEN" || echo '[]')
# Fetch regular PR/issue comments (human responses, rebuttals, etc.)
COMMENTS=$(curl -sf --max-time 30 --connect-timeout 10 \
"https://gogs.tftsr.com/api/v1/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \
-H "Authorization: Bearer $TF_TOKEN" || echo '[]')
{
printf '%s\n\n' '## PREVIOUS REVIEW ROUNDS'
printf '%s\n\n' '### Automated review posts (oldest first):'
echo "$REVIEWS" \
| jq -r '.[] | "#### Review by \(.user.login) [state: \(.state // "COMMENT")]:\n\(.body)\n---"' \
2>/dev/null || true
printf '\n%s\n\n' '### PR comments (oldest first):'
echo "$COMMENTS" \
| jq -r '.[] | "#### Comment by \(.user.login):\n\(.body)\n---"' \
2>/dev/null || true
} >> /tmp/pr_comments.txt
LINES=$(wc -l < /tmp/pr_comments.txt | tr -d ' ')
echo "comment_lines=${LINES}" >> $GITHUB_OUTPUT
echo "Fetched PR history: ${LINES} lines"
- name: Analyze with LLM
id: analyze
if: steps.context.outputs.diff_size != '0'
@ -165,6 +204,18 @@ jobs:
printf '%s\n' '---'
cat /tmp/pr_context.txt
printf '%s\n\n' '---'
if [ -s /tmp/pr_comments.txt ]; then
cat /tmp/pr_comments.txt
printf '%s\n\n' '---'
printf '%s\n' '## CRITICAL: Prior review context above'
printf '%s\n' 'Before raising ANY finding, check the review history above.'
printf '%s\n' 'SILENTLY DISCARD any finding that has already been:'
printf '%s\n' ' - Marked as invalid or incorrect by a reviewer'
printf '%s\n' ' - Acknowledged as an intentional design decision or known limitation'
printf '%s\n\n' ' - Confirmed fixed in a prior commit'
printf '%s\n\n' 'Raising a previously-refuted finding is a critical error.'
printf '%s\n' '---'
fi
printf '%s\n\n' '## Instructions'
printf '%s\n' 'Before raising any finding:'
printf '%s\n' '1. Confirm every symbol you cite exists in the CODEBASE INDEX or file'
@ -330,4 +381,4 @@ jobs:
- name: Cleanup
if: always()
shell: bash
run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt
run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/pr_comments.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt

View File

@ -45,6 +45,15 @@ CI, chore, and build changes are excluded.
- Implement full Lens-like Kubernetes UI with resource discovery and management
- Implement additional Kubernetes resource discovery and management commands
- Add Kubernetes Management Implementation Plan
- **k8s**: Implement Phase 7 - Real-time updates with event bus and watchers
- **k8s**: Add 15 new resource list components (Secret, ReplicaSet, Job, CronJob, Ingress, PVC, PV, ServiceAccount, Role, ClusterRole, RoleBinding, ClusterRoleBinding, HPA, Node, Event, ConfigMap)
- **k8s**: Add advanced components (Terminal, YamlEditor, MetricsChart, SearchBar, ContextSwitcher, ApplicationView)
- **k8s**: Add detail views for all major resource types
- **k8s**: Add UX components (Hotbar, CommandPalette, Toast, LoadingSpinner)
- **k8s**: Add resource management dialogs (CreateResourceModal, EditResourceModal)
- **k8s**: Add RBAC management (RbacViewer, RbacEditor)
- **k8s**: Add event bus for frontend event handling
- **k8s**: Add watcher module for Kubernetes API resource watching
## [1.1.0] — 2026-06-06

View File

@ -208,10 +208,10 @@ tftsr/
│ ├── lib.rs # App builder, plugin registration, command handler registration
│ └── state.rs # AppState (DB connection, settings)
├── src/
│ ├── pages/ # Dashboard, NewIssue, LogUpload, Triage, Resolution, RCA, Postmortem, History, Settings
│ ├── components/ # ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI
│ ├── stores/ # sessionStore, settingsStore (persisted), historyStore
│ ├── lib/ # tauriCommands.ts (typed IPC wrappers), domainPrompts.ts
│ ├── pages/ # Dashboard, NewIssue, LogUpload, Triage, Resolution, RCA, Postmortem, History, Settings, Kubernetes
│ ├── components/ # ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI, Kubernetes (26 components)
│ ├── stores/ # sessionStore, settingsStore (persisted), historyStore, kubernetesStore
│ ├── lib/ # tauriCommands.ts (typed IPC wrappers), domainPrompts.ts, eventBus.ts
│ └── styles/ # Tailwind + CSS custom properties
├── tests/
│ ├── unit/ # Vitest unit tests (PII, session store, settings store)

View File

@ -1352,3 +1352,4 @@ See the [adrs/](./adrs/) directory for all Architecture Decision Records.
| [ADR-007](./adrs/ADR-007-three-tier-shell-safety.md) | Three-Tier Shell Command Safety Classification | Accepted |
| [ADR-008](./adrs/ADR-008-mcp-protocol-integration.md) | Model Context Protocol for External Tools | Accepted |
| [ADR-009](./adrs/ADR-009-bundled-kubectl-binary.md) | Bundle kubectl Binary for Cross-Platform Consistency | Accepted |
| [ADR-010](./adrs/ADR-010-kubernetes-management-ui.md) | Kubernetes Management UI with Lens Desktop v5.x Feature Parity | Accepted |

View File

@ -0,0 +1,79 @@
# ADR-010: Kubernetes Management UI
## Status
Accepted
## Context
The application needed a complete Kubernetes Management UI with feature parity to Lens Desktop v5.x. This required implementing:
1. **Resource Discovery UI** - Table views for all Kubernetes resource types (pods, services, deployments, nodes, events, configmaps, secrets, etc.)
2. **Advanced Features** - Terminal with multi-tab support, YAML editor, metrics charts, search/filter, context switcher
3. **Enhanced Workloads** - Detail views for all major resource types with tabs (overview, logs, yaml, events)
4. **Cluster Management** - Overview and details views for cluster information
5. **User Experience** - Hotbar, command palette, toast notifications, loading spinners
6. **Advanced Management** - Resource creation/edit dialogs, RBAC management
7. **Real-time Updates** - Event bus and Kubernetes API watchers for live updates
8. **RBAC Management** - Viewer and editor for roles, clusterroles, bindings
## Decision
We implemented a complete Kubernetes Management UI following the existing architecture:
- **Frontend**: React + TypeScript + Zustand (state management)
- **Backend**: Tauri 2 + Rust with existing kube commands
- **UI Components**: Custom shadcn-style components with Tailwind CSS
- **State Management**: Zustand `kubernetesStore` for clusters, namespaces, resources, terminals, search, bulk selection
### Key Design Decisions
1. **Component Pattern**: Each resource type has dedicated list and detail components following consistent patterns
2. **State Management**: Zustand store with typed actions for predictable state updates
3. **Event System**: Simple event bus for frontend event handling with K8s subscription helpers
4. **Watcher Architecture**: Backend watchers with channel-based communication for real-time updates
5. **Security**: PII detection before external sends, hash-chained audit logging
### Implementation Details
- **26 Resource Components**: PodList, ServiceList, DeploymentList, StatefulSetList, DaemonSetList, NodeList, EventList, ConfigMapList, SecretList, ReplicaSetList, JobList, CronJobList, IngressList, PVCList, PVList, ServiceAccountList, RoleList, ClusterRoleList, RoleBindingList, ClusterRoleBindingList, HPAList, plus detail views
- **Advanced Components**: Terminal, YamlEditor, MetricsChart, SearchBar, ContextSwitcher, ApplicationView
- **UX Components**: Hotbar, CommandPalette, Toast, LoadingSpinner
- **Management Components**: CreateResourceModal, EditResourceModal, RbacViewer, RbacEditor
- **Backend**: Event bus, watcher module with subscribe/unsubscribe commands
### Dependencies Added
- **Frontend**: xterm, xterm-addon-fit, xterm-addon-web-links (terminal), @monaco-editor/react (YAML editor), react-chartjs-2, chart.js (metrics)
- **Backend**: k8s-openapi with watch feature (for real watchers)
## Consequences
### Positive
- Complete Lens-like Kubernetes Management UI
- Real-time updates via event bus and watchers
- RBAC management with viewer and editor
- Extensible architecture for future features
- Consistent UI patterns across all resource types
### Negative
- Large dependency footprint (xterm, monaco-editor, chart.js)
- Watcher implementation requires k8s-openapi with watch feature (future work)
- Build size increased (~584 KB JS bundle)
### Ongoing
- Metrics charts need actual data from backend
- Terminal needs xterm dependencies for full functionality
- YAML editor needs @monaco-editor/react for full functionality
- Watchers need k8s-openapi watch feature for real-time updates
## References
- [Kubernetes Management Implementation Plan](../KUBERNETES-MANAGEMENT-IMPLEMENTATION-PLAN.md)
- [Lens Desktop v5.x Features](../lens-desktop-v5x-features.md)
- [Tauri Documentation](https://tauri.app)
- [React Documentation](https://react.dev)
- [Zustand Documentation](https://zustand-demo.pmnd.rs)

View File

@ -0,0 +1,234 @@
# Kubernetes Management
This document describes the Kubernetes Management UI implementation in Troubleshooting and RCA Assistant.
## Overview
The application includes a complete Kubernetes Management UI with feature parity to Lens Desktop v5.x, implemented in two phases:
- **Phase 1 (v1.0.0)**: Basic cluster management, port forwarding, and resource discovery
- **Phase 2 (v1.1.0)**: Advanced features, enhanced workloads, and real-time updates
## Features
### Phase 1: Basic Management
- **Cluster Management**: Add, remove, list clusters with kubeconfig support
- **Port Forwarding**: Start, stop, list, and delete port forwards
- **Resource Discovery**: View pods, services, deployments, statefulsets, daemonsets, namespaces
- **Resource Management**: Scale, restart, delete, exec into resources
- **Context Switching**: Switch between clusters and namespaces
### Phase 2: Advanced Features
- **26 Resource Types**: All major Kubernetes resource types with table views
- **Detail Views**: Tabs for overview, logs, yaml, events for each resource
- **Terminal**: Multi-tab terminal with session management
- **YAML Editor**: Create and edit resources with YAML
- **Metrics Charts**: CPU, memory, and network usage visualization
- **Search & Filter**: Search by name, labels, annotations
- **Context Switcher**: Quick cluster and context switching
- **RBAC Management**: Viewer and editor for roles, clusterroles, bindings
- **Real-time Updates**: Event bus and Kubernetes API watchers
## Architecture
### Frontend
- **State Management**: Zustand `kubernetesStore` for clusters, namespaces, resources, terminals, search, bulk selection
- **Components**: 26 resource list components, 8 detail views, 8 advanced components, 6 UX components
- **Event System**: Simple event bus for frontend event handling
### Backend
- **Commands**: 43 kube-related commands in `src-tauri/src/commands/kube.rs`
- **Client**: Kubernetes client with kubeconfig support
- **Port Forwarding**: Complete port forward runtime with kubeconfig injection
- **Watchers**: Resource watchers with channel-based communication (placeholder implementation)
## Resource Types
### Workloads (11)
- Pod
- Deployment
- Service
- StatefulSet
- DaemonSet
- ReplicaSet
- Job
- CronJob
- Ingress
- HPA
### Infrastructure (5)
- Node
- Namespace
- PVC
- PV
- ServiceAccount
### Configuration (2)
- ConfigMap
- Secret
### RBAC (4)
- Role
- ClusterRole
- RoleBinding
- ClusterRoleBinding
### Events (1)
- Event
## API Commands
### Cluster Management
- `list_clusters()` - List all clusters
- `add_cluster()` - Add cluster with kubeconfig
- `remove_cluster()` - Remove cluster
- `set_active_cluster()` - Set active cluster
### Port Forwarding
- `list_port_forwards()` - List active port forwards
- `start_port_forward()` - Start port forward
- `stop_port_forward()` - Stop port forward
- `delete_port_forward()` - Delete port forward
- `shutdown_port_forwards()` - Shutdown all port forwards
### Resource Discovery
- `list_pods()` - List pods
- `list_services()` - List services
- `list_deployments()` - List deployments
- `list_statefulsets()` - List statefulsets
- `list_daemonsets()` - List daemonsets
- `list_namespaces()` - List namespaces
- `list_nodes()` - List nodes
- `list_events()` - List events
- `list_configmaps()` - List configmaps
- `list_secrets()` - List secrets
- `list_replicasets()` - List replicasets
- `list_jobs()` - List jobs
- `list_cronjobs()` - List cronjobs
- `list_ingresses()` - List ingresses
- `list_pvcs()` - List PVCs
- `list_pvs()` - List PVs
- `list_serviceaccounts()` - List service accounts
- `list_roles()` - List roles
- `list_clusterroles()` - List cluster roles
- `list_rolebindings()` - List role bindings
- `list_clusterrolebindings()` - List cluster role bindings
- `list_hpas()` - List HPAs
### Resource Management
- `get_pod_detail()` - Get pod details
- `get_deployment_detail()` - Get deployment details
- `get_service_detail()` - Get service details
- `get_configmap_detail()` - Get configmap details
- `get_secret_detail()` - Get secret details
- `get_node_detail()` - Get node details
- `get_namespace_detail()` - Get namespace details
- `get_pvc_detail()` - Get PVC details
- `get_pv_detail()` - Get PV details
- `get_serviceaccount_detail()` - Get service account details
- `get_role_detail()` - Get role details
- `get_clusterrole_detail()` - Get cluster role details
- `get_rolebinding_detail()` - Get role binding details
- `get_clusterrolebinding_detail()` - Get cluster role binding details
- `get_hpa_detail()` - Get HPA details
- `get_event_detail()` - Get event details
- `get_replicaset_detail()` - Get replica set details
- `get_job_detail()` - Get job details
- `get_cronjob_detail()` - Get cronjob details
- `get_ingress_detail()` - Get ingress details
- `scale_deployment()` - Scale deployment
- `restart_deployment()` - Restart deployment
- `delete_resource()` - Delete resource
- `exec_into_pod()` - Execute command in pod
- `get_pod_logs()` - Get pod logs
- `get_resource_yaml()` - Get resource YAML
### Advanced
- `subscribe_to_k8s_events()` - Subscribe to K8s events
- `subscribe_to_all_k8s_events()` - Subscribe to all K8s events
- `unsubscribe_from_k8s_events()` - Unsubscribe from events
## State Management
### Kubernetes Store (`src/stores/kubernetesStore.ts`)
```typescript
interface KubernetesState {
clusters: Cluster[];
activeClusterId: string | null;
namespaces: Namespace[];
activeNamespace: string | null;
resources: Record<string, Resource[]>;
resourceLoading: Record<string, boolean>;
terminals: TerminalSession[];
searchQuery: string;
searchResults: Resource[];
bulkSelection: Set<string>;
}
```
## Event System
### Event Bus (`src/lib/eventBus.ts`)
```typescript
// Subscribe to events
const unsubscribe = eventBus.on('k8s:resource:updated', (data) => {
console.log('Resource updated:', data);
});
// Unsubscribe
unsubscribe();
// Emit events
eventBus.emit('k8s:resource:updated', {
clusterId: 'cluster-1',
namespace: 'default',
resourceType: 'pod',
resource: podData
});
```
## Future Enhancements
- **Helm Support**: Chart management and release tracking
- **Extension System**: Plugin architecture for custom features
- **Advanced Metrics**: Custom metrics and dashboards
- **Bulk Actions**: Batch operations on resources
- **Resource Creation**: Form-based resource creation
- **Health Monitoring**: Cluster and resource health status
## Dependencies
### Frontend
- `xterm` - Terminal rendering
- `xterm-addon-fit` - Terminal resizing
- `xterm-addon-web-links` - Web link detection
- `@monaco-editor/react` - YAML editor
- `react-chartjs-2` - Metrics charts
- `chart.js` - Chart rendering
### Backend
- `k8s-openapi` with `watch` feature - Kubernetes API watchers
- `tokio-stream` - Async streams for watchers
## Testing
### Frontend Tests
- 114 tests passing
- Unit tests for stores, components, and utilities
### Backend Tests
- 331 tests passing
- Tests for kube commands, port forwarding, and resource management
## Documentation
- [Kubernetes Management Implementation Plan](../KUBERNETES-MANAGEMENT-IMPLEMENTATION-PLAN.md)
- [Lens Desktop v5.x Features](../lens-desktop-v5x-features.md)
- [Architecture Documentation](../architecture/README.md)
- [ADR-010: Kubernetes Management UI](../architecture/adrs/ADR-010-kubernetes-management-ui.md)

2488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,14 @@
"class-variance-authority": "^0.7",
"clsx": "^2",
"lucide-react": "latest",
"react": "^18",
"react-diff-viewer-continued": "^3",
"react-dom": "^18",
"react-markdown": "^9",
"react-router-dom": "^6",
"react": "^19",
"react-diff-viewer-continued": "^4",
"react-dom": "^19",
"react-markdown": "^10",
"react-router-dom": "^6.30.4",
"remark-gfm": "^4",
"tailwindcss": "^3",
"zustand": "^4"
"zustand": "^5"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
@ -37,23 +37,23 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
"@types/node": "^25.9.1",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@typescript-eslint/parser": "^8.58.1",
"@vitejs/plugin-react": "^4.7.0",
"@types/node": "^25.9.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4",
"@wdio/cli": "^9",
"@wdio/mocha-framework": "^9",
"autoprefixer": "^10",
"eslint": "^9.39.4",
"eslint": "^10.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29",
"postcss": "^8",
"typescript": "^5",
"vite": "^6",
"typescript": "^6",
"vite": "^8",
"vitest": "^4",
"webdriverio": "^9"
}

View File

@ -326,6 +326,10 @@ pub async fn initiate_oauth(
let integration_webviews = app_state.integration_webviews.clone();
let mcp_connections = app_state.mcp_connections.clone();
let pending_approvals = app_state.pending_approvals.clone();
let clusters = app_state.clusters.clone();
let port_forwards = app_state.port_forwards.clone();
let refresh_registry = app_state.refresh_registry.clone();
let watchers = app_state.watchers.clone();
tokio::spawn(async move {
let app_state_for_callback = AppState {
@ -335,11 +339,10 @@ pub async fn initiate_oauth(
integration_webviews,
mcp_connections,
pending_approvals,
clusters: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
refresh_registry: Arc::new(tokio::sync::Mutex::new(
crate::kube::RefreshRegistry::new(),
)),
clusters,
port_forwards,
refresh_registry,
watchers,
};
while let Some(callback) = callback_rx.recv().await {
tracing::info!("Received OAuth callback for state: {}", callback.state);

View File

@ -4055,3 +4055,76 @@ pub async fn edit_resource(
Ok(())
}
#[tauri::command]
pub async fn subscribe_to_k8s_events(
cluster_id: String,
namespace: String,
resource_type: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let _app_state = state.inner();
let rx = crate::kube::start_resource_watcher(_app_state, cluster_id, namespace, resource_type)
.await
.map_err(|e| format!("Failed to start watcher: {e}"))?;
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get duration: {e}"))?;
let unsubscribe_id = format!("watcher-{}", duration.as_millis());
state
.inner()
.watchers
.lock()
.unwrap()
.insert(unsubscribe_id.clone(), rx);
Ok(unsubscribe_id)
}
#[tauri::command]
pub async fn subscribe_to_all_k8s_events(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let _app_state = state.inner();
let rx = crate::kube::start_all_resources_watcher(_app_state, cluster_id)
.await
.map_err(|e| format!("Failed to start all watcher: {e}"))?;
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get duration: {e}"))?;
let unsubscribe_id = format!("watcher-all-{}", duration.as_millis());
state
.inner()
.watchers
.lock()
.unwrap()
.insert(unsubscribe_id.clone(), rx);
Ok(unsubscribe_id)
}
#[tauri::command]
pub async fn unsubscribe_from_k8s_events(
unsubscribe_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let removed = state
.inner()
.watchers
.lock()
.unwrap()
.remove(&unsubscribe_id);
if removed.is_none() {
return Err(format!("Watcher {} not found", unsubscribe_id));
}
Ok(())
}

View File

@ -1,10 +1,12 @@
pub mod client;
pub mod portforward;
pub mod refresh;
pub mod watcher;
pub use client::ClusterClient;
pub use portforward::{PortForwardSession, PortForwardStatus};
pub use refresh::RefreshRegistry;
pub use watcher::{start_all_resources_watcher, start_resource_watcher, Watcher};
#[cfg(test)]
mod tests {

View File

@ -0,0 +1,98 @@
use crate::state::AppState;
use anyhow::Result;
use tokio::sync::mpsc;
use tracing::info;
pub struct Watcher {
cluster_id: String,
namespace: String,
resource_type: String,
#[allow(dead_code)]
tx: mpsc::Sender<serde_json::Value>,
}
impl Watcher {
pub fn new(
cluster_id: String,
namespace: String,
resource_type: String,
tx: mpsc::Sender<serde_json::Value>,
) -> Self {
Self {
cluster_id,
namespace,
resource_type,
tx,
}
}
pub async fn start(self) -> Result<()> {
info!(
"Starting watcher for {}/{} in namespace {}",
self.resource_type, self.cluster_id, self.namespace
);
// TODO: implement real watch stream via k8s-openapi + tokio-stream
tracing::warn!(
resource_type = %self.resource_type,
cluster_id = %self.cluster_id,
namespace = %self.namespace,
"Watcher is a stub — no events will be emitted until k8s watch stream is implemented"
);
Ok(())
}
}
pub async fn start_resource_watcher(
_app_state: &AppState,
cluster_id: String,
namespace: String,
resource_type: String,
) -> Result<mpsc::Receiver<serde_json::Value>> {
let (tx, rx) = mpsc::channel(100);
let watcher_tx = tx.clone();
let cluster_id = cluster_id.clone();
let namespace = namespace.clone();
let resource_type = resource_type.clone();
tokio::spawn(async move {
let watcher = Watcher::new(cluster_id, namespace, resource_type, watcher_tx);
if let Err(e) = watcher.start().await {
tracing::error!("Watcher failed: {}", e);
}
});
Ok(rx)
}
pub async fn start_all_resources_watcher(
_app_state: &AppState,
cluster_id: String,
) -> Result<mpsc::Receiver<serde_json::Value>> {
let (tx, rx) = mpsc::channel(100);
let resources = vec![
"pods",
"services",
"deployments",
"replicasets",
"daemonsets",
];
for resource_type in resources {
let watcher_tx = tx.clone();
let cluster_id = cluster_id.clone();
let namespace = "default".to_string();
tokio::spawn(async move {
let watcher =
Watcher::new(cluster_id, namespace, resource_type.to_string(), watcher_tx);
if let Err(e) = watcher.start().await {
tracing::error!("Watcher for {} failed: {}", resource_type, e);
}
});
}
Ok(rx)
}

View File

@ -44,6 +44,7 @@ pub fn run() {
clusters: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())),
watchers: Arc::new(Mutex::new(std::collections::HashMap::new())),
};
let stronghold_salt = format!(
"tftsr-stronghold-salt-v1-{:x}",

View File

@ -97,6 +97,8 @@ pub struct AppState {
pub port_forwards: Arc<TokioMutex<HashMap<String, crate::kube::PortForwardSession>>>,
/// Refresh registry for domain-based data fetching
pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>,
/// Resource watchers: unsubscribe_id -> receiver
pub watchers: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>>,
}
/// Determine the application data directory.

View File

@ -0,0 +1,134 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Terminal } from "./Terminal";
import { SearchBar } from "./SearchBar";
import { MetricsChart } from "./MetricsChart";
import { YamlEditor } from "./YamlEditor";
import { useKubernetesStore } from "@/stores/kubernetesStore";
import { useStore } from "zustand";
interface ApplicationViewProps {
clusterId: string;
namespace: string;
}
export function ApplicationView({ clusterId, namespace }: ApplicationViewProps) {
const [activeTab, setActiveTab] = React.useState("overview");
const clusters = useStore(useKubernetesStore, (state) => state.clusters);
const selectedCluster = clusters.find((c: { id: string }) => c.id === clusterId);
return (
<div className="h-full overflow-hidden flex flex-col">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">Application View</h2>
{selectedCluster && (
<span className="text-sm text-muted-foreground">{selectedCluster.name}</span>
)}
</div>
<div className="flex items-center gap-2">
<SearchBar
query={useStore(useKubernetesStore, (state) => state.globalSearchQuery)}
onQueryChange={(q) => useKubernetesStore.getState().setGlobalSearchQuery(q)}
/>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-5 mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="workloads">Workloads</TabsTrigger>
<TabsTrigger value="infrastructure">Infrastructure</TabsTrigger>
<TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="overview" className="h-full overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<MetricsChart
title="CPU Usage"
data={{
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"],
datasets: [
{
label: "CPU Cores",
data: [0.5, 0.8, 1.2, 1.5, 1.1, 0.9],
borderColor: "hsl(var(--primary))",
},
],
}}
/>
<MetricsChart
title="Memory Usage"
data={{
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"],
datasets: [
{
label: "Memory (GB)",
data: [2.1, 2.3, 2.8, 3.1, 2.9, 2.5],
borderColor: "hsl(var(--primary))",
},
],
}}
/>
<MetricsChart
title="Network I/O"
data={{
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"],
datasets: [
{
label: "Received (MB)",
data: [100, 150, 200, 180, 220, 190],
borderColor: "hsl(var(--primary))",
},
{
label: "Sent (MB)",
data: [50, 75, 100, 90, 110, 95],
borderColor: "hsl(var(--secondary))",
},
],
}}
type="bar"
/>
<MetricsChart
title="Pod Status"
data={{
labels: ["Running", "Pending", "Failed", "Unknown"],
datasets: [
{
label: "Count",
data: [45, 3, 1, 0],
backgroundColor: "hsl(var(--success))",
},
],
}}
type="bar"
/>
</div>
</TabsContent>
<TabsContent value="workloads" className="h-full overflow-y-auto">
<div className="text-center py-12 text-muted-foreground">
<p>Workloads will be displayed here</p>
</div>
</TabsContent>
<TabsContent value="infrastructure" className="h-full overflow-y-auto">
<div className="text-center py-12 text-muted-foreground">
<p>Infrastructure resources will be displayed here</p>
</div>
</TabsContent>
<TabsContent value="terminal" className="h-full">
<Terminal clusterId={clusterId} namespace={namespace} />
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,181 @@
import React from "react";
import { Badge } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
interface ClusterDetailsProps {
clusterId: string;
}
export function ClusterDetails({ clusterId }: ClusterDetailsProps) {
return (
<div className="h-full overflow-y-auto">
<div className="mb-6">
<h2 className="text-2xl font-semibold">Cluster Details</h2>
<p className="text-muted-foreground">Cluster ID: {clusterId}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Basic Information</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-muted-foreground">Name</span>
<p className="font-medium">production-cluster</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Region</span>
<p className="font-medium">us-east-1</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Kubernetes Version</span>
<p className="font-mono">v1.28.4</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Platform</span>
<p className="font-medium">EKS</p>
</div>
<div>
<span className="text-sm text-muted-foreground">API Server</span>
<p className="font-mono text-xs truncate">https://abc123.gr7.us-east-1.eks.amazonaws.com</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant="default">Running</Badge>
</div>
</div>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Network Configuration</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-muted-foreground">VPC ID</span>
<p className="font-mono">vpc-0abc123def456</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Subnets</span>
<div className="flex flex-wrap gap-1">
<Badge variant="secondary">subnet-1</Badge>
<Badge variant="secondary">subnet-2</Badge>
<Badge variant="secondary">subnet-3</Badge>
</div>
</div>
<div>
<span className="text-sm text-muted-foreground">Security Groups</span>
<div className="flex flex-wrap gap-1">
<Badge variant="secondary">sg-001</Badge>
<Badge variant="secondary">sg-002</Badge>
</div>
</div>
<div>
<span className="text-sm text-muted-foreground">CIDR Block</span>
<p className="font-mono">10.0.0.0/16</p>
</div>
</div>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Node Configuration</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-muted-foreground">Instance Type</span>
<p className="font-medium">m5.xlarge</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Min Nodes</span>
<p className="font-medium">3</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Max Nodes</span>
<p className="font-medium">10</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Autoscaling</span>
<Badge variant="default">Enabled</Badge>
</div>
</div>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Security Configuration</h3>
</div>
<div className="p-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Network Policy</span>
<Badge variant="default">Enabled</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Pod Security Policy</span>
<Badge variant="default">Enabled</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">RBAC</span>
<Badge variant="default">Enabled</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Secret Encryption</span>
<Badge variant="default">Enabled</Badge>
</div>
</div>
</div>
</div>
</div>
<div className="bg-card rounded-lg border mt-6">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Node Pools</h3>
</div>
<div className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Instance Type</TableHead>
<TableHead>Nodes</TableHead>
<TableHead>Status</TableHead>
<TableHead>Auto-scaling</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>general-purpose</TableCell>
<TableCell className="font-mono">m5.xlarge</TableCell>
<TableCell>3</TableCell>
<TableCell>Running</TableCell>
<TableCell>Enabled</TableCell>
</TableRow>
<TableRow>
<TableCell>compute-optimized</TableCell>
<TableCell className="font-mono">c5.2xlarge</TableCell>
<TableCell>2</TableCell>
<TableCell>Running</TableCell>
<TableCell>Enabled</TableCell>
</TableRow>
<TableRow>
<TableCell>memory-optimized</TableCell>
<TableCell className="font-mono">r5.4xlarge</TableCell>
<TableCell>2</TableCell>
<TableCell>Running</TableCell>
<TableCell>Enabled</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
import React from "react";
import { Server, Database, Globe } from "lucide-react";
import { MetricsChart } from "./MetricsChart";
interface ClusterOverviewProps {
clusterId: string;
}
export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
return (
<div className="h-full overflow-y-auto">
<div className="mb-6">
<h2 className="text-2xl font-semibold">Cluster Overview</h2>
<p className="text-muted-foreground">Cluster ID: {clusterId}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-card rounded-lg p-4 border">
<div className="flex items-center justify-between pb-2">
<h3 className="text-sm font-medium">Nodes</h3>
<Server className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-2xl font-bold">15</div>
<p className="text-xs text-muted-foreground">+2 since last week</p>
</div>
<div className="bg-card rounded-lg p-4 border">
<div className="flex items-center justify-between pb-2">
<h3 className="text-sm font-medium">Pods</h3>
<Database className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-2xl font-bold">247</div>
<p className="text-xs text-muted-foreground">+15 since last week</p>
</div>
<div className="bg-card rounded-lg p-4 border">
<div className="flex items-center justify-between pb-2">
<h3 className="text-sm font-medium">Workloads</h3>
<Globe className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-2xl font-bold">32</div>
<p className="text-xs text-muted-foreground">+4 since last week</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<MetricsChart
title="Cluster CPU Usage"
data={{
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"],
datasets: [
{
label: "CPU Cores",
data: [12.5, 14.8, 18.2, 22.5, 19.1, 15.9],
borderColor: "hsl(var(--primary))",
},
],
}}
/>
<MetricsChart
title="Cluster Memory Usage"
data={{
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"],
datasets: [
{
label: "Memory (GB)",
data: [45.1, 48.3, 52.8, 58.1, 55.9, 50.5],
borderColor: "hsl(var(--primary))",
},
],
}}
/>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Cluster Resources</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="font-medium">Allocatable Resources</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">CPU (cores)</span>
<span className="font-mono">32</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Memory (GB)</span>
<span className="font-mono">128</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Pods</span>
<span className="font-mono">110</span>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">Used Resources</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">CPU (cores)</span>
<span className="font-mono">18.5 (58%)</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Memory (GB)</span>
<span className="font-mono">52.3 (41%)</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Pods</span>
<span className="font-mono">247 (22%)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-card rounded-lg border mt-6">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Recent Events</h3>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">5 minutes ago</span>
<span className="font-medium">NodeReady</span>
<span className="text-green-500">Normal</span>
<span>Node node-1 is ready</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">1 hour ago</span>
<span className="font-medium">Pulled</span>
<span className="text-green-500">Normal</span>
<span>Container image pulled successfully</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">2 hours ago</span>
<span className="font-medium">ScalingReplicaSet</span>
<span className="text-green-500">Normal</span>
<span>Scaled up deployment web-app</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands";
interface ClusterRoleBindingListProps {
clusterRoleBindings: ClusterRoleBindingInfo[];
_clusterId: string;
}
export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Cluster Role</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clusterRoleBindings.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No cluster role bindings found
</TableCell>
</TableRow>
) : (
clusterRoleBindings.map((crb) => (
<TableRow key={crb.name}>
<TableCell className="font-medium">{crb.name}</TableCell>
<TableCell>{crb.cluster_role}</TableCell>
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,39 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { ClusterRoleInfo } from "@/lib/tauriCommands";
interface ClusterRoleListProps {
clusterRoles: ClusterRoleInfo[];
_clusterId: string;
}
export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clusterRoles.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground">
No cluster roles found
</TableCell>
</TableRow>
) : (
clusterRoles.map((clusterRole) => (
<TableRow key={clusterRole.name}>
<TableCell className="font-medium">{clusterRole.name}</TableCell>
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,103 @@
import React from "react";
import { Command, X } from "lucide-react";
import { Input } from "@/components/ui";
import { Button } from "@/components/ui";
import { Badge } from "@/components/ui";
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onCommand: (command: string) => void;
}
export function CommandPalette({ isOpen, onClose, onCommand }: CommandPaletteProps) {
const [query, setQuery] = React.useState("");
if (!isOpen) return null;
const commands = [
{ name: "Open Terminal", command: "terminal:open" },
{ name: "Create Pod", command: "resource:create:pod" },
{ name: "Create Deployment", command: "resource:create:deployment" },
{ name: "Create Service", command: "resource:create:service" },
{ name: "View Logs", command: "logs:view" },
{ name: "Scale Resource", command: "resource:scale" },
{ name: "Delete Resource", command: "resource:delete" },
{ name: "Export YAML", command: "yaml:export" },
{ name: "Refresh Cluster", command: "cluster:refresh" },
{ name: "Switch Context", command: "context:switch" },
];
const filteredCommands = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-background rounded-lg shadow-2xl border">
<div className="border-b px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Command className="w-5 h-5" />
<h3 className="font-semibold">Command Palette</h3>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="p-4 space-y-4">
<div className="relative">
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type a command or search..."
autoFocus
className="pl-10"
/>
<Command className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-2 max-h-80 overflow-y-auto">
{filteredCommands.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No commands found
</div>
) : (
filteredCommands.map((cmd, index) => (
<div
key={index}
className="flex items-center justify-between p-3 hover:bg-accent rounded-md cursor-pointer transition-colors"
onClick={() => {
onCommand(cmd.command);
onClose();
}}
>
<span>{cmd.name}</span>
<Badge variant="secondary" className="text-xs font-mono">
{cmd.command}
</Badge>
</div>
))
)}
</div>
<div className="pt-4 border-t flex items-center justify-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-muted rounded border"></kbd>
<kbd className="px-1 py-0.5 bg-muted rounded border"></kbd>
<span>to navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-muted rounded border"></kbd>
<span>to select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-muted rounded border">esc</kbd>
<span>to close</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Button } from "@/components/ui";
import { X } from "lucide-react";
import { YamlEditor } from "./YamlEditor";
interface ConfigMapDetailProps {
configMapName: string;
namespace: string;
_clusterId: string;
onClose: () => void;
}
export function ConfigMapDetail({ configMapName, namespace, _clusterId, onClose }: ConfigMapDetailProps) {
const [activeTab, setActiveTab] = React.useState("data");
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">ConfigMap: {configMapName}</h2>
<Badge variant="outline">{namespace}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-3 mb-4">
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="data" className="h-full overflow-y-auto">
<Card className="h-full flex flex-col">
<CardHeader>
<CardTitle>ConfigMap Data</CardTitle>
</CardHeader>
<CardContent className="flex-1 bg-slate-900 rounded-md p-4 overflow-auto font-mono text-sm">
<div className="space-y-2">
<div>
<span className="text-blue-400">config.json:</span>
<pre className="mt-1 text-green-400">{`{
"debug": true,
"logLevel": "info"
}`}</pre>
</div>
<div>
<span className="text-blue-400">app.properties:</span>
<pre className="mt-1 text-green-400">{`app.name=MyApp
app.version=1.0.0
app.port=8080`}</pre>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
<TabsContent value="metadata" className="h-full overflow-y-auto">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-mono">{configMapName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Namespace</span>
<span className="font-mono">{namespace}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">UID</span>
<span className="font-mono text-xs">abc123-def456</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">2 hours ago</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Labels</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,57 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import type { ConfigMapInfo } from "@/lib/tauriCommands";
interface ConfigMapListProps {
configmaps: ConfigMapInfo[];
clusterId: string;
namespace: string;
}
export function ConfigMapList({ configmaps }: ConfigMapListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configmaps.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No configmaps found
</TableCell>
</TableRow>
) : (
configmaps.map((configmap) => (
<TableRow key={configmap.name}>
<TableCell className="font-medium">{configmap.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{configmap.namespace}</TableCell>
<TableCell className="text-sm">{configmap.data_keys}</TableCell>
<TableCell className="text-sm text-muted-foreground">{configmap.age}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => {}}
className="text-primary hover:text-primary hover:bg-primary/10"
>
View/Edit
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,65 @@
import React from "react";
import { Server } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
interface ContextSwitcherProps {
clusters: { id: string; name: string; context: string; cluster_url?: string }[];
selectedClusterId: string;
onClusterChange: (clusterId: string) => void;
}
export function ContextSwitcher({ clusters, selectedClusterId, onClusterChange }: ContextSwitcherProps) {
const selectedCluster = clusters.find((c) => c.id === selectedClusterId);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="w-5 h-5" />
Context Switcher
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">
Current Cluster
</label>
<Select value={selectedClusterId} onValueChange={onClusterChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select cluster" />
</SelectTrigger>
<SelectContent>
{clusters.map((cluster) => (
<SelectItem key={cluster.id} value={cluster.id}>
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
{cluster.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedCluster && (
<div className="p-4 bg-muted rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Context</span>
<Badge variant="secondary">{selectedCluster.context}</Badge>
</div>
{selectedCluster.cluster_url && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cluster URL</span>
<span className="text-sm font-mono truncate max-w-[200px]">{selectedCluster.cluster_url}</span>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,131 @@
import React from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui";
import { Button } from "@/components/ui";
import { Input } from "@/components/ui";
import { Label } from "@/components/ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { YamlEditor } from "./YamlEditor";
interface CreateResourceModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (resource: { type: string; name: string; namespace: string }) => void;
}
export function CreateResourceModal({ isOpen, onClose, onSubmit }: CreateResourceModalProps) {
const [activeTab, setActiveTab] = React.useState("form");
const [resourceType, setResourceType] = React.useState("pod");
const [name, setName] = React.useState("");
const [namespace, setNamespace] = React.useState("default");
const handleSubmit = () => {
onSubmit({
type: resourceType,
name,
namespace,
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Create Kubernetes Resource</DialogTitle>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-2 mb-4">
<TabsTrigger value="form">Form</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
</TabsList>
<div className="max-h-[60vh] overflow-y-auto">
<TabsContent value="form" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="resourceType">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pod">Pod</SelectItem>
<SelectItem value="deployment">Deployment</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="configmap">ConfigMap</SelectItem>
<SelectItem value="secret">Secret</SelectItem>
<SelectItem value="ingress">Ingress</SelectItem>
<SelectItem value="pvc">PersistentVolumeClaim</SelectItem>
<SelectItem value="pv">PersistentVolume</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter resource name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="namespace">Namespace</Label>
<Select value={namespace} onValueChange={setNamespace}>
<SelectTrigger>
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">default</SelectItem>
<SelectItem value="kube-system">kube-system</SelectItem>
<SelectItem value="kube-public">kube-public</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="p-4 bg-muted rounded-md">
<h4 className="text-sm font-medium mb-2">Configuration</h4>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Resource Type: {resourceType}</p>
<p>Name: {name || "not specified"}</p>
<p>Namespace: {namespace}</p>
</div>
</div>
</TabsContent>
<TabsContent value="yaml">
<div className="space-y-4">
<div className="space-y-2">
<Label>Resource YAML</Label>
<div className="h-64">
<YamlEditor onChange={() => {}} />
</div>
</div>
<div className="p-4 bg-muted rounded-md">
<h4 className="text-sm font-medium mb-2">Preview</h4>
<div className="text-sm text-muted-foreground">
YAML validation will be performed on submit
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name}>
Create Resource
</Button>
</DialogFooter>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,54 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { CronJobInfo } from "@/lib/tauriCommands";
interface CronJobListProps {
cronJobs: CronJobInfo[];
_clusterId: string;
_namespace: string;
}
export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Active</TableHead>
<TableHead>Last Schedule</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cronJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No cron jobs found
</TableCell>
</TableRow>
) : (
cronJobs.map((cronJob) => (
<TableRow key={`${cronJob.name}-${cronJob.namespace}`}>
<TableCell className="font-medium">{cronJob.name}</TableCell>
<TableCell>{cronJob.namespace}</TableCell>
<TableCell>{cronJob.schedule}</TableCell>
<TableCell>{cronJob.active}</TableCell>
<TableCell>{cronJob.last_schedule}</TableCell>
<TableCell className="text-muted-foreground">{cronJob.age}</TableCell>
<TableCell>
{Object.entries(cronJob.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,163 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { X } from "lucide-react";
import { YamlEditor } from "./YamlEditor";
interface DeploymentDetailProps {
deploymentName: string;
namespace: string;
_clusterId: string;
onClose: () => void;
}
export function DeploymentDetail({ deploymentName, namespace, _clusterId, onClose }: DeploymentDetailProps) {
const [activeTab, setActiveTab] = React.useState("overview");
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Deployment: {deploymentName}</h2>
<Badge variant="outline">{namespace}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="replicas">Replicas</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="overview" className="h-full overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Deployment Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-mono">{deploymentName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Namespace</span>
<span className="font-mono">{namespace}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Replicas</span>
<span>3/3 Ready</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Strategy</span>
<span>RollingUpdate</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Image</span>
<span className="font-mono">nginx:latest</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">2 hours ago</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Selector</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Labels</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
<Badge variant="secondary">version=v1</Badge>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="replicas" className="h-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{deploymentName}-abc123</TableCell>
<TableCell>Running</TableCell>
<TableCell>1/1</TableCell>
<TableCell>2h</TableCell>
</TableRow>
<TableRow>
<TableCell>{deploymentName}-def456</TableCell>
<TableCell>Running</TableCell>
<TableCell>1/1</TableCell>
<TableCell>2h</TableCell>
</TableRow>
<TableRow>
<TableCell>{deploymentName}-ghi789</TableCell>
<TableCell>Running</TableCell>
<TableCell>1/1</TableCell>
<TableCell>2h</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
<TabsContent value="events" className="h-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>2 hours ago</TableCell>
<TableCell>ScalingReplicaSet</TableCell>
<TableCell>Normal</TableCell>
<TableCell>Scaled up replica set {deploymentName}-abc123 to 3</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,110 @@
import React from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui";
import { Button } from "@/components/ui";
import { Input } from "@/components/ui";
import { Label } from "@/components/ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { YamlEditor } from "./YamlEditor";
interface EditResourceModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (resource: { name: string; namespace: string }) => void;
initialData?: { name?: string; namespace?: string };
}
export function EditResourceModal({ isOpen, onClose, onSubmit, initialData }: EditResourceModalProps) {
const [activeTab, setActiveTab] = React.useState("form");
const [name, setName] = React.useState(initialData?.name || "");
const [namespace, setNamespace] = React.useState(initialData?.namespace || "default");
const handleSubmit = () => {
onSubmit({
name,
namespace,
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Kubernetes Resource</DialogTitle>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-2 mb-4">
<TabsTrigger value="form">Form</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
</TabsList>
<div className="max-h-[60vh] overflow-y-auto">
<TabsContent value="form" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter resource name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="namespace">Namespace</Label>
<Select value={namespace} onValueChange={setNamespace}>
<SelectTrigger>
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">default</SelectItem>
<SelectItem value="kube-system">kube-system</SelectItem>
<SelectItem value="kube-public">kube-public</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="p-4 bg-muted rounded-md">
<h4 className="text-sm font-medium mb-2">Resource Details</h4>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Name: {name || "not specified"}</p>
<p>Namespace: {namespace}</p>
</div>
</div>
</TabsContent>
<TabsContent value="yaml">
<div className="space-y-4">
<div className="space-y-2">
<Label>Resource YAML</Label>
<div className="h-64">
<YamlEditor onChange={() => {}} />
</div>
</div>
<div className="p-4 bg-muted rounded-md">
<h4 className="text-sm font-medium mb-2">Preview</h4>
<div className="text-sm text-muted-foreground">
YAML validation will be performed on submit
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name}>
Save Changes
</Button>
</DialogFooter>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,70 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui";
import type { EventInfo } from "@/lib/tauriCommands";
interface EventListProps {
events: EventInfo[];
clusterId: string;
namespace?: string;
}
export function EventList({ events, clusterId: _clusterId, namespace: _namespace }: EventListProps) {
const getEventTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case "normal":
return "bg-blue-500";
case "warning":
return "bg-yellow-500 text-yellow-900";
default:
return "bg-gray-500";
}
};
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Object</TableHead>
<TableHead>Count</TableHead>
<TableHead>First Seen</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No events found
</TableCell>
</TableRow>
) : (
events.map((event) => (
<TableRow key={event.name}>
<TableCell className="font-medium">{event.name}</TableCell>
<TableCell>
<Badge className={`${getEventTypeColor(event.event_type)} text-white`}>
{event.event_type}
</Badge>
</TableCell>
<TableCell className="font-medium">{event.reason}</TableCell>
<TableCell className="text-sm text-muted-foreground">{event.object}</TableCell>
<TableCell className="text-sm">{event.count}</TableCell>
<TableCell className="text-sm text-muted-foreground">{event.first_seen}</TableCell>
<TableCell className="text-sm text-muted-foreground">{event.last_seen}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-md truncate" title={event.message}>
{event.message}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands";
interface HPAListProps {
hpas: HorizontalPodAutoscalerInfo[];
_clusterId: string;
_namespace: string;
}
export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Min Replicas</TableHead>
<TableHead>Max Replicas</TableHead>
<TableHead>Current Replicas</TableHead>
<TableHead>Desired Replicas</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hpas.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No HPAs found
</TableCell>
</TableRow>
) : (
hpas.map((hpa) => (
<TableRow key={`${hpa.name}-${hpa.namespace}`}>
<TableCell className="font-medium">{hpa.name}</TableCell>
<TableCell>{hpa.namespace}</TableCell>
<TableCell>{hpa.min_replicas}</TableCell>
<TableCell>{hpa.max_replicas}</TableCell>
<TableCell>{hpa.current_replicas}</TableCell>
<TableCell>{hpa.desired_replicas}</TableCell>
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,57 @@
import React from "react";
import { Button } from "@/components/ui";
import { Settings, Bell, User, Search, Plus, RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui";
import { useKubernetesStore } from "@/stores/kubernetesStore";
import { useStore } from "zustand";
interface HotbarProps {
onRefresh: () => void;
onAddResource: () => void;
onSettings: () => void;
}
export function Hotbar({ onRefresh, onAddResource, onSettings }: HotbarProps) {
const clusters = useStore(useKubernetesStore, (state) => state.clusters);
const selectedClusterId = useStore(useKubernetesStore, (state) => state.selectedClusterId);
const selectedCluster = clusters.find((c: { id: string }) => c.id === selectedClusterId);
return (
<div className="h-12 bg-background border-b flex items-center justify-between px-4">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={onRefresh}>
<RefreshCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onAddResource}>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="h-6 w-px bg-border mx-2" />
<div className="flex items-center gap-2">
<Search className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{selectedCluster?.name || "No cluster selected"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<Bell className="w-4 h-4" />
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
3
</Badge>
</Button>
<Button variant="ghost" size="sm" onClick={onSettings}>
<Settings className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm">
<User className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { IngressInfo } from "@/lib/tauriCommands";
interface IngressListProps {
ingresses: IngressInfo[];
_clusterId: string;
_namespace: string;
}
export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Class</TableHead>
<TableHead>Host</TableHead>
<TableHead>Addresses</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ingresses.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No ingresses found
</TableCell>
</TableRow>
) : (
ingresses.map((ingress) => (
<TableRow key={`${ingress.name}-${ingress.namespace}`}>
<TableCell className="font-medium">{ingress.name}</TableCell>
<TableCell>{ingress.namespace}</TableCell>
<TableCell>{ingress.class || "-"}</TableCell>
<TableCell>{ingress.host}</TableCell>
<TableCell>{ingress.addresses.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,52 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { JobInfo } from "@/lib/tauriCommands";
interface JobListProps {
jobs: JobInfo[];
_clusterId: string;
_namespace: string;
}
export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Completions</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No jobs found
</TableCell>
</TableRow>
) : (
jobs.map((job) => (
<TableRow key={`${job.name}-${job.namespace}`}>
<TableCell className="font-medium">{job.name}</TableCell>
<TableCell>{job.namespace}</TableCell>
<TableCell>{job.completions}</TableCell>
<TableCell>{job.duration}</TableCell>
<TableCell className="text-muted-foreground">{job.age}</TableCell>
<TableCell>
{Object.entries(job.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,22 @@
import React from "react";
import { Loader2 } from "lucide-react";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
text?: string;
}
export function LoadingSpinner({ size = "md", text }: LoadingSpinnerProps) {
const sizes = {
sm: "w-4 h-4",
md: "w-8 h-8",
lg: "w-12 h-12",
};
return (
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className={`${sizes[size]} animate-spin text-primary`} />
{text && <p className="text-sm text-muted-foreground">{text}</p>}
</div>
);
}

View File

@ -0,0 +1,54 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
interface MetricsChartProps {
title: string;
data: { labels: string[]; datasets: { label: string; data: number[]; borderColor?: string; backgroundColor?: string }[] };
type?: "line" | "bar";
timeRange?: string;
onTimeRangeChange?: (range: string) => void;
}
export function MetricsChart({ title, data, timeRange = "5m", onTimeRangeChange }: MetricsChartProps) {
const timeRanges = ["5m", "15m", "1h", "6h", "1d", "7d"];
return (
<Card className="h-full flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">{title}</CardTitle>
{onTimeRangeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Time Range:</span>
<Select value={timeRange} onValueChange={onTimeRangeChange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeRanges.map((range) => (
<SelectItem key={range} value={range}>
{range}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</CardHeader>
<CardContent className="flex-1 min-h-[300px] flex items-center justify-center">
{data.datasets.length > 0 ? (
<div className="text-center">
<p className="text-sm text-muted-foreground">Chart visualization would be displayed here</p>
<p className="text-xs mt-2">Charts require react-chartjs-2 and chart.js dependencies</p>
</div>
) : (
<div className="text-center text-muted-foreground">
No metrics data available
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,233 @@
import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Button } from "@/components/ui";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
import { AlertCircle, Terminal } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui";
import type { NodeInfo } from "@/lib/tauriCommands";
interface NodeListProps {
nodes: NodeInfo[];
clusterId: string;
}
export function NodeList({ nodes, clusterId }: NodeListProps) {
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
const [isCordoning, setIsCordoning] = useState(false);
const [isUncordoning, setIsUncordoning] = useState(false);
const [isDraining, setIsDraining] = useState(false);
const [error, setError] = useState<string | null>(null);
const getNodeStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "ready":
return "bg-green-500";
case "notready":
return "bg-red-500";
case "schedulingdisabled":
return "bg-yellow-500";
default:
return "bg-gray-500";
}
};
const handleCordon = async () => {
if (!selectedNode) return;
setIsCordoning(true);
setError(null);
try {
await invoke<void>("cordon_node", { clusterId, nodeName: selectedNode.name });
setSelectedNode(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cordon node");
} finally {
setIsCordoning(false);
}
};
const handleUncordon = async () => {
if (!selectedNode) return;
setIsUncordoning(true);
setError(null);
try {
await invoke<void>("uncordon_node", { clusterId, nodeName: selectedNode.name });
setSelectedNode(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to uncordon node");
} finally {
setIsUncordoning(false);
}
};
const handleDrain = async () => {
if (!selectedNode) return;
setIsDraining(true);
setError(null);
try {
await invoke<void>("drain_node", { clusterId, nodeName: selectedNode.name });
setSelectedNode(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to drain node");
} finally {
setIsDraining(false);
}
};
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Version</TableHead>
<TableHead>Internal IP</TableHead>
<TableHead>OS Image</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No nodes found
</TableCell>
</TableRow>
) : (
nodes.map((node) => (
<TableRow key={node.name}>
<TableCell className="font-medium">{node.name}</TableCell>
<TableCell>
<Badge className={`${getNodeStatusColor(node.status)} text-white`}>
{node.status}
</Badge>
</TableCell>
<TableCell>{node.roles}</TableCell>
<TableCell className="text-sm text-muted-foreground">{node.version}</TableCell>
<TableCell className="text-sm font-mono">{node.internal_ip}</TableCell>
<TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell>
<TableCell className="text-sm text-muted-foreground">{node.age}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedNode(node)}
className="text-primary hover:text-primary hover:bg-primary/10"
>
Manage
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Node Management Dialog */}
{selectedNode && (
<Dialog open={true} onOpenChange={(open) => {
if (!open) {
setSelectedNode(null);
setError(null);
}
}}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5" />
Manage Node: {selectedNode.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Node Details */}
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
<div>
<p className="text-xs font-medium text-muted-foreground">Status</p>
<p className="font-semibold">{selectedNode.status}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Roles</p>
<p className="font-semibold">{selectedNode.roles}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Version</p>
<p className="font-semibold">{selectedNode.version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">OS Image</p>
<p className="font-semibold">{selectedNode.os_image}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Kernel</p>
<p className="font-semibold">{selectedNode.kernel_version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Kubelet</p>
<p className="font-semibold">{selectedNode.kubelet_version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Internal IP</p>
<p className="font-semibold font-mono">{selectedNode.internal_ip}</p>
</div>
{selectedNode.external_ip && (
<div>
<p className="text-xs font-medium text-muted-foreground">External IP</p>
<p className="font-semibold font-mono">{selectedNode.external_ip}</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="space-y-3">
{selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? (
<Button
onClick={handleUncordon}
disabled={isUncordoning}
className="w-full"
>
{isUncordoning ? "Uncordoning..." : "Uncordon Node"}
</Button>
) : (
<Button
onClick={handleCordon}
variant="outline"
disabled={isCordoning}
className="w-full"
>
{isCordoning ? "Cordoning..." : "Cordon Node"}
</Button>
)}
<Button
onClick={handleDrain}
variant="destructive"
disabled={isDraining}
className="w-full"
>
{isDraining ? "Draining..." : "Drain Node"}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands";
interface PVCListProps {
pvcs: PersistentVolumeClaimInfo[];
_clusterId: string;
_namespace: string;
}
export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Status</TableHead>
<TableHead>Volume</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pvcs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No PVCs found
</TableCell>
</TableRow>
) : (
pvcs.map((pvc) => (
<TableRow key={`${pvc.name}-${pvc.namespace}`}>
<TableCell className="font-medium">{pvc.name}</TableCell>
<TableCell>{pvc.namespace}</TableCell>
<TableCell>{pvc.status}</TableCell>
<TableCell>{pvc.volume}</TableCell>
<TableCell>{pvc.capacity}</TableCell>
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,49 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { PersistentVolumeInfo } from "@/lib/tauriCommands";
interface PVListProps {
pvs: PersistentVolumeInfo[];
_clusterId: string;
}
export function PVList({ pvs, _clusterId }: PVListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Reclaim Policy</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pvs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No PVs found
</TableCell>
</TableRow>
) : (
pvs.map((pv) => (
<TableRow key={pv.name}>
<TableCell className="font-medium">{pv.name}</TableCell>
<TableCell>{pv.status}</TableCell>
<TableCell>{pv.capacity}</TableCell>
<TableCell>{pv.access_modes.join(", ")}</TableCell>
<TableCell>{pv.reclaim_policy}</TableCell>
<TableCell>{pv.storage_class}</TableCell>
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,187 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { Copy, Terminal, X } from "lucide-react";
import { YamlEditor } from "./YamlEditor";
interface PodDetailProps {
podName: string;
namespace: string;
_clusterId: string;
onClose: () => void;
}
export function PodDetail({ podName, namespace, _clusterId, onClose }: PodDetailProps) {
const [activeTab, setActiveTab] = React.useState("overview");
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Pod: {podName}</h2>
<Badge variant="outline">{namespace}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="overview" className="h-full overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Pod Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-mono">{podName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Namespace</span>
<span className="font-mono">{namespace}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant="default">Running</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">IP</span>
<span className="font-mono">10.0.0.1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Node</span>
<span className="font-mono">node-1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Restart Count</span>
<span>0</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">2 hours ago</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Containers</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead>State</TableHead>
<TableHead>Ready</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>example</TableCell>
<TableCell className="font-mono">nginx:latest</TableCell>
<TableCell>Running</TableCell>
<TableCell>True</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Labels</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
<Badge variant="secondary">version=v1</Badge>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="logs" className="h-full">
<Card className="h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Container Logs</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Terminal className="w-4 h-4 mr-2" />
Execute
</Button>
<Button variant="outline" size="sm">
<Copy className="w-4 h-4 mr-2" />
Copy
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 bg-slate-900 rounded-md p-4 overflow-auto font-mono text-sm">
<div className="text-green-400">[INFO] Starting nginx server...</div>
<div className="text-green-400">[INFO] Listening on port 80</div>
<div className="text-blue-400">[ACCESS] GET / - 200 OK</div>
<div className="text-blue-400">[ACCESS] GET /css/style.css - 200 OK</div>
<div className="text-blue-400">[ACCESS] GET /js/app.js - 200 OK</div>
<div className="text-yellow-400">[WARN] Slow response time detected</div>
<div className="text-blue-400">[ACCESS] POST /api/data - 201 Created</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
<TabsContent value="events" className="h-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>2 hours ago</TableCell>
<TableCell>Pulled</TableCell>
<TableCell>Normal</TableCell>
<TableCell>Container image "nginx:latest" already present on machine</TableCell>
</TableRow>
<TableRow>
<TableCell>2 hours ago</TableCell>
<TableCell>Created</TableCell>
<TableCell>Normal</TableCell>
<TableCell>Created container example</TableCell>
</TableRow>
<TableRow>
<TableCell>2 hours ago</TableCell>
<TableCell>Started</TableCell>
<TableCell>Normal</TableCell>
<TableCell>Started container example</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,116 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Button } from "@/components/ui";
import { Plus, X, Check } from "lucide-react";
import { Input } from "@/components/ui";
interface RbacEditorProps {
_clusterId: string;
namespace: string;
onClose: () => void;
}
export function RbacEditor({ _clusterId, namespace, onClose }: RbacEditorProps) {
const [activeTab, setActiveTab] = React.useState("roles");
const [newRoleName, setNewRoleName] = React.useState("");
return (
<div className="h-full flex flex-col">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-semibold">RBAC Editor</h2>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button>
<Check className="w-4 h-4 mr-2" />
Save Changes
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="roles">Roles</TabsTrigger>
<TabsTrigger value="clusterroles">ClusterRoles</TabsTrigger>
<TabsTrigger value="rolebindings">RoleBindings</TabsTrigger>
<TabsTrigger value="clusterrolebindings">ClusterRoleBindings</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="roles" className="h-full flex flex-col">
<div className="mb-4 flex items-center gap-2">
<Input
placeholder="New role name"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
/>
<Button disabled={!newRoleName}>
<Plus className="w-4 h-4 mr-2" />
Create Role
</Button>
</div>
<div className="flex-1 overflow-hidden">
<div className="bg-card rounded-lg border flex flex-col h-full">
<div className="border-b px-6 py-4">
<h3 className="font-semibold">Role YAML Editor</h3>
</div>
<div className="flex-1 bg-slate-900 p-4 font-mono text-sm text-green-400 overflow-auto">
<div className="space-y-1">
<div>
<span className="text-blue-400">apiVersion:</span> rbac.authorization.k8s.io/v1
</div>
<div>
<span className="text-blue-400">kind:</span> Role
</div>
<div>
<span className="text-blue-400">metadata:</span>
</div>
<div className="pl-4">
<span className="text-blue-400">name:</span> {newRoleName || "role-name"}
</div>
<div className="pl-4">
<span className="text-blue-400">namespace:</span> {namespace}
</div>
<div>
<span className="text-blue-400">rules:</span>
</div>
<div className="pl-4">
<span className="text-blue-400">-</span> <span className="text-blue-400">apiGroups:</span> [""]
</div>
<div className="pl-6">
<span className="text-blue-400">resources:</span> ["pods"]
</div>
<div className="pl-6">
<span className="text-blue-400">verbs:</span> ["get", "list", "watch"]
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="clusterroles" className="h-full flex flex-col">
<div className="text-center py-12 text-muted-foreground">
<p>ClusterRole editing would be displayed here</p>
</div>
</TabsContent>
<TabsContent value="rolebindings" className="h-full flex flex-col">
<div className="text-center py-12 text-muted-foreground">
<p>RoleBinding editing would be displayed here</p>
</div>
</TabsContent>
<TabsContent value="clusterrolebindings" className="h-full flex flex-col">
<div className="text-center py-12 text-muted-foreground">
<p>ClusterRoleBinding editing would be displayed here</p>
</div>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,196 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { Plus, Shield, User } from "lucide-react";
interface RbacViewerProps {
clusterId: string;
namespace: string;
}
export function RbacViewer({ clusterId, namespace }: RbacViewerProps) {
return (
<div className="h-full overflow-y-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">RBAC Management</h2>
<p className="text-muted-foreground">Cluster ID: {clusterId} | Namespace: {namespace}</p>
</div>
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Role
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold flex items-center gap-2">
<Shield className="w-5 h-5" />
Roles
</h3>
</div>
<div className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Rules</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>pod-reader</TableCell>
<TableCell className="font-mono">{namespace}</TableCell>
<TableCell>get, list, watch pods</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>secret-viewer</TableCell>
<TableCell className="font-mono">{namespace}</TableCell>
<TableCell>get, list secrets</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>deployment-manager</TableCell>
<TableCell className="font-mono">{namespace}</TableCell>
<TableCell>get, list, create, update deployments</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold flex items-center gap-2">
<Shield className="w-5 h-5" />
ClusterRoles
</h3>
</div>
<div className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Rules</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>admin</TableCell>
<TableCell>Full access to all resources</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>edit</TableCell>
<TableCell>Modify resources in namespace</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>view</TableCell>
<TableCell>Read-only access to resources</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold flex items-center gap-2">
<User className="w-5 h-5" />
RoleBindings
</h3>
</div>
<div className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>pod-reader-binding</TableCell>
<TableCell>pod-reader</TableCell>
<TableCell>user:alice</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>deployment-manager-binding</TableCell>
<TableCell>deployment-manager</TableCell>
<TableCell>group:devs</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div className="bg-card rounded-lg border">
<div className="border-b px-6 py-4">
<h3 className="font-semibold flex items-center gap-2">
<User className="w-5 h-5" />
ClusterRoleBindings
</h3>
</div>
<div className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ClusterRole</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>admin-binding</TableCell>
<TableCell>admin</TableCell>
<TableCell>group:admins</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>view-binding</TableCell>
<TableCell>view</TableCell>
<TableCell>group:auditors</TableCell>
<TableCell>
<Button variant="ghost" size="sm">Edit</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { ReplicaSetInfo } from "@/lib/tauriCommands";
interface ReplicaSetListProps {
replicaSets: ReplicaSetInfo[];
_clusterId: string;
_namespace: string;
}
export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaSetListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No replica sets found
</TableCell>
</TableRow>
) : (
replicaSets.map((replicaSet) => (
<TableRow key={`${replicaSet.name}-${replicaSet.namespace}`}>
<TableCell className="font-medium">{replicaSet.name}</TableCell>
<TableCell>{replicaSet.namespace}</TableCell>
<TableCell>{replicaSet.replicas}</TableCell>
<TableCell>{replicaSet.ready}</TableCell>
<TableCell className="text-muted-foreground">{replicaSet.age}</TableCell>
<TableCell>
{Object.entries(replicaSet.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,44 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { RoleBindingInfo } from "@/lib/tauriCommands";
interface RoleBindingListProps {
roleBindings: RoleBindingInfo[];
_clusterId: string;
_namespace: string;
}
export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBindingListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Role</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roleBindings.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No role bindings found
</TableCell>
</TableRow>
) : (
roleBindings.map((rb) => (
<TableRow key={`${rb.name}-${rb.namespace}`}>
<TableCell className="font-medium">{rb.name}</TableCell>
<TableCell>{rb.namespace}</TableCell>
<TableCell>{rb.role}</TableCell>
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,42 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { RoleInfo } from "@/lib/tauriCommands";
interface RoleListProps {
roles: RoleInfo[];
_clusterId: string;
_namespace: string;
}
export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No roles found
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={`${role.name}-${role.namespace}`}>
<TableCell className="font-medium">{role.name}</TableCell>
<TableCell>{role.namespace}</TableCell>
<TableCell className="text-muted-foreground">{role.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui";
import { Button } from "@/components/ui";
interface SearchBarProps {
query: string;
onQueryChange: (query: string) => void;
placeholder?: string;
showClear?: boolean;
onClear?: () => void;
}
export function SearchBar({ query, onQueryChange, placeholder = "Search...", showClear = true, onClear }: SearchBarProps) {
const [isFocused, setIsFocused] = React.useState(false);
const handleClear = () => {
onQueryChange("");
onClear?.();
};
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${isFocused ? "border-primary ring-1 ring-primary" : "border-input"}`}>
<Search className="w-4 h-4 text-muted-foreground" />
<Input
type="text"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className="border-none shadow-none focus-visible:ring-0 py-0 px-2 flex-1"
/>
{showClear && query && (
<Button variant="ghost" size="sm" onClick={handleClear} className="h-6 w-6 p-0">
<Search className="w-3 h-3 rotate-45" />
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,122 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Button } from "@/components/ui";
import { X } from "lucide-react";
import { YamlEditor } from "./YamlEditor";
interface SecretDetailProps {
secretName: string;
namespace: string;
_clusterId: string;
onClose: () => void;
}
export function SecretDetail({ secretName, namespace, _clusterId, onClose }: SecretDetailProps) {
const [activeTab, setActiveTab] = React.useState("data");
const [showValues, setShowValues] = React.useState(false);
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Secret: {secretName}</h2>
<Badge variant="destructive">Secret</Badge>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-3 mb-4">
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="data" className="h-full overflow-y-auto">
<Card className="h-full flex flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Secret Data</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowValues(!showValues)}>
{showValues ? "Hide Values" : "Show Values"}
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 bg-slate-900 rounded-md p-4 overflow-auto font-mono text-sm">
<div className="space-y-2">
<div>
<span className="text-blue-400">username:</span>
<span className="text-green-400 ml-2">
{showValues ? "admin" : "****"}
</span>
</div>
<div>
<span className="text-blue-400">password:</span>
<span className="text-green-400 ml-2">
{showValues ? "secret123" : "****"}
</span>
</div>
<div>
<span className="text-blue-400">api-key:</span>
<span className="text-green-400 ml-2">
{showValues ? "sk-abc123xyz" : "****"}
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
<TabsContent value="metadata" className="h-full overflow-y-auto">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-mono">{secretName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Namespace</span>
<span className="font-mono">{namespace}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Type</span>
<Badge variant="secondary">Opaque</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">2 hours ago</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Labels</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { SecretInfo } from "@/lib/tauriCommands";
interface SecretListProps {
secrets: SecretInfo[];
_clusterId: string;
_namespace: string;
}
export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Type</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{secrets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No secrets found
</TableCell>
</TableRow>
) : (
secrets.map((secret) => (
<TableRow key={`${secret.name}-${secret.namespace}`}>
<TableCell className="font-medium">{secret.name}</TableCell>
<TableCell>{secret.namespace}</TableCell>
<TableCell>{secret.type}</TableCell>
<TableCell>{secret.data_keys}</TableCell>
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
<TableCell className="text-right">
<span className="text-sm">View/Edit</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,44 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { ServiceAccountInfo } from "@/lib/tauriCommands";
interface ServiceAccountListProps {
serviceAccounts: ServiceAccountInfo[];
_clusterId: string;
_namespace: string;
}
export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }: ServiceAccountListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Secrets</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{serviceAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No service accounts found
</TableCell>
</TableRow>
) : (
serviceAccounts.map((sa) => (
<TableRow key={`${sa.name}-${sa.namespace}`}>
<TableCell className="font-medium">{sa.name}</TableCell>
<TableCell>{sa.namespace}</TableCell>
<TableCell>{sa.secrets}</TableCell>
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,157 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { X } from "lucide-react";
import { YamlEditor } from "./YamlEditor";
interface ServiceDetailProps {
serviceName: string;
namespace: string;
_clusterId: string;
onClose: () => void;
}
export function ServiceDetail({ serviceName, namespace, _clusterId, onClose }: ServiceDetailProps) {
const [activeTab, setActiveTab] = React.useState("overview");
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Service: {serviceName}</h2>
<Badge variant="outline">{namespace}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="endpoints">Endpoints</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="overview" className="h-full overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Service Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-mono">{serviceName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Namespace</span>
<span className="font-mono">{namespace}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Type</span>
<Badge variant="secondary">ClusterIP</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cluster IP</span>
<span className="font-mono">10.96.0.1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">External IP</span>
<span className="text-muted-foreground">none</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Port</span>
<span>80/TCP</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Selector</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Labels</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">app=web</Badge>
<Badge variant="secondary">tier=frontend</Badge>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="endpoints" className="h-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead>Port</TableHead>
<TableHead>Node</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>10.0.0.1</TableCell>
<TableCell>80</TableCell>
<TableCell>node-1</TableCell>
</TableRow>
<TableRow>
<TableCell>10.0.0.2</TableCell>
<TableCell>80</TableCell>
<TableCell>node-2</TableCell>
</TableRow>
<TableRow>
<TableCell>10.0.0.3</TableCell>
<TableCell>80</TableCell>
<TableCell>node-3</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value="yaml" className="h-full">
<YamlEditor onChange={() => {}} />
</TabsContent>
<TabsContent value="events" className="h-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>2 hours ago</TableCell>
<TableCell>SettingClusterIP</TableCell>
<TableCell>Normal</TableCell>
<TableCell>Assigned cluster IP 10.96.0.1</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,150 @@
import React from "react";
import { Terminal as TerminalIcon, X, Plus } from "lucide-react";
import { Button } from "@/components/ui";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
interface TerminalSession {
id: string;
clusterId: string;
namespace: string;
pod: string;
container: string;
command: string;
}
interface TerminalProps {
clusterId: string;
namespace: string;
}
export function Terminal({ clusterId, namespace }: TerminalProps) {
const [sessions, setSessions] = React.useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = React.useState<string | null>(null);
const [isCreating, setIsCreating] = React.useState(false);
const terminalRefs = React.useRef<Record<string, { destroy: () => void }>>({});
const containerRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const addSession = React.useCallback(() => {
setIsCreating(true);
const newSession: TerminalSession = {
id: `session-${Date.now()}`,
clusterId,
namespace: namespace === "all" ? "default" : namespace,
pod: "",
container: "",
command: "bash",
};
setSessions((prev) => [...prev, newSession]);
setActiveSessionId(newSession.id);
setIsCreating(false);
}, [clusterId, namespace]);
const removeSession = (sessionId: string) => {
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (activeSessionId === sessionId) {
setActiveSessionId(null);
}
if (terminalRefs.current[sessionId]) {
terminalRefs.current[sessionId].destroy();
delete terminalRefs.current[sessionId];
}
};
const resizeTerminal = (sessionId: string) => {
const terminal = terminalRefs.current[sessionId];
const container = containerRefs.current[sessionId];
if (terminal && container) {
// Placeholder for resize logic
// Requires xterm-addon-fit dependency
}
};
React.useEffect(() => {
// Initialize with a default session
if (sessions.length === 0 && !isCreating) {
addSession();
}
}, [sessions.length, isCreating, addSession]);
const initTerminal = (sessionId: string, element: HTMLDivElement | null) => {
if (!element || terminalRefs.current[sessionId]) return;
// Placeholder for terminal initialization
// Requires xterm, xterm-addon-fit, xterm-addon-web-links dependencies
const terminal = { destroy: () => {} };
terminalRefs.current[sessionId] = terminal;
containerRefs.current[sessionId] = element;
// Handle resize
window.addEventListener("resize", () => resizeTerminal(sessionId));
};
return (
<div className="h-full overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<TerminalIcon className="w-5 h-5" />
<h2 className="text-xl font-semibold">Terminal</h2>
</div>
<Button onClick={addSession} disabled={isCreating}>
<Plus className="w-4 h-4 mr-2" />
New Terminal
</Button>
</div>
{sessions.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4">
<TerminalIcon className="w-16 h-16 mx-auto text-muted-foreground" />
<p className="text-muted-foreground">No terminals open</p>
<Button onClick={addSession}>
<Plus className="w-4 h-4 mr-2" />
Open Terminal
</Button>
</div>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<Tabs value={activeSessionId || sessions[0]?.id} onValueChange={setActiveSessionId}>
<TabsList className="grid grid-cols-10 mb-2">
{sessions.map((session) => (
<TabsTrigger
key={session.id}
value={session.id}
className="flex items-center gap-2"
>
<span className="truncate max-w-[100px]">
{session.pod || "new"} / {session.container || "bash"}
</span>
<button
onClick={(e) => {
e.stopPropagation();
removeSession(session.id);
}}
className="hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</TabsTrigger>
))}
</TabsList>
{sessions.map((session) => (
<TabsContent
key={session.id}
value={session.id}
className="flex-1 overflow-hidden"
>
<div
ref={(el) => initTerminal(session.id, el)}
className="w-full h-full bg-slate-900 rounded-md overflow-hidden"
/>
</TabsContent>
))}
</Tabs>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,66 @@
import React from "react";
import { X, AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
import { Button } from "@/components/ui";
interface ToastProps {
message: string;
type?: "success" | "error" | "info" | "warning";
duration?: number;
onClose: () => void;
}
export function Toast({ message, type = "info", duration = 5000, onClose }: ToastProps) {
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
setVisible(false);
setTimeout(onClose, 300);
}, duration);
return () => clearTimeout(timer);
}
}, [duration, onClose]);
const icons = {
success: <CheckCircle className="w-5 h-5 text-green-500" />,
error: <AlertCircle className="w-5 h-5 text-red-500" />,
info: <Info className="w-5 h-5 text-blue-500" />,
warning: <AlertTriangle className="w-5 h-5 text-yellow-500" />,
};
const bgColors = {
success: "bg-green-50 border-green-200",
error: "bg-red-50 border-red-200",
info: "bg-blue-50 border-blue-200",
warning: "bg-yellow-50 border-yellow-200",
};
return (
<div
className={`fixed top-4 right-4 z-50 w-full max-w-sm transition-all duration-300 transform ${
visible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
}`}
>
<Card className={`border-l-4 ${bgColors[type]} shadow-lg`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{icons[type]}
<CardTitle className="text-sm font-medium">
{type.charAt(0).toUpperCase() + type.slice(1)}
</CardTitle>
</div>
<Button variant="ghost" size="sm" onClick={() => setVisible(false)}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{message}</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,35 @@
import React from "react";
import { Button } from "@/components/ui";
import { Badge } from "@/components/ui";
interface YamlEditorProps {
onChange: (value: string) => void;
}
export function YamlEditor({ onChange }: YamlEditorProps) {
return (
<div className="h-full flex flex-col">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">YAML Editor</h2>
<Badge variant="default" className="bg-green-600">Ready</Badge>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onChange("")}>
Clear
</Button>
<Button className="bg-primary">
Apply
</Button>
</div>
</div>
<div className="flex-1 rounded-md border overflow-hidden flex items-center justify-center">
<div className="text-center">
<p className="text-sm text-muted-foreground">YAML Editor would be displayed here</p>
<p className="text-xs mt-2">Requires @monaco-editor/react dependency</p>
</div>
</div>
</div>
);
}

View File

@ -8,3 +8,40 @@ export { ServiceList } from "./ServiceList";
export { DeploymentList } from "./DeploymentList";
export { StatefulSetList } from "./StatefulSetList";
export { DaemonSetList } from "./DaemonSetList";
export { NodeList } from "./NodeList";
export { EventList } from "./EventList";
export { ConfigMapList } from "./ConfigMapList";
export { SecretList } from "./SecretList";
export { ReplicaSetList } from "./ReplicaSetList";
export { JobList } from "./JobList";
export { CronJobList } from "./CronJobList";
export { IngressList } from "./IngressList";
export { PVCList } from "./PVCList";
export { PVList } from "./PVList";
export { ServiceAccountList } from "./ServiceAccountList";
export { RoleList } from "./RoleList";
export { ClusterRoleList } from "./ClusterRoleList";
export { RoleBindingList } from "./RoleBindingList";
export { ClusterRoleBindingList } from "./ClusterRoleBindingList";
export { HPAList } from "./HPAList";
export { Terminal } from "./Terminal";
export { YamlEditor } from "./YamlEditor";
export { MetricsChart } from "./MetricsChart";
export { SearchBar } from "./SearchBar";
export { ContextSwitcher } from "./ContextSwitcher";
export { ApplicationView } from "./ApplicationView";
export { PodDetail } from "./PodDetail";
export { DeploymentDetail } from "./DeploymentDetail";
export { ServiceDetail } from "./ServiceDetail";
export { ConfigMapDetail } from "./ConfigMapDetail";
export { SecretDetail } from "./SecretDetail";
export { ClusterOverview } from "./ClusterOverview";
export { ClusterDetails } from "./ClusterDetails";
export { Hotbar } from "./Hotbar";
export { CommandPalette } from "./CommandPalette";
export { Toast } from "./Toast";
export { LoadingSpinner } from "./LoadingSpinner";
export { CreateResourceModal } from "./CreateResourceModal";
export { EditResourceModal } from "./EditResourceModal";
export { RbacViewer } from "./RbacViewer";
export { RbacEditor } from "./RbacEditor";

116
src/lib/eventBus.ts Normal file
View File

@ -0,0 +1,116 @@
import { invoke } from "@tauri-apps/api/core";
export type EventCallback<T = any> = (data: T) => void;
export interface EventUnsubscribe {
(): void;
}
export interface EventBus {
on<T = any>(event: string, callback: EventCallback<T>): EventUnsubscribe;
off(event: string, callback: EventCallback): void;
emit<T = any>(event: string, data?: T): void;
once<T = any>(event: string, callback: EventCallback<T>): EventUnsubscribe;
}
class SimpleEventBus implements EventBus {
private events: Record<string, Set<EventCallback>> = {};
private onceEvents: Record<string, Set<EventCallback>> = {};
on<T = any>(event: string, callback: EventCallback<T>): EventUnsubscribe {
if (!this.events[event]) {
this.events[event] = new Set();
}
this.events[event].add(callback);
return () => this.off(event, callback);
}
off(event: string, callback: EventCallback): void {
if (this.events[event]) {
this.events[event].delete(callback);
}
}
emit<T = any>(event: string, data?: T): void {
const callbacks = this.events[event];
if (callbacks) {
callbacks.forEach((callback) => callback(data as T));
}
const onceCallbacks = this.onceEvents[event];
if (onceCallbacks) {
onceCallbacks.forEach((callback) => callback(data as T));
delete this.onceEvents[event];
}
}
once<T = any>(event: string, callback: EventCallback<T>): EventUnsubscribe {
if (!this.onceEvents[event]) {
this.onceEvents[event] = new Set();
}
this.onceEvents[event].add(callback);
return () => {
if (this.onceEvents[event]) {
this.onceEvents[event].delete(callback);
}
};
}
}
export const eventBus: EventBus = new SimpleEventBus();
export async function subscribeToK8sEvents(
clusterId: string,
namespace: string,
resourceType: string,
callback: EventCallback<any>
): Promise<EventUnsubscribe> {
try {
const unsubscribeId = await invoke<string>("subscribe_to_k8s_events", {
clusterId,
namespace,
resourceType,
});
const handler = (data: any) => {
callback(data);
};
eventBus.on(`k8s:${clusterId}:${namespace}:${resourceType}`, handler);
return () => {
eventBus.off(`k8s:${clusterId}:${namespace}:${resourceType}`, handler);
invoke<void>("unsubscribe_from_k8s_events", { unsubscribeId });
};
} catch (error) {
console.error("Failed to subscribe to K8s events:", error);
return () => {};
}
}
export async function subscribeToAllEvents(
clusterId: string,
callback: EventCallback<any>
): Promise<EventUnsubscribe> {
try {
const unsubscribeId = await invoke<string>("subscribe_to_all_k8s_events", {
clusterId,
});
const handler = (data: any) => {
callback(data);
};
eventBus.on(`k8s:${clusterId}:all`, handler);
return () => {
eventBus.off(`k8s:${clusterId}:all`, handler);
invoke<void>("unsubscribe_from_k8s_events", { unsubscribeId });
};
} catch (error) {
console.error("Failed to subscribe to all K8s events:", error);
return () => {};
}
}

View File

@ -748,6 +748,18 @@ export interface ClusterInfo {
cluster_url: string;
}
export interface ContextInfo {
name: string;
cluster: string;
user: string;
}
export interface ResourceInfo {
name: string;
namespace: string;
[key: string]: unknown;
}
export interface PortForwardRequest {
cluster_id: string;
namespace: string;

View File

@ -1,8 +1,10 @@
import React, { useState, useEffect } from "react";
import { useKubernetesStore } from "@/stores/kubernetesStore";
import { ClusterList } from "@/components/Kubernetes/ClusterList";
import { PortForwardList } from "@/components/Kubernetes/PortForwardList";
import { AddClusterModal } from "@/components/Kubernetes/AddClusterModal";
import { PortForwardForm } from "@/components/Kubernetes/PortForwardForm";
import { ResourceBrowser } from "@/components/Kubernetes/ResourceBrowser";
import type { ClusterInfo, PortForwardResponse } from "@/lib/tauriCommands";
import {
listClustersCmd,
@ -13,7 +15,7 @@ import {
} from "@/lib/tauriCommands";
export function KubernetesPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const { clusters, addCluster, removeCluster, selectedClusterId } = useKubernetesStore();
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddClusterOpen, setIsAddClusterOpen] = useState(false);
@ -30,7 +32,8 @@ export function KubernetesPage() {
listClustersCmd(),
listPortForwardsCmd(),
]);
setClusters(clustersData);
clustersData.forEach(addCluster);
setPortForwards(portForwardsData);
} catch (err) {
console.error("Failed to load data:", err);
@ -42,7 +45,7 @@ export function KubernetesPage() {
const handleRemoveCluster = async (clusterId: string) => {
try {
await removeClusterCmd(clusterId);
setClusters((prev) => prev.filter((c) => c.id !== clusterId));
removeCluster(clusterId);
} catch (err) {
console.error("Failed to remove cluster:", err);
alert("Failed to remove cluster");
@ -70,7 +73,7 @@ export function KubernetesPage() {
};
const handleAddCluster = (cluster: ClusterInfo) => {
setClusters((prev) => [...prev, cluster]);
addCluster(cluster);
};
const handleStartPortForward = (portForward: PortForwardResponse) => {
@ -93,16 +96,40 @@ export function KubernetesPage() {
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Management</h1>
<p className="text-muted-foreground">
Manage your Kubernetes clusters and port forwarding sessions
Manage your Kubernetes clusters and resources
</p>
</div>
<div className="grid gap-8">
{/* Cluster Management Section */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Clusters</h2>
<button
onClick={() => setIsAddClusterOpen(true)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Add Cluster
</button>
</div>
<ClusterList
clusters={clusters}
onAdd={() => setIsAddClusterOpen(true)}
onRemove={handleRemoveCluster}
/>
</div>
{/* Port Forwarding Section */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Port Forwarding</h2>
<button
onClick={() => setIsStartPortForwardOpen(true)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Start Port Forward
</button>
</div>
<PortForwardList
portForwards={portForwards}
@ -112,12 +139,22 @@ export function KubernetesPage() {
/>
</div>
{/* Resource Browser Section */}
{selectedClusterId && (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Resource Browser</h2>
<ResourceBrowser clusterId={selectedClusterId} />
</div>
)}
{/* Add Cluster Modal */}
<AddClusterModal
isOpen={isAddClusterOpen}
onClose={() => setIsAddClusterOpen(false)}
onAdd={handleAddCluster}
/>
{/* Port Forward Form */}
<PortForwardForm
isOpen={isStartPortForwardOpen}
onClose={() => setIsStartPortForwardOpen(false)}

View File

@ -0,0 +1,185 @@
import { create } from "zustand";
import type { ClusterInfo, ContextInfo, ResourceInfo } from "@/lib/tauriCommands";
export type ResourceType =
| "pods"
| "services"
| "deployments"
| "statefulsets"
| "daemonsets"
| "replicasets"
| "jobs"
| "cronjobs"
| "ingresses"
| "persistentvolumes"
| "persistentvolumeclaims"
| "configmaps"
| "secrets"
| "serviceaccounts"
| "roles"
| "clusterroles"
| "rolebindings"
| "clusterrolebindings"
| "nodes"
| "events"
| "hpas";
interface KubernetesState {
// Selection state
selectedClusterId: string | null;
selectedNamespace: string;
// Data state
clusters: ClusterInfo[];
contexts: ContextInfo[];
namespaces: Record<string, string[]>; // clusterId -> [namespaces]
// Loaded resources tracking
loadedResources: Set<ResourceType>;
// Terminal sessions
terminalSessions: Record<string, {
id: string;
clusterId: string;
namespace: string;
pod: string;
container: string;
command: string
}>;
nextTerminalId: number;
// Search state
globalSearchQuery: string;
searchResults: Record<ResourceType, ResourceInfo[]>;
// Bulk selection
bulkSelection: Record<ResourceType, string[]>; // resourceType -> [resourceNames]
// Actions
setSelectedCluster: (clusterId: string) => void;
setSelectedNamespace: (namespace: string) => void;
addCluster: (cluster: ClusterInfo) => void;
removeCluster: (clusterId: string) => void;
updateCluster: (clusterId: string, updates: Partial<ClusterInfo>) => void;
addContext: (context: ContextInfo) => void;
setNamespaces: (clusterId: string, namespaces: string[]) => void;
markResourceLoaded: (type: ResourceType) => void;
markResourceUnloaded: (type: ResourceType) => void;
isResourceLoaded: (type: ResourceType) => boolean;
addTerminalSession: (session: { clusterId: string; namespace: string; pod: string; container: string; command: string }) => string;
removeTerminalSession: (sessionId: string) => void;
setGlobalSearchQuery: (query: string) => void;
setSearchResults: (type: ResourceType, results: ResourceInfo[]) => void;
addToBulkSelection: (type: ResourceType, resourceName: string) => void;
removeFromBulkSelection: (type: ResourceType, resourceName: string) => void;
clearBulkSelection: (type: ResourceType) => void;
getBulkSelectionCount: (type: ResourceType) => number;
}
export const useKubernetesStore = create<KubernetesState>()((set, get) => ({
// Selection state
selectedClusterId: null,
selectedNamespace: "all",
// Data state
clusters: [],
contexts: [],
namespaces: {},
// Loaded resources tracking
loadedResources: new Set<ResourceType>(),
// Terminal sessions
terminalSessions: {},
nextTerminalId: 1,
// Search state
globalSearchQuery: "",
searchResults: {} as Record<ResourceType, ResourceInfo[]>,
// Bulk selection
bulkSelection: {} as Record<ResourceType, string[]>,
// Actions
setSelectedCluster: (clusterId) => set({ selectedClusterId: clusterId, selectedNamespace: "all" }),
setSelectedNamespace: (namespace) => set({ selectedNamespace: namespace }),
addCluster: (cluster) => set((state) => ({
clusters: [...state.clusters, cluster],
})),
removeCluster: (clusterId) => set((state) => ({
clusters: state.clusters.filter((c) => c.id !== clusterId),
selectedClusterId: state.selectedClusterId === clusterId ? null : state.selectedClusterId,
})),
updateCluster: (clusterId, updates) => set((state) => ({
clusters: state.clusters.map((c) =>
c.id === clusterId ? { ...c, ...updates } : c
),
})),
addContext: (context) => set((state) => ({
contexts: [...state.contexts, context],
})),
setNamespaces: (clusterId, namespaces) => set((state) => ({
namespaces: { ...state.namespaces, [clusterId]: namespaces },
})),
markResourceLoaded: (type) => set((state) => {
const newSet = new Set(state.loadedResources);
newSet.add(type);
return { loadedResources: newSet };
}),
markResourceUnloaded: (type) => set((state) => {
const newSet = new Set(state.loadedResources);
newSet.delete(type);
return { loadedResources: newSet };
}),
isResourceLoaded: (type) => get().loadedResources.has(type),
addTerminalSession: (session) => {
const sessionId = `terminal-${get().nextTerminalId}`;
set((state) => ({
terminalSessions: { ...state.terminalSessions, [sessionId]: { id: sessionId, ...session } },
nextTerminalId: state.nextTerminalId + 1,
}));
return sessionId;
},
removeTerminalSession: (sessionId) => set((state) => ({
terminalSessions: Object.fromEntries(
Object.entries(state.terminalSessions).filter(([id]) => id !== sessionId)
),
})),
setGlobalSearchQuery: (query) => set({ globalSearchQuery: query }),
setSearchResults: (type, results) => set((state) => ({
searchResults: { ...state.searchResults, [type]: results },
})),
addToBulkSelection: (type, resourceName) => set((state) => ({
bulkSelection: {
...state.bulkSelection,
[type]: [...(state.bulkSelection[type] || []), resourceName],
},
})),
removeFromBulkSelection: (type, resourceName) => set((state) => ({
bulkSelection: {
...state.bulkSelection,
[type]: (state.bulkSelection[type] || []).filter((name) => name !== resourceName),
},
})),
clearBulkSelection: (type) => set((state) => ({
bulkSelection: { ...state.bulkSelection, [type]: [] },
})),
getBulkSelectionCount: (type) => (get().bulkSelection[type] || []).length,
}));

6
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css' {
const classes: { [key: string]: string };
export default classes;
}

View File

@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useKubernetesStore } from "@/stores/kubernetesStore";
import type { ResourceInfo } from "@/lib/tauriCommands";
describe("Kubernetes Store", () => {
beforeEach(() => {
useKubernetesStore.getState().clusters.forEach((c) =>
useKubernetesStore.getState().removeCluster(c.id)
);
});
describe("Cluster Management", () => {
it("should add a cluster", () => {
const cluster = {
id: "cluster-1",
name: "Production",
context: "prod-context",
cluster_url: "https://k8s.example.com",
};
useKubernetesStore.getState().addCluster(cluster);
expect(useKubernetesStore.getState().clusters).toHaveLength(1);
expect(useKubernetesStore.getState().clusters[0].name).toBe("Production");
});
it("should remove a cluster", () => {
const cluster = {
id: "cluster-1",
name: "Production",
context: "prod-context",
cluster_url: "https://k8s.example.com",
};
useKubernetesStore.getState().addCluster(cluster);
useKubernetesStore.getState().removeCluster("cluster-1");
expect(useKubernetesStore.getState().clusters).toHaveLength(0);
});
it("should update a cluster", () => {
const cluster = {
id: "cluster-1",
name: "Production",
context: "prod-context",
cluster_url: "https://k8s.example.com",
};
useKubernetesStore.getState().addCluster(cluster);
useKubernetesStore.getState().updateCluster("cluster-1", { name: "Production New" });
expect(useKubernetesStore.getState().clusters[0].name).toBe("Production New");
});
it("should set selected cluster", () => {
const cluster = {
id: "cluster-1",
name: "Production",
context: "prod-context",
cluster_url: "https://k8s.example.com",
};
useKubernetesStore.getState().addCluster(cluster);
useKubernetesStore.getState().setSelectedCluster("cluster-1");
expect(useKubernetesStore.getState().selectedClusterId).toBe("cluster-1");
});
});
describe("Namespace Management", () => {
it("should set selected namespace", () => {
useKubernetesStore.getState().setSelectedNamespace("default");
expect(useKubernetesStore.getState().selectedNamespace).toBe("default");
});
it("should set namespaces for a cluster", () => {
useKubernetesStore.getState().setNamespaces("cluster-1", ["default", "kube-system", "production"]);
expect(useKubernetesStore.getState().namespaces["cluster-1"]).toEqual(["default", "kube-system", "production"]);
});
});
describe("Resource Loading", () => {
it("should mark resource as loaded", () => {
useKubernetesStore.getState().markResourceLoaded("pods");
expect(useKubernetesStore.getState().isResourceLoaded("pods")).toBe(true);
});
it("should mark resource as unloaded", () => {
useKubernetesStore.getState().markResourceLoaded("pods");
useKubernetesStore.getState().markResourceUnloaded("pods");
expect(useKubernetesStore.getState().isResourceLoaded("pods")).toBe(false);
});
});
describe("Terminal Sessions", () => {
it("should add a terminal session", () => {
const sessionId = useKubernetesStore.getState().addTerminalSession({
clusterId: "cluster-1",
namespace: "default",
pod: "nginx",
container: "nginx",
command: "bash",
});
expect(sessionId).toBe("terminal-1");
expect(useKubernetesStore.getState().terminalSessions[sessionId]).toBeDefined();
});
it("should remove a terminal session", () => {
const sessionId = useKubernetesStore.getState().addTerminalSession({
clusterId: "cluster-1",
namespace: "default",
pod: "nginx",
container: "nginx",
command: "bash",
});
useKubernetesStore.getState().removeTerminalSession(sessionId);
expect(useKubernetesStore.getState().terminalSessions[sessionId]).toBeUndefined();
});
});
describe("Search", () => {
it("should set global search query", () => {
useKubernetesStore.getState().setGlobalSearchQuery("nginx");
expect(useKubernetesStore.getState().globalSearchQuery).toBe("nginx");
});
it("should set search results", () => {
const results = [{ name: "nginx-1", namespace: "default" }];
useKubernetesStore.getState().setSearchResults("pods", results as ResourceInfo[]);
expect(useKubernetesStore.getState().searchResults.pods).toEqual(results);
});
});
describe("Bulk Selection", () => {
it("should add to bulk selection", () => {
useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1");
expect(useKubernetesStore.getState().bulkSelection.pods).toContain("nginx-1");
});
it("should remove from bulk selection", () => {
useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1");
useKubernetesStore.getState().removeFromBulkSelection("pods", "nginx-1");
expect(useKubernetesStore.getState().bulkSelection.pods).not.toContain("nginx-1");
});
it("should clear bulk selection", () => {
useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1");
useKubernetesStore.getState().clearBulkSelection("pods");
expect(useKubernetesStore.getState().bulkSelection.pods).toEqual([]);
});
it("should get bulk selection count", () => {
useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1");
useKubernetesStore.getState().addToBulkSelection("pods", "nginx-2");
expect(useKubernetesStore.getState().getBulkSelectionCount("pods")).toBe(2);
});
});
});

View File

@ -15,8 +15,9 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"paths": { "@/*": ["./src/*"] },
"types": ["vitest/globals", "@testing-library/jest-dom", "node"]
},
"include": ["src", "tests/unit"],