Initial commit
This commit is contained in:
parent
56f7e8c933
commit
d95f11dd79
|
|
@ -39,3 +39,9 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# sqlite database
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-journal
|
||||||
|
/data/*.db-wal
|
||||||
|
/data/*.db-shm
|
||||||
|
|
|
||||||
102
README.md
102
README.md
|
|
@ -1,36 +1,98 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Release Orchestration & Tracking System
|
||||||
|
|
||||||
|
A web application to manage, track, and execute multi-customer deployment workflows across multiple Kubernetes clusters.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-Cluster Support**: Manage multiple K8s clusters, each hosting one or more customers
|
||||||
|
- **Customer Management**: Organize customers by cluster with namespace isolation
|
||||||
|
- **Release Management**: Create and track releases of different types (onboarding, regular release, hotfix)
|
||||||
|
- **Step Templates**: Define common deployment and verification steps
|
||||||
|
- **Customer-Specific Customization**: Override or add custom steps per customer
|
||||||
|
- **Matrix View**: Visual progress tracking across all customers and clusters
|
||||||
|
- **Execution Tracking**: Mark steps as done, skipped, or reverted with notes
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 14+ (App Router)
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Database**: SQLite (via better-sqlite3)
|
||||||
|
- **ORM**: Drizzle ORM
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **UI Components**: shadcn/ui
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
First, run the development server:
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20.x or higher
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm install
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
2. Run the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
3. Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
### Database
|
||||||
|
|
||||||
## Learn More
|
The application uses SQLite with a local database file stored in `data/app.db`. The database schema is automatically initialized on first run.
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Usage
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
### 1. Setup Your Infrastructure
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
1. **Create Clusters**: Go to Clusters page and add your Kubernetes clusters
|
||||||
|
2. **Add Customers**: For each cluster, add customers with their namespaces
|
||||||
|
|
||||||
## Deploy on Vercel
|
### 2. Create a Release
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
1. Go to Releases page and click "Create Release"
|
||||||
|
2. Choose release type:
|
||||||
|
- **Onboarding**: For new customer setup
|
||||||
|
- **Regular Release**: Standard deployment with version number
|
||||||
|
- **Hotfix**: Emergency fixes
|
||||||
|
3. Define deployment and verification steps
|
||||||
|
4. Activate the release to generate customer steps
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
### 3. Track Progress
|
||||||
|
|
||||||
|
1. View the matrix visualization to see all customers' progress
|
||||||
|
2. Click on individual steps to mark them as done or skipped
|
||||||
|
3. Override step content for specific customers if needed
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Next.js pages
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── actions/ # Server Actions
|
||||||
|
│ │ ├── db/ # Database schema and connection
|
||||||
|
│ │ └── utils/ # Utilities
|
||||||
|
├── data/ # SQLite database
|
||||||
|
└── docs/ # Documentation (FSD, TSD)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Auto-execution of bash/SQL scripts via K8s agents
|
||||||
|
- Multi-user support with authentication
|
||||||
|
- Audit logging
|
||||||
|
- Jenkins/Rancher API integrations
|
||||||
|
- Email notifications
|
||||||
|
- Scheduled releases
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,552 @@
|
||||||
|
# Functional Specification Document
|
||||||
|
## Release Orchestration & Tracking System
|
||||||
|
|
||||||
|
### Version: 1.0
|
||||||
|
### Date: 2026-02-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
### 1.1 Purpose
|
||||||
|
A web application to manage, track, and execute multi-customer deployment workflows across multiple Kubernetes clusters with customer-specific variations.
|
||||||
|
|
||||||
|
### 1.2 Target User
|
||||||
|
Developer/Release manager managing multiple isolated customer environments across one or more K8s clusters.
|
||||||
|
|
||||||
|
### 1.3 Core Value
|
||||||
|
- Eliminate forgotten steps across customers and clusters
|
||||||
|
- Prevent wrong script execution on wrong customers
|
||||||
|
- Provide clear visibility of deployment progress per customer and cluster
|
||||||
|
- Manage customers organized by their hosting clusters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Personas & Stories
|
||||||
|
|
||||||
|
### Persona: Release Manager (Developer)
|
||||||
|
|
||||||
|
| ID | User Story |
|
||||||
|
|----|-----------|
|
||||||
|
| US-001 | As a release manager, I want to create a release with deployment and verification steps so that I can standardize the release process |
|
||||||
|
| US-002 | As a release manager, I want to define common steps that apply to all customers so that I don't repeat myself |
|
||||||
|
| US-003 | As a release manager, I want to customize steps per customer so that customer-specific requirements are handled |
|
||||||
|
| US-004 | As a release manager, I want to track which steps are completed for each customer so that I don't miss anything |
|
||||||
|
| US-005 | As a release manager, I want to see the execution status at a glance so that I can identify blockers quickly |
|
||||||
|
| US-006 | As a release manager, I want to manage multiple K8s clusters so that I can organize customers by their hosting infrastructure |
|
||||||
|
| US-007 | As a release manager, I want to see which cluster a customer belongs to so that I can execute cluster-specific operations correctly |
|
||||||
|
| US-008 | As a release manager, I want to filter/view customers by cluster so that I can focus on one cluster at a time |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Core Entities
|
||||||
|
|
||||||
|
### 3.1 Entity Relationship Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Cluster │────<│ Customer │>────│ CustomerStep │
|
||||||
|
├─────────────────┤ 1:M ├──────────────────┤ M:1 ├─────────────────┤
|
||||||
|
│ id │ │ id │ │ id │
|
||||||
|
│ name │ │ cluster_id (FK) │ │ release_id (FK) │
|
||||||
|
│ kubeconfig_path │ │ namespace │ │ customer_id(FK) │
|
||||||
|
│ description │ │ name │ │ template_id(FK) │
|
||||||
|
│ is_active │ │ description │ │ ... │
|
||||||
|
│ metadata │ │ is_active │ │ status │
|
||||||
|
│ created_at │ │ metadata │ │ content │
|
||||||
|
└─────────────────┘ │ created_at │ └─────────────────┘
|
||||||
|
└──────────────────┘ │
|
||||||
|
^ │
|
||||||
|
│ │
|
||||||
|
│ M:1 │ M:1
|
||||||
|
│ │
|
||||||
|
┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ StepTemplate │<────│ Release │
|
||||||
|
├──────────────────┤ M:1 ├─────────────────┤
|
||||||
|
│ id │ │ id │
|
||||||
|
│ release_id (FK) │ │ name │
|
||||||
|
│ name │ │ type │
|
||||||
|
│ type │ │ status │
|
||||||
|
│ content │ │ version_number │
|
||||||
|
│ category │ │ release_date │
|
||||||
|
│ order_index │ │ description │
|
||||||
|
└──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Entity Definitions
|
||||||
|
|
||||||
|
#### Cluster
|
||||||
|
Represents a Kubernetes cluster that hosts one or more customers.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Primary key |
|
||||||
|
| name | string | Cluster name (e.g., "production-eu", "staging-us") |
|
||||||
|
| kubeconfig_path | string | Path to kubeconfig file (optional, for future auto-execution) |
|
||||||
|
| description | text | Human-readable description |
|
||||||
|
| is_active | boolean | Whether cluster is active |
|
||||||
|
| metadata | json | Extensible metadata (API endpoints, region, etc.) |
|
||||||
|
| created_at | timestamp | Creation time |
|
||||||
|
| updated_at | timestamp | Last update time |
|
||||||
|
|
||||||
|
#### Customer
|
||||||
|
Represents a tenant/customer deployed in a specific namespace within a cluster.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Primary key |
|
||||||
|
| cluster_id | integer | FK to Cluster |
|
||||||
|
| namespace | string | K8s namespace (e.g., "customer-acme-prod") |
|
||||||
|
| name | string | Customer display name |
|
||||||
|
| description | text | Description |
|
||||||
|
| is_active | boolean | Whether customer is active |
|
||||||
|
| metadata | json | Extensible metadata |
|
||||||
|
| created_at | timestamp | Creation time |
|
||||||
|
| updated_at | timestamp | Last update time |
|
||||||
|
|
||||||
|
#### Release
|
||||||
|
Represents a deployment/release cycle.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Primary key |
|
||||||
|
| name | string | Release name |
|
||||||
|
| type | enum | `onboarding`, `release`, `hotfix` |
|
||||||
|
| status | enum | `draft`, `active`, `archived` |
|
||||||
|
| version_number | string | Version (e.g., "v2.5.0") for release type |
|
||||||
|
| release_date | timestamp | Target release date |
|
||||||
|
| description | text | Release notes/description |
|
||||||
|
| metadata | json | Additional properties |
|
||||||
|
| created_at | timestamp | Creation time |
|
||||||
|
| updated_at | timestamp | Last update time |
|
||||||
|
|
||||||
|
#### StepTemplate
|
||||||
|
Common step definition at release level.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Primary key |
|
||||||
|
| release_id | integer | FK to Release |
|
||||||
|
| name | string | Step name |
|
||||||
|
| category | enum | `deploy` or `verify` |
|
||||||
|
| type | enum | `bash`, `sql`, `text` |
|
||||||
|
| content | text | Step content (script, SQL, instructions) |
|
||||||
|
| order_index | integer | Execution order within category |
|
||||||
|
| description | text | Step description |
|
||||||
|
| created_at | timestamp | Creation time |
|
||||||
|
|
||||||
|
#### CustomerStep
|
||||||
|
Customer-specific step instance (actual execution unit).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Primary key |
|
||||||
|
| release_id | integer | FK to Release |
|
||||||
|
| customer_id | integer | FK to Customer |
|
||||||
|
| template_id | integer | FK to StepTemplate (nullable for custom steps) |
|
||||||
|
| name | string | Step name (copied or custom) |
|
||||||
|
| category | enum | `deploy` or `verify` |
|
||||||
|
| type | enum | `bash`, `sql`, `text` |
|
||||||
|
| content | text | Step content (may be overridden) |
|
||||||
|
| order_index | integer | Execution order |
|
||||||
|
| status | enum | `pending`, `done`, `skipped`, `reverted` |
|
||||||
|
| executed_at | timestamp | When step was executed |
|
||||||
|
| executed_by | string | Who executed (for future multi-user) |
|
||||||
|
| skip_reason | text | Reason if skipped |
|
||||||
|
| notes | text | Execution notes |
|
||||||
|
| is_custom | boolean | True if not from template |
|
||||||
|
| is_overridden | boolean | True if template content was changed |
|
||||||
|
| created_at | timestamp | Creation time |
|
||||||
|
| updated_at | timestamp | Last update time |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Feature Specifications
|
||||||
|
|
||||||
|
### 4.1 Cluster Management
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FC-001 | Create Cluster | Add new K8s cluster with name, kubeconfig path (optional), description |
|
||||||
|
| FC-002 | Edit Cluster | Update cluster details |
|
||||||
|
| FC-003 | Delete Cluster | Soft delete cluster (only if no active customers) |
|
||||||
|
| FC-004 | List Clusters | View all clusters with customer count |
|
||||||
|
| FC-005 | View Cluster Detail | See cluster info and list of customers in it |
|
||||||
|
| FC-006 | Cluster Filter | Filter customers/releases by cluster |
|
||||||
|
|
||||||
|
### 4.2 Customer Management
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FM-001 | Create Customer | Add customer with name, namespace, and assign to cluster |
|
||||||
|
| FM-002 | Edit Customer | Update customer details or move to different cluster |
|
||||||
|
| FM-003 | Delete Customer | Soft delete (archive) customer |
|
||||||
|
| FM-004 | List Customers | View all customers with cluster and namespace info |
|
||||||
|
| FM-005 | View Customer Detail | See customer info, cluster, namespace, and release history |
|
||||||
|
| FM-006 | Filter by Cluster | Show only customers in selected cluster(s) |
|
||||||
|
|
||||||
|
### 4.3 Release Management
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FR-001 | Create Release | Create with type, name, description, version/date for release type |
|
||||||
|
| FR-002 | Release Lifecycle | Draft → Active → Archived workflow |
|
||||||
|
| FR-003 | Activate Release | Copies template steps to all active customers |
|
||||||
|
| FR-004 | Clone Release | Use existing release as template for new one |
|
||||||
|
| FR-005 | Archive Release | Archive completed/abandoned releases |
|
||||||
|
| FR-006 | View Release Dashboard | See progress across all customers and clusters |
|
||||||
|
| FR-007 | Filter Dashboard | Filter by cluster to focus on specific infrastructure |
|
||||||
|
|
||||||
|
### 4.4 Step Management (Template Layer)
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FS-001 | Add Template Step | Add common step with category, type, content |
|
||||||
|
| FS-002 | Edit Template Step | Modify common step (affects pending customer steps only) |
|
||||||
|
| FS-003 | Reorder Steps | Drag-and-drop to reorder steps |
|
||||||
|
| FS-004 | Delete Template Step | Remove step with warning |
|
||||||
|
| FS-005 | Syntax Highlight | Display bash/SQL/text with appropriate highlighting |
|
||||||
|
|
||||||
|
### 4.5 Customer Step Customization
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FCS-001 | View Inherited Steps | See template steps applied to customer |
|
||||||
|
| FCS-002 | Override Step Content | Modify step content for specific customer |
|
||||||
|
| FCS-003 | Add Custom Step | Add step only for specific customer |
|
||||||
|
| FCS-004 | Skip Step | Mark step as skipped with mandatory reason |
|
||||||
|
| FCS-005 | Revert Override | Reset to template content |
|
||||||
|
| FCS-006 | Remove Custom Step | Delete customer-specific step |
|
||||||
|
|
||||||
|
### 4.6 Execution Tracking
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FET-001 | Mark Done | Mark step as completed with optional notes |
|
||||||
|
| FET-002 | Mark Reverted | Revert a completed step |
|
||||||
|
| FET-003 | Skip Step | Skip with mandatory reason |
|
||||||
|
| FET-004 | Bulk Mark Done | Mark all deploy/verify steps done for customer |
|
||||||
|
| FET-005 | Progress Indicators | Show % complete per customer, per cluster, per category |
|
||||||
|
|
||||||
|
### 4.7 Dashboard & Views
|
||||||
|
|
||||||
|
| ID | Feature | Description |
|
||||||
|
|----|---------|-------------|
|
||||||
|
| FV-001 | Main Dashboard | Overview of active releases, recent activity |
|
||||||
|
| FV-002 | Release List | All releases with type badges and status |
|
||||||
|
| FV-003 | Release Matrix View | Grid: Steps × Customers with cluster grouping |
|
||||||
|
| FV-004 | Customer View | Steps list for specific customer |
|
||||||
|
| FV-005 | Step Cross-Customer View | Status of one step across all customers |
|
||||||
|
| FV-006 | Cluster View | All customers in a cluster with their release status |
|
||||||
|
| FV-007 | Step Detail Modal | Full content display with syntax highlight and copy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI/UX Design
|
||||||
|
|
||||||
|
### 5.1 Navigation Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🏠 Dashboard 🗂️ Clusters 👥 Customers 📦 Releases │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Dashboard View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Dashboard [+ Release] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Active Releases │ │ Clusters │ │ Quick Stats │ │
|
||||||
|
│ │ ━━━━━━━━━━━━━ │ │ ━━━━━━━━━━━━━ │ │ ━━━━━━━━━━━━━ │ │
|
||||||
|
│ │ • v2.5.0 (Rel) │ │ • prod-us (3) │ │ Pending: 47 │ │
|
||||||
|
│ │ • Hotfix-2024-1 │ │ • prod-eu (2) │ │ Done: 128 │ │
|
||||||
|
│ │ │ │ • staging (1) │ │ Skipped: 5 │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Recent Activity │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • [v2.5.0] Customer A (prod-us) - Deploy Step 3 Done │ │
|
||||||
|
│ │ • [v2.5.0] Customer B (prod-eu) - Verify Step 1 Skipped │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Clusters List View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Clusters [+ Cluster] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🟢 prod-us [Edit][🗑️] │ │
|
||||||
|
│ │ Path: ~/.kube/prod-us-config │ │
|
||||||
|
│ │ Region: us-east-1 | 3 Customers │ │
|
||||||
|
│ │ Customers: customer-a, customer-b, customer-c │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🟢 prod-eu [Edit][🗑️] │ │
|
||||||
|
│ │ Path: ~/.kube/prod-eu-config │ │
|
||||||
|
│ │ Region: eu-west-1 | 2 Customers │ │
|
||||||
|
│ │ Customers: customer-d, customer-e │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🟡 staging [Edit][🗑️] │ │
|
||||||
|
│ │ Path: ~/.kube/staging-config │ │
|
||||||
|
│ │ Region: internal | 1 Customer │ │
|
||||||
|
│ │ Customers: demo-customer │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Customers List View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Customers [Filter: All Clusters ▼] [+ Add]│
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 📁 prod-us (3 customers) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ customer-a namespace: cust-a-prod [Edit] │ │
|
||||||
|
│ │ customer-b namespace: cust-b-prod [Edit] │ │
|
||||||
|
│ │ customer-c namespace: cust-c-prod [Edit] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 📁 prod-eu (2 customers) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ customer-d namespace: cust-d-prod [Edit] │ │
|
||||||
|
│ │ customer-e namespace: cust-e-prod [Edit] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Release Matrix View (Cluster-Aware)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Release: v2.5.0 (Regular Release) │
|
||||||
|
│ Type: release | Status: Active | Date: 2024-01-15 │
|
||||||
|
│ [Deploy Tab] [Verify Tab] [Settings] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Filter: [All Clusters ▼] [All Status ▼] [Expand All] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 📁 prod-us │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ ┌──────────┬─────────────────┬─────────────────┬────────────┐ │
|
||||||
|
│ │ Step │ customer-a │ customer-b │ customer-c │ │
|
||||||
|
│ │ │ (cust-a-prod) │ (cust-b-prod) │ (cust-c..) │ │
|
||||||
|
│ ├──────────┼─────────────────┼─────────────────┼────────────┤ │
|
||||||
|
│ │ 1. Deploy│ ✅ Done │ ✅ Done │ 🔄 Pending │ │
|
||||||
|
│ │ 2. SQL ⭐ │ ✅ Done │ ⚠️ Overridden │ 🔄 Pending │ │
|
||||||
|
│ │ 3. Config│ ⏸️ Skipped │ 🔄 Pending │ 🔄 Pending │ │
|
||||||
|
│ └──────────┴─────────────────┴─────────────────┴────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 📁 prod-eu │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ ┌──────────┬─────────────────┬─────────────────┐ │
|
||||||
|
│ │ Step │ customer-d │ customer-e │ │
|
||||||
|
│ │ │ (cust-d-prod) │ (cust-e-prod) │ │
|
||||||
|
│ ├──────────┼─────────────────┼─────────────────┤ │
|
||||||
|
│ │ 1. Deploy│ ✅ Done │ 🔄 Pending │ │
|
||||||
|
│ │ 2. SQL │ 🔄 Pending │ 🔄 Pending │ │
|
||||||
|
│ │ 3. Config│ 🔄 Pending │ ⏸️ Skipped │ │
|
||||||
|
│ └──────────┴─────────────────┴─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Legend: ✅ Done | 🔄 Pending | ⏸️ Skipped | ⚠️ Custom/Overridden │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 Customer Step Detail View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Customer: customer-a │
|
||||||
|
│ Cluster: prod-us | Namespace: cust-a-prod │
|
||||||
|
│ Release: v2.5.0 [Back] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Deploy Steps (2/3 done) [Mark All] │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ✅ 1. Deploy via Jenkins (Done) │ │
|
||||||
|
│ │ Completed: 2024-01-15 10:30 │ │
|
||||||
|
│ │ [View Content] [Revert] │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ ✅ 2. Run Migration SQL ⭐ CUSTOM (Done) │ │
|
||||||
|
│ │ Overridden from template │ │
|
||||||
|
│ │ Completed: 2024-01-15 10:45 │ │
|
||||||
|
│ │ [View Content] [Revert] [Reset to Template] │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ 🔄 3. Update ConfigMap (Pending)│ │
|
||||||
|
│ │ [View Content] [Mark Done] [Skip] [Edit] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Verify Steps (0/2 done) [Mark All] │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🔄 1. Health Check API (Pending)│ │
|
||||||
|
│ │ [View Content] [Mark Done] [Skip] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 Step Content Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Step: Run Migration SQL [X] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Category: Deploy | Type: SQL │
|
||||||
|
│ Source: Template (edited for this customer) │
|
||||||
|
│ Customer: customer-a (prod-us/cust-a-prod) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ```sql │ │
|
||||||
|
│ │ -- Custom migration for customer-a │ │
|
||||||
|
│ │ ALTER TABLE users ADD COLUMN custom_field VARCHAR(100); │ │
|
||||||
|
│ │ UPDATE users SET custom_field = 'value' WHERE id > 100; │ │
|
||||||
|
│ │ ``` │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [📋 Copy] [⬇️ Download] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Execution Notes: │ │
|
||||||
|
│ │ [Optional: Add notes about this execution... ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Mark as Done] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Business Rules
|
||||||
|
|
||||||
|
### 6.1 Cluster Rules
|
||||||
|
- **BR-C01**: Cluster cannot be deleted if it has active customers
|
||||||
|
- **BR-C02**: Cluster name must be unique
|
||||||
|
- **BR-C03**: When viewing releases, customers are grouped by cluster for clarity
|
||||||
|
|
||||||
|
### 6.2 Customer Rules
|
||||||
|
- **BR-M01**: Customer namespace + cluster combination must be unique
|
||||||
|
- **BR-M02**: Customer can be moved between clusters (with warning about context change)
|
||||||
|
- **BR-M03**: Soft delete only; maintain history for audit
|
||||||
|
|
||||||
|
### 6.3 Release Rules
|
||||||
|
- **BR-R01**: When release is activated, steps are created for ALL active customers across ALL clusters
|
||||||
|
- **BR-R02**: Editing template step only affects customers where step is `pending`
|
||||||
|
- **BR-R03**: Once step is marked `done`, content is locked (prevent accidental changes)
|
||||||
|
- **BR-R04**: Reverting a step sets status to `reverted` but preserves history
|
||||||
|
|
||||||
|
### 6.4 Step Rules
|
||||||
|
- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template
|
||||||
|
- **BR-S02**: Override (is_overridden=true) preserves link to template for reference
|
||||||
|
- **BR-S03**: Skipping requires mandatory reason
|
||||||
|
- **BR-S04**: Order index is per category (deploy/verify have separate ordering)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workflow Scenarios
|
||||||
|
|
||||||
|
### 7.1 New Customer Onboarding
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Create new Cluster (if new infrastructure)
|
||||||
|
└─→ Enter name, kubeconfig path, description
|
||||||
|
|
||||||
|
2. Create new Customer in Cluster
|
||||||
|
└─→ Select cluster
|
||||||
|
└─→ Enter namespace, name, description
|
||||||
|
|
||||||
|
3. Create "onboarding" type Release
|
||||||
|
└─→ Enter release name, type=onboarding
|
||||||
|
└─→ Add template steps (common for all customers)
|
||||||
|
|
||||||
|
4. Activate Release
|
||||||
|
└─→ System creates CustomerStep for new customer
|
||||||
|
└─→ Customize steps if needed for this customer
|
||||||
|
|
||||||
|
5. Execute steps and track progress
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Regular Release Deployment
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Create "release" type Release
|
||||||
|
└─→ Enter version number (e.g., v2.5.0)
|
||||||
|
└─→ Set target release date
|
||||||
|
└─→ Add deploy steps and verify steps
|
||||||
|
|
||||||
|
2. Activate Release
|
||||||
|
└─→ Steps copied to all customers across all clusters
|
||||||
|
|
||||||
|
3. Execute per cluster or per customer
|
||||||
|
└─→ Use cluster filter to focus on one cluster
|
||||||
|
└─→ Mark steps done as completed
|
||||||
|
└─→ Override content if customer-specific changes needed
|
||||||
|
└─→ Add custom steps if needed
|
||||||
|
|
||||||
|
4. Monitor progress via matrix view
|
||||||
|
└─→ Check all customers have completed all steps
|
||||||
|
|
||||||
|
5. Archive release when done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Hotfix Deployment
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Create "hotfix" type Release
|
||||||
|
└─→ Enter hotfix name/description
|
||||||
|
└─→ May target specific customers only (via custom steps)
|
||||||
|
|
||||||
|
2. Add minimal steps for the fix
|
||||||
|
|
||||||
|
3. Activate and deploy
|
||||||
|
|
||||||
|
4. Verify and archive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Display Requirements
|
||||||
|
|
||||||
|
### 8.1 Cluster Information Display
|
||||||
|
Everywhere a customer is shown, the following should be visible:
|
||||||
|
- Cluster name
|
||||||
|
- Namespace
|
||||||
|
|
||||||
|
### 8.2 Progress Calculation
|
||||||
|
- **Per Customer**: (done_steps / total_steps) × 100
|
||||||
|
- **Per Cluster**: Average of all customers in cluster
|
||||||
|
- **Overall**: Average of all customers across all clusters
|
||||||
|
- **By Category**: Separate progress for deploy vs verify
|
||||||
|
|
||||||
|
### 8.3 Filtering & Sorting
|
||||||
|
- Filter customers by cluster
|
||||||
|
- Filter matrix view by cluster
|
||||||
|
- Sort customers by name, cluster, or progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future Extensibility
|
||||||
|
|
||||||
|
| Feature | Description | Current Preparation |
|
||||||
|
|---------|-------------|---------------------|
|
||||||
|
| Auto-execution | Execute bash/SQL via K8s agents | kubeconfig_path in Cluster, step type field |
|
||||||
|
| Multi-user | Authentication & authorization | executed_by field in CustomerStep |
|
||||||
|
| Audit log | Full change history | Template references, timestamps, override tracking |
|
||||||
|
| Batch operations | Execute on multiple clusters | Cluster grouping in UI |
|
||||||
|
| Integration APIs | Jenkins webhook, Rancher API | Metadata JSON fields for API endpoints |
|
||||||
|
| Scheduling | Schedule releases for specific time | release_date field |
|
||||||
|
| Notifications | Alert on step completion/failure | Status tracking infrastructure |
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/db/schema.ts',
|
||||||
|
out: './src/lib/db/migrations',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || 'file:./data/app.db',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Disable turbopack for now since we need webpack config for better-sqlite3
|
||||||
|
turbopack: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
|
|
@ -9,18 +9,39 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getReleaseById } from '@/lib/actions/releases';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const release = await getReleaseById(parseInt(id));
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return NextResponse.json({ error: 'Release not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(release);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { deleteStepTemplate } from '@/lib/actions/step-templates';
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await deleteStepTemplate(parseInt(id));
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { addStepTemplate, getNextOrderIndex } from '@/lib/actions/step-templates';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { releaseId, category, name, type, content, description } = body;
|
||||||
|
|
||||||
|
const orderIndex = await getNextOrderIndex(releaseId, category);
|
||||||
|
|
||||||
|
const step = await addStepTemplate({
|
||||||
|
releaseId,
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
orderIndex,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(step);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ClusterForm } from '@/components/clusters/cluster-form';
|
||||||
|
import { getClusterById } from '@/lib/actions/clusters';
|
||||||
|
|
||||||
|
interface EditClusterPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditClusterPage({ params }: EditClusterPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const cluster = await getClusterById(parseInt(id));
|
||||||
|
|
||||||
|
if (!cluster) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Edit Cluster</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Update cluster information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ClusterForm cluster={cluster} isEdit />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Server, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { getClusterWithCustomers } from '@/lib/actions/clusters';
|
||||||
|
|
||||||
|
interface ClusterDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ClusterDetailPage({ params }: ClusterDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const cluster = await getClusterWithCustomers(parseInt(id));
|
||||||
|
|
||||||
|
if (!cluster) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/clusters">
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{cluster.name}</h1>
|
||||||
|
<p className="text-slate-600">Cluster Details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5" />
|
||||||
|
Cluster Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{cluster.kubeconfigPath && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Kubeconfig Path</label>
|
||||||
|
<p className="text-slate-900">{cluster.kubeconfigPath}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cluster.description && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Description</label>
|
||||||
|
<p className="text-slate-900">{cluster.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Created</label>
|
||||||
|
<p className="text-slate-900">{cluster.createdAt ? new Date(cluster.createdAt).toLocaleString() : 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Customers ({cluster.customers.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{cluster.customers.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm">No customers in this cluster yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{cluster.customers.map((customer) => (
|
||||||
|
<li key={customer.id}>
|
||||||
|
<Link
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
className="block p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-slate-900">{customer.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{customer.namespace}</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Link href="/customers/new">
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
Add Customer
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ClusterForm } from '@/components/clusters/cluster-form';
|
||||||
|
|
||||||
|
export default function NewClusterPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Create Cluster</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Add a new Kubernetes cluster to manage your customers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ClusterForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ClusterCard } from '@/components/clusters/cluster-card';
|
||||||
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { customers } from '@/lib/db/schema';
|
||||||
|
import { eq, count } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export default async function ClustersPage() {
|
||||||
|
const clusters = await listClusters();
|
||||||
|
|
||||||
|
// Get customer counts for each cluster
|
||||||
|
const clustersWithCount = await Promise.all(
|
||||||
|
clusters.map(async (cluster) => {
|
||||||
|
const result = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.clusterId, cluster.id));
|
||||||
|
return {
|
||||||
|
...cluster,
|
||||||
|
customerCount: result[0]?.value || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Clusters</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Manage your Kubernetes clusters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/clusters/new">
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Cluster
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clustersWithCount.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
|
||||||
|
<p className="text-slate-600">No clusters yet. Create your first cluster to get started.</p>
|
||||||
|
<Link href="/clusters/new" className="mt-4 inline-block">
|
||||||
|
<Button>Create Cluster</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{clustersWithCount.map((cluster) => (
|
||||||
|
<ClusterCard key={cluster.id} cluster={cluster} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { CustomerForm } from '@/components/customers/customer-form';
|
||||||
|
import { getCustomerById } from '@/lib/actions/customers';
|
||||||
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
|
||||||
|
interface EditCustomerPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditCustomerPage({ params }: EditCustomerPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [customer, clusters] = await Promise.all([
|
||||||
|
getCustomerById(parseInt(id)),
|
||||||
|
listClusters(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Edit Customer</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Update customer information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CustomerForm customer={customer} clusters={clusters} isEdit />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Users, Server, Package } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { getCustomerById } from '@/lib/actions/customers';
|
||||||
|
import { listReleases } from '@/lib/actions/releases';
|
||||||
|
import { getCustomerSteps } from '@/lib/actions/customer-steps';
|
||||||
|
|
||||||
|
interface CustomerDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CustomerDetailPage({ params }: CustomerDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const customer = await getCustomerById(parseInt(id));
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = await listReleases();
|
||||||
|
|
||||||
|
// Get steps for each active release
|
||||||
|
const releasesWithSteps = await Promise.all(
|
||||||
|
releases
|
||||||
|
.filter(r => r.status === 'active')
|
||||||
|
.map(async (release) => {
|
||||||
|
const steps = await getCustomerSteps(release.id, customer.id);
|
||||||
|
const done = steps.filter(s => s.status === 'done').length;
|
||||||
|
const total = steps.length;
|
||||||
|
return {
|
||||||
|
...release,
|
||||||
|
steps,
|
||||||
|
progress: total > 0 ? Math.round((done / total) * 100) : 0,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/customers">
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{customer.name}</h1>
|
||||||
|
<p className="text-slate-600 flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
{customer.cluster?.name} / {customer.namespace}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Customer Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Namespace</label>
|
||||||
|
<p className="text-slate-900 font-mono">{customer.namespace}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Cluster</label>
|
||||||
|
<p className="text-slate-900">{customer.cluster?.name}</p>
|
||||||
|
</div>
|
||||||
|
{customer.description && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Description</label>
|
||||||
|
<p className="text-slate-900">{customer.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Created</label>
|
||||||
|
<p className="text-slate-900">{customer.createdAt ? new Date(customer.createdAt).toLocaleString() : 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
Active Releases
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{releasesWithSteps.length === 0 ? (
|
||||||
|
<p className="text-slate-500">No active releases.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{releasesWithSteps.map((release) => (
|
||||||
|
<div key={release.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-slate-900">{release.name}</h3>
|
||||||
|
<Badge variant={release.type === 'hotfix' ? 'destructive' : 'default'}>
|
||||||
|
{release.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
{release.done} / {release.total} steps completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-2xl font-bold text-slate-900">{release.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/releases/${release.id}`}>
|
||||||
|
<Button size="sm">View</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { CustomerForm } from '@/components/customers/customer-form';
|
||||||
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
|
||||||
|
export default async function NewCustomerPage() {
|
||||||
|
const clusters = await listClusters();
|
||||||
|
|
||||||
|
if (clusters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Create Customer</h1>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<p className="text-amber-800">
|
||||||
|
You need to create a cluster first before adding customers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Create Customer</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Add a new customer to a cluster
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CustomerForm clusters={clusters} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CustomerCard } from '@/components/customers/customer-card';
|
||||||
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
import { listCustomers } from '@/lib/actions/customers';
|
||||||
|
|
||||||
|
export default async function CustomersPage() {
|
||||||
|
const [customers, clusters] = await Promise.all([
|
||||||
|
listCustomers(),
|
||||||
|
listClusters(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Group customers by cluster
|
||||||
|
const groupedCustomers = customers.reduce((acc, customer) => {
|
||||||
|
const clusterId = customer.cluster?.id || 0;
|
||||||
|
const clusterName = customer.cluster?.name || 'Unknown Cluster';
|
||||||
|
|
||||||
|
if (!acc[clusterId]) {
|
||||||
|
acc[clusterId] = {
|
||||||
|
clusterName,
|
||||||
|
customers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[clusterId].customers.push(customer);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, { clusterName: string; customers: typeof customers }>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Customers</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Manage your customers grouped by cluster
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/customers/new">
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Customer
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
|
||||||
|
<p className="text-slate-600">No customers yet. Create your first customer to get started.</p>
|
||||||
|
<Link href="/customers/new" className="mt-4 inline-block">
|
||||||
|
<Button>Create Customer</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Object.entries(groupedCustomers).map(([clusterId, group]) => (
|
||||||
|
<div key={clusterId}>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-700 mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||||
|
{group.clusterName}
|
||||||
|
<span className="text-sm font-normal text-slate-500">
|
||||||
|
({group.customers.length} customers)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{group.customers.map((customer) => (
|
||||||
|
<CustomerCard key={customer.id} customer={customer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,125 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--radius: 0.625rem;
|
||||||
--background: #0a0a0a;
|
--background: oklch(1 0 0);
|
||||||
--foreground: #ededed;
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { initDb } from "@/lib/db";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -13,10 +15,13 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Release Tracker",
|
||||||
description: "Generated by create next app",
|
description: "Release Orchestration & Tracking System",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize database on startup
|
||||||
|
initDb().catch(console.error);
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
@ -25,9 +30,14 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-slate-50`}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 p-8 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
290
src/app/page.tsx
290
src/app/page.tsx
|
|
@ -1,65 +1,235 @@
|
||||||
import Image from "next/image";
|
import Link from 'next/link';
|
||||||
|
import { Package, Server, Users, CheckCircle, Clock, SkipForward } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
import { listCustomers } from '@/lib/actions/customers';
|
||||||
|
import { getActiveReleases, getReleaseStats } from '@/lib/actions/releases';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { customers } from '@/lib/db/schema';
|
||||||
|
import { eq, count } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const [clusters, customersList, activeReleases, stats] = await Promise.all([
|
||||||
|
listClusters(),
|
||||||
|
listCustomers(),
|
||||||
|
getActiveReleases(),
|
||||||
|
getReleaseStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get customer counts per cluster
|
||||||
|
const clustersWithCount = await Promise.all(
|
||||||
|
clusters.map(async (cluster) => {
|
||||||
|
const result = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.clusterId, cluster.id));
|
||||||
|
return {
|
||||||
|
...cluster,
|
||||||
|
customerCount: result[0]?.value || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="space-y-8">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Header */}
|
||||||
<Image
|
<div>
|
||||||
className="dark:invert"
|
<h1 className="text-3xl font-bold text-slate-900">Dashboard</h1>
|
||||||
src="/next.svg"
|
<p className="text-slate-600 mt-1">
|
||||||
alt="Next.js logo"
|
Overview of your release orchestration system
|
||||||
width={100}
|
</p>
|
||||||
height={20}
|
</div>
|
||||||
priority
|
|
||||||
/>
|
{/* Stats Cards */}
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<Card>
|
||||||
To get started, edit the page.tsx file.
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
</h1>
|
<CardTitle className="text-sm font-medium text-slate-500">
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
Active Releases
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
</CardTitle>
|
||||||
<a
|
<Package className="w-4 h-4 text-blue-600" />
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</CardHeader>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<CardContent>
|
||||||
>
|
<div className="text-3xl font-bold">{stats.activeReleases}</div>
|
||||||
Templates
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
</a>{" "}
|
of {stats.totalReleases} total
|
||||||
or the{" "}
|
</p>
|
||||||
<a
|
</CardContent>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</Card>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
<Card>
|
||||||
Learning
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
</a>{" "}
|
<CardTitle className="text-sm font-medium text-slate-500">
|
||||||
center.
|
Pending Steps
|
||||||
</p>
|
</CardTitle>
|
||||||
</div>
|
<Clock className="w-4 h-4 text-amber-600" />
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
</CardHeader>
|
||||||
<a
|
<CardContent>
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<div className="text-3xl font-bold">{stats.pendingSteps}</div>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
target="_blank"
|
awaiting completion
|
||||||
rel="noopener noreferrer"
|
</p>
|
||||||
>
|
</CardContent>
|
||||||
<Image
|
</Card>
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
<Card>
|
||||||
alt="Vercel logomark"
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
width={16}
|
<CardTitle className="text-sm font-medium text-slate-500">
|
||||||
height={16}
|
Completed Steps
|
||||||
/>
|
</CardTitle>
|
||||||
Deploy Now
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
</a>
|
</CardHeader>
|
||||||
<a
|
<CardContent>
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
<div className="text-3xl font-bold">{stats.doneSteps}</div>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
target="_blank"
|
successfully done
|
||||||
rel="noopener noreferrer"
|
</p>
|
||||||
>
|
</CardContent>
|
||||||
Documentation
|
</Card>
|
||||||
</a>
|
|
||||||
</div>
|
<Card>
|
||||||
</main>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-slate-500">
|
||||||
|
Total Customers
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="w-4 h-4 text-emerald-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{customersList.length}</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
across {clusters.length} clusters
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Active Releases */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Active Releases</CardTitle>
|
||||||
|
<Link href="/releases">
|
||||||
|
<Button variant="outline" size="sm">View All</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activeReleases.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<p>No active releases.</p>
|
||||||
|
<Link href="/releases/new">
|
||||||
|
<Button className="mt-4" size="sm">Create Release</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeReleases.slice(0, 5).map((release) => (
|
||||||
|
<Link
|
||||||
|
key={release.id}
|
||||||
|
href={`/releases/${release.id}`}
|
||||||
|
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-slate-900">{release.name}</h3>
|
||||||
|
<Badge variant={release.type === 'hotfix' ? 'destructive' : 'default'} className="text-xs">
|
||||||
|
{release.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{release.versionNumber && (
|
||||||
|
<p className="text-sm text-slate-500">{release.versionNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">View</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clusters Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Clusters</CardTitle>
|
||||||
|
<Link href="/clusters">
|
||||||
|
<Button variant="outline" size="sm">Manage</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{clustersWithCount.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<p>No clusters configured.</p>
|
||||||
|
<Link href="/clusters/new">
|
||||||
|
<Button className="mt-4" size="sm">Add Cluster</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{clustersWithCount.map((cluster) => (
|
||||||
|
<Link
|
||||||
|
key={cluster.id}
|
||||||
|
href={`/clusters/${cluster.id}`}
|
||||||
|
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="font-medium text-slate-900">{cluster.name}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{cluster.customerCount}</Badge>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Link href="/releases/new">
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
|
<CardContent className="flex items-center gap-4 py-6">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">Create Release</h3>
|
||||||
|
<p className="text-sm text-slate-500">Start a new deployment cycle</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/clusters/new">
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
|
<CardContent className="flex items-center gap-4 py-6">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Server className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">Add Cluster</h3>
|
||||||
|
<p className="text-sm text-slate-500">Register a new K8s cluster</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/customers/new">
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
|
<CardContent className="flex items-center gap-4 py-6">
|
||||||
|
<div className="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">Add Customer</h3>
|
||||||
|
<p className="text-sm text-slate-500">Onboard a new customer</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ReleaseForm } from '@/components/releases/release-form';
|
||||||
|
import { getReleaseById } from '@/lib/actions/releases';
|
||||||
|
|
||||||
|
interface EditReleasePageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditReleasePage({ params }: EditReleasePageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const release = await getReleaseById(parseInt(id));
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Edit Release</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Update release information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReleaseForm release={release} isEdit />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Edit, Play, Archive, Plus, CheckCircle, Circle, SkipForward, RotateCcw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases';
|
||||||
|
import { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps';
|
||||||
|
import { listCustomers } from '@/lib/actions/customers';
|
||||||
|
|
||||||
|
interface ReleaseDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
onboarding: 'bg-purple-100 text-purple-800',
|
||||||
|
release: 'bg-blue-100 text-blue-800',
|
||||||
|
hotfix: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'bg-slate-100 text-slate-800',
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
archived: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTypeColor(type: string) {
|
||||||
|
return typeColors[type] || 'bg-slate-100 text-slate-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
return statusColors[status] || 'bg-slate-100 text-slate-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReleaseDetailPage({ params }: ReleaseDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const releaseId = parseInt(id);
|
||||||
|
const release = await getReleaseById(releaseId);
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get steps grouped by cluster
|
||||||
|
const stepsByCluster = release.status === 'active'
|
||||||
|
? await getReleaseStepsGroupedByCluster(releaseId)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const stats = release.status === 'active'
|
||||||
|
? await getStepStats(releaseId)
|
||||||
|
: { total: 0, done: 0, skipped: 0, pending: 0, reverted: 0, percentage: 0 };
|
||||||
|
|
||||||
|
const customers = await listCustomers();
|
||||||
|
|
||||||
|
async function handleActivate() {
|
||||||
|
'use server';
|
||||||
|
await activateRelease(releaseId);
|
||||||
|
redirect(`/releases/${releaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
'use server';
|
||||||
|
await archiveRelease(releaseId);
|
||||||
|
redirect(`/releases/${releaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/releases">
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{release.name}</h1>
|
||||||
|
<Badge className={getTypeColor(release.type || '')}>{release.type}</Badge>
|
||||||
|
<Badge className={getStatusColor(release.status || '')}>{release.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{release.versionNumber && (
|
||||||
|
<p className="text-slate-600 mt-1">Version: {release.versionNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={`/releases/${releaseId}/edit`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{release.status === 'draft' && (
|
||||||
|
<form action={handleActivate}>
|
||||||
|
<Button size="sm" type="submit">
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{release.status === 'active' && (
|
||||||
|
<form action={handleArchive}>
|
||||||
|
<Button variant="outline" size="sm" type="submit">
|
||||||
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release Info */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Release Date</label>
|
||||||
|
<p className="text-slate-900">
|
||||||
|
{release.releaseDate
|
||||||
|
? new Date(release.releaseDate).toLocaleDateString()
|
||||||
|
: 'Not set'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Total Customers</label>
|
||||||
|
<p className="text-slate-900">{customers.length}</p>
|
||||||
|
</div>
|
||||||
|
{release.status === 'active' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Progress</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${stats.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{stats.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{release.description && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-500">Description</label>
|
||||||
|
<p className="text-slate-900 mt-1 whitespace-pre-wrap">{release.description}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Steps Management (for draft) */}
|
||||||
|
{release.status === 'draft' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template Steps</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-3">Deploy Steps ({release.templates.filter(t => t.category === 'deploy').length})</h3>
|
||||||
|
<Link href={`/releases/${releaseId}/steps`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Manage Deploy Steps
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-3">Verify Steps ({release.templates.filter(t => t.category === 'verify').length})</h3>
|
||||||
|
<Link href={`/releases/${releaseId}/steps`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Manage Verify Steps
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Matrix View (for active) */}
|
||||||
|
{release.status === 'active' && (
|
||||||
|
<Tabs defaultValue="deploy">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="deploy">
|
||||||
|
Deploy ({stats.done + stats.skipped}/{stats.total})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="verify">
|
||||||
|
Verify
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="deploy" className="mt-4">
|
||||||
|
<MatrixView
|
||||||
|
stepsByCluster={stepsByCluster}
|
||||||
|
category="deploy"
|
||||||
|
releaseId={releaseId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="verify" className="mt-4">
|
||||||
|
<MatrixView
|
||||||
|
stepsByCluster={stepsByCluster}
|
||||||
|
category="verify"
|
||||||
|
releaseId={releaseId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixViewProps {
|
||||||
|
stepsByCluster: any;
|
||||||
|
category: 'deploy' | 'verify';
|
||||||
|
releaseId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatrixView({ stepsByCluster, category, releaseId }: MatrixViewProps) {
|
||||||
|
const clusters = Object.values(stepsByCluster);
|
||||||
|
|
||||||
|
if (clusters.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-slate-500">
|
||||||
|
No customers found. Add customers to see the matrix view.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{clusters.map((clusterData: any) => {
|
||||||
|
const customers = Object.values(clusterData.customers);
|
||||||
|
|
||||||
|
// Get all unique steps for this category
|
||||||
|
const allSteps = new Map();
|
||||||
|
customers.forEach((customer: any) => {
|
||||||
|
customer.steps
|
||||||
|
.filter((s: any) => s.category === category)
|
||||||
|
.forEach((step: any) => {
|
||||||
|
if (!allSteps.has(step.name)) {
|
||||||
|
allSteps.set(step.name, step);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const steps = Array.from(allSteps.values()).sort((a: any, b: any) => a.orderIndex - b.orderIndex);
|
||||||
|
|
||||||
|
if (steps.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={clusterData.cluster?.id || 'unknown'}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 bg-blue-500 rounded-full"></span>
|
||||||
|
{clusterData.cluster?.name || 'Unknown Cluster'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-500 w-48">Step</th>
|
||||||
|
{customers.map((customer: any) => (
|
||||||
|
<th key={customer.customer.id} className="text-center py-2 px-3 font-medium text-slate-500 min-w-[120px]">
|
||||||
|
<div>{customer.customer.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 font-normal">{customer.customer.namespace}</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{steps.map((step: any, stepIndex: number) => (
|
||||||
|
<tr key={step.id} className="border-b hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-400">{stepIndex + 1}.</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{step.name}</p>
|
||||||
|
{step.isOverridden && (
|
||||||
|
<Badge variant="outline" className="text-xs">custom</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{customers.map((customer: any) => {
|
||||||
|
const customerStep = customer.steps.find(
|
||||||
|
(s: any) => s.name === step.name && s.category === category
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!customerStep) return <td key={customer.customer.id} className="py-2 px-3"></td>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={customer.customer.id} className="py-2 px-3 text-center">
|
||||||
|
<StepStatusCell step={customerStep} releaseId={releaseId} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepStatusCell({ step, releaseId }: { step: any; releaseId: number }) {
|
||||||
|
const statusIcons = {
|
||||||
|
pending: <Circle className="w-5 h-5 text-slate-300" />,
|
||||||
|
done: <CheckCircle className="w-5 h-5 text-green-500" />,
|
||||||
|
skipped: <SkipForward className="w-5 h-5 text-amber-500" />,
|
||||||
|
reverted: <RotateCcw className="w-5 h-5 text-red-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function markDone() {
|
||||||
|
'use server';
|
||||||
|
const { markStepDone } = await import('@/lib/actions/customer-steps');
|
||||||
|
await markStepDone(step.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipStep(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const { skipStep } = await import('@/lib/actions/customer-steps');
|
||||||
|
await skipStep(step.id, formData.get('reason') as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<form action={markDone}>
|
||||||
|
<button type="submit" className="hover:scale-110 transition-transform">
|
||||||
|
{statusIcons[step.status as keyof typeof statusIcons]}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{step.status === 'pending' && (
|
||||||
|
<form action={skipStep} className="flex gap-1">
|
||||||
|
<input name="reason" type="hidden" value="Skipped by user" />
|
||||||
|
<button type="submit" className="text-xs text-slate-400 hover:text-amber-600">
|
||||||
|
skip
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Plus, GripVertical, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: 'deploy' | 'verify';
|
||||||
|
type: 'bash' | 'sql' | 'text';
|
||||||
|
content: string;
|
||||||
|
orderIndex: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepsPage({ params }: PageProps) {
|
||||||
|
const [releaseId, setReleaseId] = useState<string>('');
|
||||||
|
const [release, setRelease] = useState<any>(null);
|
||||||
|
const [deploySteps, setDeploySteps] = useState<Step[]>([]);
|
||||||
|
const [verifySteps, setVerifySteps] = useState<Step[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(({ id }) => {
|
||||||
|
setReleaseId(id);
|
||||||
|
loadData(id);
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
async function loadData(id: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/releases/${id}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load');
|
||||||
|
const data = await response.json();
|
||||||
|
setRelease(data);
|
||||||
|
setDeploySteps(data.templates.filter((s: Step) => s.category === 'deploy').sort((a: Step, b: Step) => a.orderIndex - b.orderIndex));
|
||||||
|
setVerifySteps(data.templates.filter((s: Step) => s.category === 'verify').sort((a: Step, b: Step) => a.orderIndex - b.orderIndex));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
if (!release) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href={`/releases/${releaseId}`}>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Manage Steps</h1>
|
||||||
|
<p className="text-slate-600">{release.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="deploy" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="deploy">
|
||||||
|
Deploy Steps ({deploySteps.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="verify">
|
||||||
|
Verify Steps ({verifySteps.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="deploy" className="mt-6">
|
||||||
|
<StepList
|
||||||
|
steps={deploySteps}
|
||||||
|
category="deploy"
|
||||||
|
releaseId={parseInt(releaseId)}
|
||||||
|
onUpdate={() => loadData(releaseId)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="verify" className="mt-6">
|
||||||
|
<StepList
|
||||||
|
steps={verifySteps}
|
||||||
|
category="verify"
|
||||||
|
releaseId={parseInt(releaseId)}
|
||||||
|
onUpdate={() => loadData(releaseId)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepListProps {
|
||||||
|
steps: Step[];
|
||||||
|
category: 'deploy' | 'verify';
|
||||||
|
releaseId: number;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
async function handleAddStep(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/steps', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
releaseId,
|
||||||
|
category,
|
||||||
|
name: formData.get('name'),
|
||||||
|
type: formData.get('type'),
|
||||||
|
content: formData.get('content'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteStep(stepId: number) {
|
||||||
|
if (!confirm('Are you sure you want to delete this step?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/steps/${stepId}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="capitalize">{category} Steps</CardTitle>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Step
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add {category} Step</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleAddStep} className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Name</label>
|
||||||
|
<input name="name" className="w-full p-2 border rounded" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Type</label>
|
||||||
|
<select name="type" className="w-full p-2 border rounded">
|
||||||
|
<option value="bash">Bash Script</option>
|
||||||
|
<option value="sql">SQL</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Content</label>
|
||||||
|
<textarea name="content" className="w-full p-2 border rounded font-mono" rows={6} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Description</label>
|
||||||
|
<input name="description" className="w-full p-2 border rounded" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Add Step</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-center py-8">No {category} steps defined yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg border"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-500 w-6">{index + 1}.</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{step.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{step.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{step.description && (
|
||||||
|
<p className="text-sm text-slate-500">{step.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-600"
|
||||||
|
onClick={() => handleDeleteStep(step.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ReleaseForm } from '@/components/releases/release-form';
|
||||||
|
|
||||||
|
export default function NewReleasePage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Create Release</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Create a new release to manage deployment steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReleaseForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { ReleaseCard } from '@/components/releases/release-card';
|
||||||
|
import { listReleases } from '@/lib/actions/releases';
|
||||||
|
|
||||||
|
export default async function ReleasesPage() {
|
||||||
|
const releases = await listReleases();
|
||||||
|
|
||||||
|
const drafts = releases.filter(r => r.status === 'draft');
|
||||||
|
const active = releases.filter(r => r.status === 'active');
|
||||||
|
const archived = releases.filter(r => r.status === 'archived');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Releases</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Manage your deployment releases
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/releases/new">
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Release
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="active" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="active">
|
||||||
|
Active ({active.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="draft">
|
||||||
|
Drafts ({drafts.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="archived">
|
||||||
|
Archived ({archived.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="active" className="mt-6">
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
|
||||||
|
<p className="text-slate-600">No active releases.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{active.map((release) => (
|
||||||
|
<ReleaseCard key={release.id} release={release} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="draft" className="mt-6">
|
||||||
|
{drafts.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
|
||||||
|
<p className="text-slate-600">No draft releases.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{drafts.map((release) => (
|
||||||
|
<ReleaseCard key={release.id} release={release} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="archived" className="mt-6">
|
||||||
|
{archived.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
|
||||||
|
<p className="text-slate-600">No archived releases.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{archived.map((release) => (
|
||||||
|
<ReleaseCard key={release.id} release={release} showActions={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Server, Edit, Trash2, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { deleteCluster } from '@/lib/actions/clusters';
|
||||||
|
import type { Cluster } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface ClusterCardProps {
|
||||||
|
cluster: Cluster & { customerCount?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterCard({ cluster }: ClusterCardProps) {
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteCluster(cluster.id);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to delete cluster');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Server className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{cluster.name}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1 mt-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{cluster.customerCount || 0} customers
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Link href={`/clusters/${cluster.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Cluster</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the cluster "{cluster.name}"?
|
||||||
|
This action cannot be undone. You can only delete clusters with no active customers.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{cluster.kubeconfigPath && (
|
||||||
|
<div className="text-sm text-slate-600 mb-2">
|
||||||
|
<span className="font-medium">Kubeconfig:</span> {cluster.kubeconfigPath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cluster.description && (
|
||||||
|
<p className="text-sm text-slate-600 line-clamp-2">{cluster.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Link href={`/clusters/${cluster.id}`}>
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { createCluster, updateCluster } from '@/lib/actions/clusters';
|
||||||
|
import type { Cluster } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface ClusterFormProps {
|
||||||
|
cluster?: Cluster;
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterForm({ cluster, isEdit = false }: ClusterFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name: formData.get('name') as string,
|
||||||
|
kubeconfigPath: formData.get('kubeconfigPath') as string || undefined,
|
||||||
|
description: formData.get('description') as string || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && cluster) {
|
||||||
|
await updateCluster(cluster.id, data);
|
||||||
|
} else {
|
||||||
|
await createCluster(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/clusters');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isEdit ? 'Edit Cluster' : 'Create New Cluster'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Cluster Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={cluster?.name}
|
||||||
|
placeholder="e.g., production-us"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="kubeconfigPath">Kubeconfig Path</Label>
|
||||||
|
<Input
|
||||||
|
id="kubeconfigPath"
|
||||||
|
name="kubeconfigPath"
|
||||||
|
defaultValue={cluster?.kubeconfigPath || ''}
|
||||||
|
placeholder="~/.kube/config"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Path to kubeconfig file (optional, for future auto-execution)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={cluster?.description || ''}
|
||||||
|
placeholder="Description of this cluster..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Saving...' : isEdit ? 'Update Cluster' : 'Create Cluster'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/clusters')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Users, Edit, Trash2, Server } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { deleteCustomer } from '@/lib/actions/customers';
|
||||||
|
import type { Customer } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface CustomerCardProps {
|
||||||
|
customer: Customer & { cluster?: { name: string } | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerCard({ customer }: CustomerCardProps) {
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteCustomer(customer.id);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to delete customer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{customer.name}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1 mt-1">
|
||||||
|
<Server className="w-3 h-3" />
|
||||||
|
{customer.cluster?.name || 'Unknown Cluster'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Link href={`/customers/${customer.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Customer</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the customer "{customer.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-slate-600 mb-2">
|
||||||
|
<span className="font-medium">Namespace:</span> {customer.namespace}
|
||||||
|
</div>
|
||||||
|
{customer.description && (
|
||||||
|
<p className="text-sm text-slate-600 line-clamp-2">{customer.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Link href={`/customers/${customer.id}`}>
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { createCustomer, updateCustomer } from '@/lib/actions/customers';
|
||||||
|
import type { Customer, Cluster } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface CustomerFormProps {
|
||||||
|
customer?: Customer;
|
||||||
|
clusters: Cluster[];
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerForm({ customer, clusters, isEdit = false }: CustomerFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
clusterId: parseInt(formData.get('clusterId') as string),
|
||||||
|
namespace: formData.get('namespace') as string,
|
||||||
|
name: formData.get('name') as string,
|
||||||
|
description: formData.get('description') as string || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && customer) {
|
||||||
|
await updateCustomer(customer.id, data);
|
||||||
|
} else {
|
||||||
|
await createCustomer(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/customers');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isEdit ? 'Edit Customer' : 'Create New Customer'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clusterId">Cluster *</Label>
|
||||||
|
<Select
|
||||||
|
name="clusterId"
|
||||||
|
defaultValue={customer?.clusterId?.toString()}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a cluster" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clusters.map((cluster) => (
|
||||||
|
<SelectItem key={cluster.id} value={cluster.id.toString()}>
|
||||||
|
{cluster.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Customer Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={customer?.name}
|
||||||
|
placeholder="e.g., Acme Corporation"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="namespace">Namespace *</Label>
|
||||||
|
<Input
|
||||||
|
id="namespace"
|
||||||
|
name="namespace"
|
||||||
|
defaultValue={customer?.namespace}
|
||||||
|
placeholder="e.g., acme-prod"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Kubernetes namespace for this customer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={customer?.description || ''}
|
||||||
|
placeholder="Description of this customer..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Saving...' : isEdit ? 'Update Customer' : 'Create Customer'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/customers')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
Package,
|
||||||
|
ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/clusters', label: 'Clusters', icon: Server },
|
||||||
|
{ href: '/customers', label: 'Customers', icon: Users },
|
||||||
|
{ href: '/releases', label: 'Releases', icon: Package },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col h-screen sticky top-0">
|
||||||
|
<div className="p-6 border-b border-slate-800">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-xl">ReleaseTracker</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
{isActive && <ChevronRight className="w-4 h-4 ml-auto" />}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-800 text-xs text-slate-500">
|
||||||
|
<p>Release Orchestration System</p>
|
||||||
|
<p>v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Package, Edit, Trash2, Play, Archive, Copy } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { activateRelease, archiveRelease, deleteRelease, cloneRelease } from '@/lib/actions/releases';
|
||||||
|
import type { Release } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface ReleaseCardProps {
|
||||||
|
release: Release;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
onboarding: 'bg-purple-100 text-purple-800',
|
||||||
|
release: 'bg-blue-100 text-blue-800',
|
||||||
|
hotfix: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'bg-slate-100 text-slate-800',
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
archived: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTypeColor(type: string) {
|
||||||
|
return typeColors[type] || 'bg-slate-100 text-slate-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
return statusColors[status] || 'bg-slate-100 text-slate-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleaseCard({ release, showActions = true }: ReleaseCardProps) {
|
||||||
|
async function handleActivate() {
|
||||||
|
try {
|
||||||
|
await activateRelease(release.id);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to activate release');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
try {
|
||||||
|
await archiveRelease(release.id);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to archive release');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteRelease(release.id);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to delete release');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClone() {
|
||||||
|
try {
|
||||||
|
const newName = `${release.name} (Copy)`;
|
||||||
|
await cloneRelease(release.id, newName);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to clone release');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{release.name}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge className={getTypeColor(release.type || '')} variant="secondary">
|
||||||
|
{release.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={getStatusColor(release.status || '')} variant="secondary">
|
||||||
|
{release.status}
|
||||||
|
</Badge>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{release.status === 'draft' && (
|
||||||
|
<DropdownMenuItem onClick={handleActivate}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={handleClone}>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Clone
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{release.status === 'active' && (
|
||||||
|
<DropdownMenuItem onClick={handleArchive}>
|
||||||
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => {}}>
|
||||||
|
<Link href={`/releases/${release.id}/edit`} className="flex items-center">
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{release.status === 'draft' && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Release</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the release "{release.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{release.versionNumber && (
|
||||||
|
<div className="text-sm text-slate-600 mb-2">
|
||||||
|
<span className="font-medium">Version:</span> {release.versionNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{release.releaseDate && (
|
||||||
|
<div className="text-sm text-slate-600 mb-2">
|
||||||
|
<span className="font-medium">Release Date:</span>{' '}
|
||||||
|
{new Date(release.releaseDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{release.description && (
|
||||||
|
<p className="text-sm text-slate-600 line-clamp-2">{release.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Link href={`/releases/${release.id}`}>
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { createRelease, updateRelease } from '@/lib/actions/releases';
|
||||||
|
import type { Release, ReleaseType } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface ReleaseFormProps {
|
||||||
|
release?: Release;
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseTypes: { value: ReleaseType; label: string }[] = [
|
||||||
|
{ value: 'onboarding', label: 'Onboarding' },
|
||||||
|
{ value: 'release', label: 'Regular Release' },
|
||||||
|
{ value: 'hotfix', label: 'Hotfix' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReleaseForm({ release, isEdit = false }: ReleaseFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<ReleaseType>(release?.type || 'release');
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name: formData.get('name') as string,
|
||||||
|
type: formData.get('type') as ReleaseType,
|
||||||
|
versionNumber: formData.get('versionNumber') as string || undefined,
|
||||||
|
releaseDate: formData.get('releaseDate')
|
||||||
|
? new Date(formData.get('releaseDate') as string)
|
||||||
|
: undefined,
|
||||||
|
description: formData.get('description') as string || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && release) {
|
||||||
|
await updateRelease(release.id, data);
|
||||||
|
} else {
|
||||||
|
await createRelease(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/releases');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isEdit ? 'Edit Release' : 'Create New Release'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Release Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={release?.name}
|
||||||
|
placeholder="e.g., Q1 2024 Major Release"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="type">Release Type *</Label>
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
defaultValue={release?.type || 'release'}
|
||||||
|
onValueChange={(value) => setSelectedType(value as ReleaseType)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{releaseTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedType === 'release' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="versionNumber">Version Number</Label>
|
||||||
|
<Input
|
||||||
|
id="versionNumber"
|
||||||
|
name="versionNumber"
|
||||||
|
defaultValue={release?.versionNumber || ''}
|
||||||
|
placeholder="e.g., v2.5.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="releaseDate">Release Date</Label>
|
||||||
|
<Input
|
||||||
|
id="releaseDate"
|
||||||
|
name="releaseDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={release?.releaseDate
|
||||||
|
? new Date(release.releaseDate).toISOString().split('T')[0]
|
||||||
|
: ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description / Release Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={release?.description || ''}
|
||||||
|
placeholder="Describe this release, changes, notes..."
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Saving...' : isEdit ? 'Update Release' : 'Create Release'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/releases')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||||
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clusters, customers } from '@/lib/db/schema';
|
||||||
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export type ClusterInput = {
|
||||||
|
name: string;
|
||||||
|
kubeconfigPath?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createCluster(data: ClusterInput) {
|
||||||
|
const [cluster] = await db.insert(clusters).values({
|
||||||
|
...data,
|
||||||
|
isActive: true,
|
||||||
|
}).returning();
|
||||||
|
revalidatePath('/clusters');
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCluster(id: number, data: Partial<ClusterInput>) {
|
||||||
|
const [cluster] = await db
|
||||||
|
.update(clusters)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(clusters.id, id))
|
||||||
|
.returning();
|
||||||
|
revalidatePath('/clusters');
|
||||||
|
revalidatePath(`/clusters/${id}`);
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCluster(id: number) {
|
||||||
|
// Check if cluster has active customers
|
||||||
|
const result = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(customers)
|
||||||
|
.where(and(
|
||||||
|
eq(customers.clusterId, id),
|
||||||
|
eq(customers.isActive, true)
|
||||||
|
));
|
||||||
|
|
||||||
|
const customerCount = result[0]?.count || 0;
|
||||||
|
|
||||||
|
if (customerCount > 0) {
|
||||||
|
throw new Error(`Cannot delete cluster: ${customerCount} active customer(s) exist. Please move or delete customers first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(clusters)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(clusters.id, id));
|
||||||
|
revalidatePath('/clusters');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listClusters() {
|
||||||
|
return db.query.clusters.findMany({
|
||||||
|
where: eq(clusters.isActive, true),
|
||||||
|
orderBy: clusters.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClusterById(id: number) {
|
||||||
|
return db.query.clusters.findFirst({
|
||||||
|
where: eq(clusters.id, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClusterWithCustomers(id: number) {
|
||||||
|
return db.query.clusters.findFirst({
|
||||||
|
where: eq(clusters.id, id),
|
||||||
|
with: {
|
||||||
|
customers: {
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
orderBy: customers.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
customerSteps,
|
||||||
|
customers,
|
||||||
|
clusters,
|
||||||
|
type StepCategory,
|
||||||
|
type StepType
|
||||||
|
} from '@/lib/db/schema';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export type CustomStepInput = {
|
||||||
|
name: string;
|
||||||
|
category: StepCategory;
|
||||||
|
type: StepType;
|
||||||
|
content: string;
|
||||||
|
orderIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCustomerSteps(releaseId: number, customerId: number) {
|
||||||
|
return db.query.customerSteps.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customerSteps.releaseId, releaseId),
|
||||||
|
eq(customerSteps.customerId, customerId)
|
||||||
|
),
|
||||||
|
orderBy: [customerSteps.category, asc(customerSteps.orderIndex)],
|
||||||
|
with: { template: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function overrideStepContent(stepId: number, newContent: string) {
|
||||||
|
const [step] = await db
|
||||||
|
.update(customerSteps)
|
||||||
|
.set({
|
||||||
|
content: newContent,
|
||||||
|
isOverridden: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(customerSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCustomStep(
|
||||||
|
releaseId: number,
|
||||||
|
customerId: number,
|
||||||
|
data: CustomStepInput
|
||||||
|
) {
|
||||||
|
const [step] = await db.insert(customerSteps).values({
|
||||||
|
...data,
|
||||||
|
releaseId,
|
||||||
|
customerId,
|
||||||
|
templateId: null,
|
||||||
|
status: 'pending',
|
||||||
|
isCustom: true,
|
||||||
|
isOverridden: false,
|
||||||
|
}).returning();
|
||||||
|
revalidatePath(`/releases/${releaseId}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markStepDone(stepId: number, notes?: string) {
|
||||||
|
const [step] = await db
|
||||||
|
.update(customerSteps)
|
||||||
|
.set({
|
||||||
|
status: 'done',
|
||||||
|
executedAt: new Date(),
|
||||||
|
notes: notes || null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(customerSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markStepReverted(stepId: number, reason?: string) {
|
||||||
|
const [step] = await db
|
||||||
|
.update(customerSteps)
|
||||||
|
.set({
|
||||||
|
status: 'reverted',
|
||||||
|
notes: reason || null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(customerSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipStep(stepId: number, reason: string) {
|
||||||
|
const [step] = await db
|
||||||
|
.update(customerSteps)
|
||||||
|
.set({
|
||||||
|
status: 'skipped',
|
||||||
|
skipReason: reason,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(customerSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkMarkDone(stepIds: number[]) {
|
||||||
|
for (const id of stepIds) {
|
||||||
|
await markStepDone(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetToTemplate(stepId: number) {
|
||||||
|
const step = await db.query.customerSteps.findFirst({
|
||||||
|
where: eq(customerSteps.id, stepId),
|
||||||
|
with: { template: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!step?.template) throw new Error('No template found');
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(customerSteps)
|
||||||
|
.set({
|
||||||
|
content: step.template.content,
|
||||||
|
name: step.template.name,
|
||||||
|
isOverridden: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(customerSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomStep(stepId: number) {
|
||||||
|
const step = await db.query.customerSteps.findFirst({
|
||||||
|
where: eq(customerSteps.id, stepId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!step) return;
|
||||||
|
if (!step.isCustom) throw new Error('Cannot delete non-custom steps');
|
||||||
|
|
||||||
|
await db.delete(customerSteps).where(eq(customerSteps.id, stepId));
|
||||||
|
revalidatePath(`/releases/${step.releaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReleaseStepsGroupedByCluster(releaseId: number) {
|
||||||
|
const steps = await db.query.customerSteps.findMany({
|
||||||
|
where: eq(customerSteps.releaseId, releaseId),
|
||||||
|
with: {
|
||||||
|
customer: {
|
||||||
|
with: { cluster: true },
|
||||||
|
},
|
||||||
|
template: true,
|
||||||
|
},
|
||||||
|
orderBy: [customerSteps.category, asc(customerSteps.orderIndex)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by cluster
|
||||||
|
const grouped = steps.reduce((acc, step) => {
|
||||||
|
const clusterId = step.customer.cluster?.id || 0;
|
||||||
|
const clusterName = step.customer.cluster?.name || 'Unknown';
|
||||||
|
|
||||||
|
if (!acc[clusterId]) {
|
||||||
|
acc[clusterId] = {
|
||||||
|
cluster: step.customer.cluster,
|
||||||
|
customers: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = step.customer.id;
|
||||||
|
if (!acc[clusterId].customers[customerId]) {
|
||||||
|
acc[clusterId].customers[customerId] = {
|
||||||
|
customer: step.customer,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[clusterId].customers[customerId].steps.push(step);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, {
|
||||||
|
cluster: typeof steps[0]['customer']['cluster'];
|
||||||
|
customers: Record<number, {
|
||||||
|
customer: typeof steps[0]['customer'];
|
||||||
|
steps: typeof steps;
|
||||||
|
}>;
|
||||||
|
}>);
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReleaseStepsByCustomer(releaseId: number) {
|
||||||
|
const steps = await db.query.customerSteps.findMany({
|
||||||
|
where: eq(customerSteps.releaseId, releaseId),
|
||||||
|
with: {
|
||||||
|
customer: {
|
||||||
|
with: { cluster: true },
|
||||||
|
},
|
||||||
|
template: true,
|
||||||
|
},
|
||||||
|
orderBy: [customerSteps.category, asc(customerSteps.orderIndex)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = steps.reduce((acc, step) => {
|
||||||
|
const customerId = step.customer.id;
|
||||||
|
if (!acc[customerId]) {
|
||||||
|
acc[customerId] = {
|
||||||
|
customer: step.customer,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[customerId].steps.push(step);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, {
|
||||||
|
customer: typeof steps[0]['customer'];
|
||||||
|
steps: typeof steps;
|
||||||
|
}>);
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStepStats(releaseId: number) {
|
||||||
|
const allSteps = await db.query.customerSteps.findMany({
|
||||||
|
where: eq(customerSteps.releaseId, releaseId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = allSteps.length;
|
||||||
|
const done = allSteps.filter(s => s.status === 'done').length;
|
||||||
|
const skipped = allSteps.filter(s => s.status === 'skipped').length;
|
||||||
|
const pending = allSteps.filter(s => s.status === 'pending').length;
|
||||||
|
const reverted = allSteps.filter(s => s.status === 'reverted').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
done,
|
||||||
|
skipped,
|
||||||
|
pending,
|
||||||
|
reverted,
|
||||||
|
percentage: total > 0 ? Math.round(((done + skipped) / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { customers, clusters } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export type CustomerInput = {
|
||||||
|
clusterId: number;
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createCustomer(data: CustomerInput) {
|
||||||
|
const [customer] = await db.insert(customers).values({
|
||||||
|
...data,
|
||||||
|
isActive: true,
|
||||||
|
}).returning();
|
||||||
|
revalidatePath('/customers');
|
||||||
|
revalidatePath(`/clusters/${data.clusterId}`);
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomer(id: number, data: Partial<CustomerInput>) {
|
||||||
|
const [customer] = await db
|
||||||
|
.update(customers)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(customers.id, id))
|
||||||
|
.returning();
|
||||||
|
revalidatePath('/customers');
|
||||||
|
revalidatePath(`/customers/${id}`);
|
||||||
|
if (data.clusterId) {
|
||||||
|
revalidatePath(`/clusters/${data.clusterId}`);
|
||||||
|
}
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomer(id: number) {
|
||||||
|
await db.update(customers)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(customers.id, id));
|
||||||
|
revalidatePath('/customers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomers() {
|
||||||
|
return db.query.customers.findMany({
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
with: { cluster: true },
|
||||||
|
orderBy: customers.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomersByCluster(clusterId: number) {
|
||||||
|
return db.query.customers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customers.clusterId, clusterId),
|
||||||
|
eq(customers.isActive, true)
|
||||||
|
),
|
||||||
|
orderBy: customers.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomerById(id: number) {
|
||||||
|
return db.query.customers.findFirst({
|
||||||
|
where: eq(customers.id, id),
|
||||||
|
with: { cluster: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomersGroupedByCluster() {
|
||||||
|
const allCustomers = await db.query.customers.findMany({
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
with: { cluster: true },
|
||||||
|
orderBy: customers.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = allCustomers.reduce((acc, customer) => {
|
||||||
|
const clusterName = customer.cluster?.name || 'Unknown';
|
||||||
|
const clusterId = customer.cluster?.id || 0;
|
||||||
|
|
||||||
|
if (!acc[clusterId]) {
|
||||||
|
acc[clusterId] = {
|
||||||
|
cluster: customer.cluster,
|
||||||
|
customers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[clusterId].customers.push(customer);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, { cluster: typeof allCustomers[0]['cluster']; customers: typeof allCustomers }>);
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
releases,
|
||||||
|
stepTemplates,
|
||||||
|
customerSteps,
|
||||||
|
customers,
|
||||||
|
type ReleaseType,
|
||||||
|
type ReleaseStatus,
|
||||||
|
} from '@/lib/db/schema';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export type ReleaseInput = {
|
||||||
|
name: string;
|
||||||
|
type: ReleaseType;
|
||||||
|
versionNumber?: string;
|
||||||
|
releaseDate?: Date;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createRelease(data: ReleaseInput) {
|
||||||
|
const [release] = await db.insert(releases).values({
|
||||||
|
...data,
|
||||||
|
releaseDate: data.releaseDate || null,
|
||||||
|
status: 'draft',
|
||||||
|
}).returning();
|
||||||
|
revalidatePath('/releases');
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRelease(id: number, data: Partial<ReleaseInput>) {
|
||||||
|
const [release] = await db
|
||||||
|
.update(releases)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(releases.id, id))
|
||||||
|
.returning();
|
||||||
|
revalidatePath('/releases');
|
||||||
|
revalidatePath(`/releases/${id}`);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateRelease(id: number) {
|
||||||
|
const release = await db.query.releases.findFirst({
|
||||||
|
where: eq(releases.id, id),
|
||||||
|
with: { templates: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!release) throw new Error('Release not found');
|
||||||
|
if (release.status !== 'draft') throw new Error('Release is not in draft status');
|
||||||
|
|
||||||
|
const activeCustomers = await db.query.customers.findMany({
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create customer steps from templates
|
||||||
|
const customerStepsToInsert = activeCustomers.flatMap(customer =>
|
||||||
|
release.templates.map(template => ({
|
||||||
|
releaseId: id,
|
||||||
|
customerId: customer.id,
|
||||||
|
templateId: template.id,
|
||||||
|
name: template.name,
|
||||||
|
category: template.category,
|
||||||
|
type: template.type,
|
||||||
|
content: template.content,
|
||||||
|
orderIndex: template.orderIndex,
|
||||||
|
status: 'pending' as const,
|
||||||
|
isCustom: false,
|
||||||
|
isOverridden: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customerStepsToInsert.length > 0) {
|
||||||
|
await db.insert(customerSteps).values(customerStepsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(releases)
|
||||||
|
.set({ status: 'active', updatedAt: new Date() })
|
||||||
|
.where(eq(releases.id, id));
|
||||||
|
|
||||||
|
revalidatePath('/releases');
|
||||||
|
revalidatePath(`/releases/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveRelease(id: number) {
|
||||||
|
await db.update(releases)
|
||||||
|
.set({ status: 'archived', updatedAt: new Date() })
|
||||||
|
.where(eq(releases.id, id));
|
||||||
|
revalidatePath('/releases');
|
||||||
|
revalidatePath(`/releases/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRelease(id: number) {
|
||||||
|
await db.delete(releases).where(eq(releases.id, id));
|
||||||
|
revalidatePath('/releases');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReleases() {
|
||||||
|
return db.query.releases.findMany({
|
||||||
|
orderBy: desc(releases.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReleaseById(id: number) {
|
||||||
|
return db.query.releases.findFirst({
|
||||||
|
where: eq(releases.id, id),
|
||||||
|
with: {
|
||||||
|
templates: {
|
||||||
|
orderBy: [stepTemplates.category, stepTemplates.orderIndex],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveReleases() {
|
||||||
|
return db.query.releases.findMany({
|
||||||
|
where: eq(releases.status, 'active'),
|
||||||
|
orderBy: desc(releases.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneRelease(id: number, newName: string) {
|
||||||
|
const original = await db.query.releases.findFirst({
|
||||||
|
where: eq(releases.id, id),
|
||||||
|
with: { templates: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!original) throw new Error('Release not found');
|
||||||
|
|
||||||
|
// Create new release
|
||||||
|
const [newRelease] = await db.insert(releases).values({
|
||||||
|
name: newName,
|
||||||
|
type: original.type,
|
||||||
|
status: 'draft',
|
||||||
|
versionNumber: original.versionNumber,
|
||||||
|
description: `Cloned from: ${original.name}\n\n${original.description || ''}`,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Clone templates
|
||||||
|
if (original.templates.length > 0) {
|
||||||
|
await db.insert(stepTemplates).values(
|
||||||
|
original.templates.map(t => ({
|
||||||
|
releaseId: newRelease.id,
|
||||||
|
name: t.name,
|
||||||
|
category: t.category,
|
||||||
|
type: t.type,
|
||||||
|
content: t.content,
|
||||||
|
orderIndex: t.orderIndex,
|
||||||
|
description: t.description,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/releases');
|
||||||
|
return newRelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReleaseStats() {
|
||||||
|
const allReleases = await db.query.releases.findMany();
|
||||||
|
const activeReleases = allReleases.filter(r => r.status === 'active');
|
||||||
|
|
||||||
|
// Count total customer steps that are pending
|
||||||
|
const pendingSteps = await db
|
||||||
|
.select({ count: { value: customerSteps.id } })
|
||||||
|
.from(customerSteps)
|
||||||
|
.where(eq(customerSteps.status, 'pending'));
|
||||||
|
|
||||||
|
const doneSteps = await db
|
||||||
|
.select({ count: { value: customerSteps.id } })
|
||||||
|
.from(customerSteps)
|
||||||
|
.where(eq(customerSteps.status, 'done'));
|
||||||
|
|
||||||
|
const skippedSteps = await db
|
||||||
|
.select({ count: { value: customerSteps.id } })
|
||||||
|
.from(customerSteps)
|
||||||
|
.where(eq(customerSteps.status, 'skipped'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReleases: allReleases.length,
|
||||||
|
activeReleases: activeReleases.length,
|
||||||
|
pendingSteps: pendingSteps[0]?.count?.value || 0,
|
||||||
|
doneSteps: doneSteps[0]?.count?.value || 0,
|
||||||
|
skippedSteps: skippedSteps[0]?.count?.value || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { stepTemplates, customerSteps, type StepCategory, type StepType } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export type StepTemplateInput = {
|
||||||
|
releaseId: number;
|
||||||
|
name: string;
|
||||||
|
category: StepCategory;
|
||||||
|
type: StepType;
|
||||||
|
content: string;
|
||||||
|
orderIndex: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function addStepTemplate(data: StepTemplateInput) {
|
||||||
|
const [template] = await db.insert(stepTemplates).values(data).returning();
|
||||||
|
revalidatePath(`/releases/${data.releaseId}/steps`);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStepTemplate(id: number, data: Partial<StepTemplateInput>) {
|
||||||
|
const template = await db.query.stepTemplates.findFirst({
|
||||||
|
where: eq(stepTemplates.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) throw new Error('Step template not found');
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(stepTemplates)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(stepTemplates.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update pending customer steps
|
||||||
|
if (data.content) {
|
||||||
|
await db.update(customerSteps)
|
||||||
|
.set({ content: data.content })
|
||||||
|
.where(and(
|
||||||
|
eq(customerSteps.templateId, id),
|
||||||
|
eq(customerSteps.status, 'pending'),
|
||||||
|
eq(customerSteps.isOverridden, false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
await db.update(customerSteps)
|
||||||
|
.set({ name: data.name })
|
||||||
|
.where(and(
|
||||||
|
eq(customerSteps.templateId, id),
|
||||||
|
eq(customerSteps.status, 'pending'),
|
||||||
|
eq(customerSteps.isOverridden, false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/releases/${template.releaseId}/steps`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStepTemplate(id: number) {
|
||||||
|
const template = await db.query.stepTemplates.findFirst({
|
||||||
|
where: eq(stepTemplates.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
// Delete associated customer steps that haven't been executed
|
||||||
|
await db.delete(customerSteps)
|
||||||
|
.where(and(
|
||||||
|
eq(customerSteps.templateId, id),
|
||||||
|
eq(customerSteps.status, 'pending')
|
||||||
|
));
|
||||||
|
|
||||||
|
await db.delete(stepTemplates).where(eq(stepTemplates.id, id));
|
||||||
|
revalidatePath(`/releases/${template.releaseId}/steps`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderSteps(
|
||||||
|
releaseId: number,
|
||||||
|
category: StepCategory,
|
||||||
|
orderedIds: number[]
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
await db.update(stepTemplates)
|
||||||
|
.set({ orderIndex: i })
|
||||||
|
.where(eq(stepTemplates.id, orderedIds[i]));
|
||||||
|
|
||||||
|
// Also update customer steps
|
||||||
|
await db.update(customerSteps)
|
||||||
|
.set({ orderIndex: i })
|
||||||
|
.where(and(
|
||||||
|
eq(customerSteps.templateId, orderedIds[i]),
|
||||||
|
eq(customerSteps.category, category)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
revalidatePath(`/releases/${releaseId}/steps`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStepTemplatesByRelease(releaseId: number) {
|
||||||
|
return db.query.stepTemplates.findMany({
|
||||||
|
where: eq(stepTemplates.releaseId, releaseId),
|
||||||
|
orderBy: [stepTemplates.category, stepTemplates.orderIndex],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStepTemplatesByCategory(releaseId: number, category: StepCategory) {
|
||||||
|
return db.query.stepTemplates.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(stepTemplates.releaseId, releaseId),
|
||||||
|
eq(stepTemplates.category, category)
|
||||||
|
),
|
||||||
|
orderBy: stepTemplates.orderIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNextOrderIndex(releaseId: number, category: StepCategory) {
|
||||||
|
const result = await db
|
||||||
|
.select({ maxOrder: { value: stepTemplates.orderIndex } })
|
||||||
|
.from(stepTemplates)
|
||||||
|
.where(and(
|
||||||
|
eq(stepTemplates.releaseId, releaseId),
|
||||||
|
eq(stepTemplates.category, category)
|
||||||
|
));
|
||||||
|
|
||||||
|
return (result[0]?.maxOrder?.value ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as schema from './schema';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = join(process.cwd(), 'data');
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_URL?.replace('file:', '') || join(dataDir, 'app.db');
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Initialize database with migrations
|
||||||
|
export async function initDb() {
|
||||||
|
// Create tables if they don't exist
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS clusters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
kubeconfig_path TEXT,
|
||||||
|
description TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch() * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cluster_id INTEGER NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE(cluster_id, namespace)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS releases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('onboarding', 'release', 'hotfix')),
|
||||||
|
status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'active', 'archived')),
|
||||||
|
version_number TEXT,
|
||||||
|
release_date INTEGER,
|
||||||
|
description TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch() * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS step_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
release_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK(category IN ('deploy', 'verify')),
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('bash', 'sql', 'text')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(release_id, category, order_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_steps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
release_id INTEGER NOT NULL,
|
||||||
|
customer_id INTEGER NOT NULL,
|
||||||
|
template_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK(category IN ('deploy', 'verify')),
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('bash', 'sql', 'text')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'done', 'skipped', 'reverted')),
|
||||||
|
executed_at INTEGER,
|
||||||
|
executed_by TEXT,
|
||||||
|
skip_reason TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_custom INTEGER DEFAULT 0,
|
||||||
|
is_overridden INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||||
|
FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES step_templates(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE(release_id, customer_id, template_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_cluster ON customers(cluster_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_steps_release ON customer_steps(release_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_steps_customer ON customer_steps(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_step_templates_release ON step_templates(release_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { sqliteTable, integer, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ==================== Clusters ====================
|
||||||
|
export const clusters = sqliteTable('clusters', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
kubeconfigPath: text('kubeconfig_path'),
|
||||||
|
description: text('description'),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
||||||
|
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clustersRelations = relations(clusters, ({ many }) => ({
|
||||||
|
customers: many(customers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== Customers ====================
|
||||||
|
export const customers = sqliteTable('customers', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
clusterId: integer('cluster_id').notNull().references(() => clusters.id, { onDelete: 'restrict' }),
|
||||||
|
namespace: text('namespace').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
||||||
|
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueNamespacePerCluster: uniqueIndex('unique_namespace_per_cluster')
|
||||||
|
.on(table.clusterId, table.namespace),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const customersRelations = relations(customers, ({ one, many }) => ({
|
||||||
|
cluster: one(clusters, { fields: [customers.clusterId], references: [clusters.id] }),
|
||||||
|
steps: many(customerSteps),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== Releases ====================
|
||||||
|
export const releases = sqliteTable('releases', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
type: text('type', { enum: ['onboarding', 'release', 'hotfix'] }).notNull(),
|
||||||
|
status: text('status', { enum: ['draft', 'active', 'archived'] }).default('draft'),
|
||||||
|
versionNumber: text('version_number'),
|
||||||
|
releaseDate: integer('release_date', { mode: 'timestamp' }),
|
||||||
|
description: text('description'),
|
||||||
|
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const releasesRelations = relations(releases, ({ many }) => ({
|
||||||
|
templates: many(stepTemplates),
|
||||||
|
customerSteps: many(customerSteps),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== Step Templates ====================
|
||||||
|
export const stepTemplates = sqliteTable('step_templates', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
releaseId: integer('release_id').notNull().references(() => releases.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
category: text('category', { enum: ['deploy', 'verify'] }).notNull(),
|
||||||
|
type: text('type', { enum: ['bash', 'sql', 'text'] }).notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
orderIndex: integer('order_index').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueOrderPerReleaseCategory: uniqueIndex('unique_order_per_release_category')
|
||||||
|
.on(table.releaseId, table.category, table.orderIndex),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const stepTemplatesRelations = relations(stepTemplates, ({ one, many }) => ({
|
||||||
|
release: one(releases, { fields: [stepTemplates.releaseId], references: [releases.id] }),
|
||||||
|
customerSteps: many(customerSteps),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== Customer Steps ====================
|
||||||
|
export const customerSteps = sqliteTable('customer_steps', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
releaseId: integer('release_id').notNull().references(() => releases.id, { onDelete: 'cascade' }),
|
||||||
|
customerId: integer('customer_id').notNull().references(() => customers.id, { onDelete: 'cascade' }),
|
||||||
|
templateId: integer('template_id').references(() => stepTemplates.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Copied/Overridden fields
|
||||||
|
name: text('name').notNull(),
|
||||||
|
category: text('category', { enum: ['deploy', 'verify'] }).notNull(),
|
||||||
|
type: text('type', { enum: ['bash', 'sql', 'text'] }).notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
orderIndex: integer('order_index').notNull(),
|
||||||
|
|
||||||
|
// Execution tracking
|
||||||
|
status: text('status', { enum: ['pending', 'done', 'skipped', 'reverted'] }).default('pending'),
|
||||||
|
executedAt: integer('executed_at', { mode: 'timestamp' }),
|
||||||
|
executedBy: text('executed_by'),
|
||||||
|
skipReason: text('skip_reason'),
|
||||||
|
notes: text('notes'),
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
isCustom: integer('is_custom', { mode: 'boolean' }).default(false),
|
||||||
|
isOverridden: integer('is_overridden', { mode: 'boolean' }).default(false),
|
||||||
|
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
// Ensure unique steps per customer per release
|
||||||
|
uniqueCustomerStep: uniqueIndex('unique_customer_step')
|
||||||
|
.on(table.releaseId, table.customerId, table.templateId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const customerStepsRelations = relations(customerSteps, ({ one }) => ({
|
||||||
|
release: one(releases, { fields: [customerSteps.releaseId], references: [releases.id] }),
|
||||||
|
customer: one(customers, { fields: [customerSteps.customerId], references: [customers.id] }),
|
||||||
|
template: one(stepTemplates, { fields: [customerSteps.templateId], references: [stepTemplates.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== Types ====================
|
||||||
|
export type Cluster = typeof clusters.$inferSelect;
|
||||||
|
export type NewCluster = typeof clusters.$inferInsert;
|
||||||
|
|
||||||
|
export type Customer = typeof customers.$inferSelect;
|
||||||
|
export type NewCustomer = typeof customers.$inferInsert;
|
||||||
|
|
||||||
|
export type Release = typeof releases.$inferSelect;
|
||||||
|
export type NewRelease = typeof releases.$inferInsert;
|
||||||
|
|
||||||
|
export type StepTemplate = typeof stepTemplates.$inferSelect;
|
||||||
|
export type NewStepTemplate = typeof stepTemplates.$inferInsert;
|
||||||
|
|
||||||
|
export type CustomerStep = typeof customerSteps.$inferSelect;
|
||||||
|
export type NewCustomerStep = typeof customerSteps.$inferInsert;
|
||||||
|
|
||||||
|
export type StepCategory = 'deploy' | 'verify';
|
||||||
|
export type StepType = 'bash' | 'sql' | 'text';
|
||||||
|
export type ReleaseType = 'onboarding' | 'release' | 'hotfix';
|
||||||
|
export type ReleaseStatus = 'draft' | 'active' | 'archived';
|
||||||
|
export type StepStatus = 'pending' | 'done' | 'skipped' | 'reverted';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue