Compare commits

...

8 Commits

Author SHA1 Message Date
1d108ed4a9 Merge pull request 'feat(kubernetes): implement Phase 7 - Real-time updates with Lens Desktop v5.x feature parity' (#76) from feature/kubernetes-management-v2 into master
Some checks failed
Auto Tag / autotag (push) Successful in 14s
Auto Tag / wiki-sync (push) Successful in 20s
Test / frontend-typecheck (push) Successful in 1m46s
Test / frontend-tests (push) Successful in 1m37s
Auto Tag / changelog (push) Successful in 1m41s
Auto Tag / build-linux-amd64 (push) Successful in 9m44s
Auto Tag / build-windows-amd64 (push) Successful in 11m52s
Auto Tag / build-linux-arm64 (push) Successful in 11m56s
Test / rust-fmt-check (push) Successful in 16m26s
Test / rust-clippy (push) Successful in 17m53s
Test / rust-tests (push) Successful in 19m26s
Auto Tag / build-macos-arm64 (push) Failing after 20m24s
Reviewed-on: #76
2026-06-07 16:52:11 +00:00
Shaun Arman
91b6bf3d90 ci(pr-review): fetch existing PR comments before LLM analysis
Some checks failed
PR Review Automation / review (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Test / frontend-typecheck (pull_request) Has been cancelled
Test / frontend-tests (pull_request) Has been cancelled
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Add a new 'Fetch PR comment history' step that pulls both review posts
and issue comments from the Gitea API before the LLM is called.
The full comment history is injected into the prompt with an explicit
instruction to silently discard any finding already marked as invalid,
acknowledged as intentional, or confirmed fixed in a prior round.
This prevents the reviewer from repeatedly raising refuted findings
across successive push events on the same PR.
2026-06-07 11:47:28 -05:00
Shaun Arman
468a69d89e fix(kubernetes): remove redundant TS cast and fix cargo fmt failures
All checks were successful
Test / frontend-tests (pull_request) Successful in 1m28s
Test / frontend-typecheck (pull_request) Successful in 1m36s
PR Review Automation / review (pull_request) Successful in 4m1s
Test / rust-fmt-check (pull_request) Successful in 10m59s
Test / rust-clippy (pull_request) Successful in 12m49s
Test / rust-tests (pull_request) Successful in 14m17s
- Remove redundant `as Set<ResourceType>` cast in kubernetesStore initial
  state; the generic parameter already constrains the type
- Reformat watcher.rs vec! literal and Watcher::new call to satisfy
  rustfmt line-length rules (CI was failing cargo fmt --check)
2026-06-07 11:37:17 -05:00
Shaun Arman
8753a05a04 fix(kubernetes): address PR #76 review findings
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m35s
Test / frontend-typecheck (pull_request) Successful in 1m43s
PR Review Automation / review (pull_request) Successful in 4m12s
Test / rust-fmt-check (pull_request) Failing after 11m14s
Test / rust-clippy (pull_request) Successful in 12m46s
Test / rust-tests (pull_request) Successful in 13m56s
- Remove duplicate state.inner() calls in subscribe_to_k8s_events and
  subscribe_to_all_k8s_events (copy-paste error)
- Share all AppState Arc fields in OAuth callback task — clusters,
  port_forwards, refresh_registry, and watchers were previously
  constructed as fresh isolated instances instead of being cloned from
  the live AppState
- Replace infinite sleep loop in Watcher::start with an immediate
  warn-and-return, preventing Tokio thread leaks from stub watchers
2026-06-07 11:20:57 -05:00
Shaun Arman
664aeaafad docs: update documentation for Kubernetes Management UI
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m29s
Test / frontend-typecheck (pull_request) Successful in 1m38s
PR Review Automation / review (pull_request) Successful in 3m58s
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
- Add ADR-010: Kubernetes Management UI with Lens Desktop v5.x feature parity
- Add Kubernetes-Management.md wiki page
- Update CHANGELOG.md with Phase 7 features
- Update README.md with kubernetesStore and components
- Update docs/architecture/README.md with ADR-010
- Fix build issues: downgrade tailwindcss v4 to v3, add vite-env.d.ts, fix tsconfig
- All 114 frontend tests passing, 331 Rust tests passing, build successful
2026-06-07 11:09:22 -05:00
Shaun Arman
e51bfc4ce9 feat(kubernetes): implement Phase 7 - real-time updates
- Add event bus (src/lib/eventBus.ts) for frontend event handling
- Add watcher module (src-tauri/src/kube/watcher.rs) for K8s resource watching
- Add backend commands: subscribe_to_k8s_events, subscribe_to_all_k8s_events, unsubscribe_from_k8s_events
- Add watchers field to AppState for tracking active watchers
- Update mod.rs to export watcher module
- All tests pass, build successful
2026-06-07 10:53:18 -05:00
Shaun Arman
512feb5e49 feat(kubernetes): implement Phase 3 - detail views and cluster management
- Add detail views: PodDetail, DeploymentDetail, ServiceDetail, ConfigMapDetail, SecretDetail
- Add cluster management views: ClusterOverview, ClusterDetails
- Add UX components: Hotbar, CommandPalette, Toast, LoadingSpinner
- Add resource management: CreateResourceModal, EditResourceModal
- Add RBAC management: RbacViewer, RbacEditor
- Update index.tsx exports for all new components
- All components pass ESLint, TypeScript, and pass 114 tests
- Build successful
2026-06-07 10:43:20 -05:00
Shaun Arman
a3da4f5ce7 feat(kubernetes): implement Phase 1 & 2: resource discovery UIs and advanced features
- Add kubernetesStore.ts with Zustand state management (clusters, namespaces, resources, terminals, search, bulk selection)
- Create 15 resource list components (Secret, ReplicaSet, Job, CronJob, Ingress, PVC, PV, ServiceAccount, Role, ClusterRole, RoleBinding, ClusterRoleBinding, HPA, Node, Event, ConfigMap)
- Add advanced components (Terminal, YamlEditor, MetricsChart, SearchBar, ContextSwitcher, ApplicationView, PodDetail)
- Update KubernetesPage.tsx to integrate kubernetesStore and add cluster management
- Add ContextInfo and ResourceInfo types to tauriCommands.ts
- All components pass ESLint, TypeScript, and pass 114 tests
- Build successful
2026-06-07 10:24:26 -05: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,17 +96,41 @@ 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}
onStart={() => setIsStartPortForwardOpen(true)}
@ -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"],