Initial commit

This commit is contained in:
Tiger Ren 2026-02-01 14:29:18 +08:00
parent 56f7e8c933
commit d95f11dd79
60 changed files with 9635 additions and 108 deletions

6
.gitignore vendored
View File

@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# sqlite database
/data/*.db
/data/*.db-journal
/data/*.db-wal
/data/*.db-shm

102
README.md
View File

@ -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
First, run the development server:
### Prerequisites
- Node.js 20.x or higher
- npm or yarn
### Installation
1. Install dependencies:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
```
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
### 1. Setup Your Infrastructure
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

23
components.json Normal file
View File

@ -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
data/.gitkeep Normal file
View File

552
docs/FSD.md Normal file
View File

@ -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 |

1163
docs/TSD.md Normal file

File diff suppressed because it is too large Load Diff

10
drizzle.config.ts Normal file
View File

@ -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',
},
});

View File

@ -1,7 +1,13 @@
import type { NextConfig } from "next";
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;

2810
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,18 +9,39 @@
"lint": "eslint"
},
"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",
"prismjs": "^1.30.0",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

60
src/app/clusters/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,26 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--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 {
--background: #0a0a0a;
--foreground: #ededed;
}
--radius: 0.625rem;
--background: oklch(1 0 0);
--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);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.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;
}
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/sidebar";
import { initDb } from "@/lib/db";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,10 +15,13 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Release Tracker",
description: "Release Orchestration & Tracking System",
};
// Initialize database on startup
initDb().catch(console.error);
export default function RootLayout({
children,
}: Readonly<{
@ -25,9 +30,14 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-slate-50`}
>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-8 overflow-auto">
{children}
</main>
</div>
</body>
</html>
);

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-slate-900">Dashboard</h1>
<p className="text-slate-600 mt-1">
Overview of your release orchestration system
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
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]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
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]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-slate-500">
Active Releases
</CardTitle>
<Package className="w-4 h-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.activeReleases}</div>
<p className="text-xs text-slate-500 mt-1">
of {stats.totalReleases} total
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-slate-500">
Pending Steps
</CardTitle>
<Clock className="w-4 h-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.pendingSteps}</div>
<p className="text-xs text-slate-500 mt-1">
awaiting completion
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-slate-500">
Completed Steps
</CardTitle>
<CheckCircle className="w-4 h-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.doneSteps}</div>
<p className="text-xs text-slate-500 mt-1">
successfully done
</p>
</CardContent>
</Card>
<Card>
<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>
</main>
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

89
src/app/releases/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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 &quot;{cluster.name}&quot;?
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>
);
}

View File

@ -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>
);
}

View File

@ -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 &quot;{customer.name}&quot;?
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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 &quot;{release.name}&quot;?
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>
);
}

View File

@ -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>
);
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

116
src/components/ui/table.tsx Normal file
View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
},
},
});
}

View File

@ -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,
};
}

View File

@ -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;
}

186
src/lib/actions/releases.ts Normal file
View File

@ -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,
};
}

View File

@ -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;
}

106
src/lib/db/index.ts Normal file
View File

@ -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);
`);
}

140
src/lib/db/schema.ts Normal file
View File

@ -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';

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}