Add frontend

This commit is contained in:
pratik
2025-10-23 08:10:15 -05:00
parent 23dacdd0c2
commit c35cd7092a
28 changed files with 2243 additions and 4 deletions

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
dist
.git
.gitignore
README.md
helm

25
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Node
node_modules/
npm-debug.log
yarn-error.log
# Angular
dist/
.angular/
.ng_build/
.ng_pkg_build/
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local

33
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: Build Angular app
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build Angular app for production
RUN npm run build:prod
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf.template
# Copy built Angular app
COPY --from=builder /app/dist/cf-deployer-ui/browser /usr/share/nginx/html
# Set default backend URL
ENV BACKEND_URL=http://localhost:8080
# Replace env variables and start nginx
CMD envsubst '${BACKEND_URL}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'
EXPOSE 80

348
frontend/README.md Normal file
View File

@@ -0,0 +1,348 @@
# Tanzu Deployer UI - Angular Frontend
A modern, dark-themed Angular 19.1 frontend for deploying applications to Tanzu/Cloud Foundry environments.
## Features
-**Modern Dark Theme**: Sleek GitHub-inspired dark UI with blue/purple gradients
-**Simple Form Interface**: Easy-to-use deployment form with all Tanzu configuration fields
-**Modern File Upload**: Beautiful drag-and-drop style file upload buttons with SVG icons
-**Chunked File Upload**: Handles large JAR files using Base64-encoded chunks (compatible with Java proxy)
-**Real-time Progress**: Visual progress bar showing upload and deployment status
-**Live Logs**: Collapsible terminal-style output window displaying deployment logs
-**Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
-**Secure**: Password fields are masked, follows Angular security best practices
-**Configurable**: Environment-based configuration for all settings
-**Production Ready**: Includes nginx configuration and Helm chart for K8s deployment
## Configuration
All application settings are managed via environment files:
**`src/environments/environment.ts`** (Development):
```typescript
export const environment = {
production: false,
apiBase: '/api/cf', // API endpoint base URL
chunkSize: 1048576, // Chunk size in bytes (1MB)
enableSslValidation: false, // Show/hide SSL validation checkbox
pollInterval: 5000, // Poll interval in milliseconds
maxPollAttempts: 120, // Max polling attempts (10 minutes)
defaultLogsExpanded: true, // Logs expanded by default
showDebugInfo: false // Show debug information
};
```
**`src/environments/environment.prod.ts`** (Production):
- Same structure as development
- Used when building with `--configuration production`
### Customizing Configuration
To customize the application behavior:
1. Edit `src/environments/environment.ts` for development
2. Edit `src/environments/environment.prod.ts` for production
3. Rebuild the application
**Common Customizations:**
- **Enable SSL Validation Checkbox**: Set `enableSslValidation: true`
- **Change Chunk Size**: Set `chunkSize: 2097152` (2MB)
- **Increase Poll Time**: Set `maxPollAttempts: 240` (20 minutes)
- **Change API Endpoint**: Set `apiBase: 'https://api.example.com/cf'`
## Quick Start
### Local Development
1. **Install dependencies:**
```bash
npm install
```
2. **Start development server:**
```bash
npm start
```
3. **Open browser:**
Navigate to `http://localhost:4200`
### Build for Production
```bash
npm run build:prod
```
Built files will be in `dist/cf-deployer-ui/browser/`
## Docker Deployment
### Build Docker Image
```bash
docker build -t cf-deployer-ui:latest .
```
### Run Docker Container
```bash
docker run -d \
-p 8080:80 \
-e BACKEND_URL=http://cf-deployer-backend:8080 \
--name cf-deployer-ui \
cf-deployer-ui:latest
```
**Environment Variables:**
- `BACKEND_URL`: URL of the CF Deployer backend API (default: `http://localhost:8080`)
## Kubernetes Deployment
### Prerequisites
- Kubernetes cluster
- Helm 3.x installed
- Docker image pushed to registry
### Deploy with Helm
1. **Update values.yaml:**
```yaml
image:
repository: your-registry/cf-deployer-ui
tag: "1.0.0"
backend:
url: "http://cf-deployer-backend:8080"
ingress:
enabled: true
hosts:
- host: cf-deployer.example.com
paths:
- path: /
pathType: Prefix
```
2. **Install the chart:**
```bash
helm install cf-deployer-ui ./helm
```
3. **Upgrade the chart:**
```bash
helm upgrade cf-deployer-ui ./helm
```
4. **Uninstall:**
```bash
helm uninstall cf-deployer-ui
```
### Helm Configuration Options
| Parameter | Description | Default |
|-----------|-------------|---------|
| `replicaCount` | Number of replicas | `2` |
| `image.repository` | Docker image repository | `cf-deployer-ui` |
| `image.tag` | Docker image tag | `latest` |
| `service.type` | Kubernetes service type | `ClusterIP` |
| `service.port` | Service port | `80` |
| `backend.url` | Backend API URL | `http://cf-deployer-backend:8080` |
| `ingress.enabled` | Enable ingress | `false` |
| `resources.limits.cpu` | CPU limit | `500m` |
| `resources.limits.memory` | Memory limit | `512Mi` |
## Usage
### Deployment Flow
1. **Fill in Cloud Foundry Details:**
- API Endpoint (e.g., `https://api.cf.example.com`)
- Username and Password
- Organization and Space
- Application Name
- Skip SSL Validation (if needed)
2. **Select Files:**
- JAR File: Your application JAR
- Manifest File: Cloud Foundry manifest.yml
3. **Deploy:**
- Click "Deploy to Cloud Foundry"
- Watch progress bar and logs
- Wait for completion
### Screenshots
**Main Form:**
- Clean, responsive form with all required fields
- File upload with size display
- SSL validation checkbox
**Progress Tracking:**
- Visual progress bar (0-100%)
- Current step indicator
- Status badges (IN_PROGRESS, COMPLETED, FAILED)
**Logs Output:**
- Collapsible terminal-style output
- Timestamped log entries
- Auto-scroll to latest logs
## Architecture
### How It Works
The frontend mimics the behavior of `deploy-chunked-simple.sh`:
1. **Initialize Upload Session:**
```
POST /api/cf/upload/init
→ Returns uploadSessionId
```
2. **Upload Files in Chunks:**
- Splits files into 1MB chunks
- Base64 encodes each chunk (for Java proxy compatibility)
- Uploads via:
```
POST /api/cf/upload/chunk?uploadSessionId=...&fileType=...&chunkIndex=...&totalChunks=...&fileName=...
Headers: Content-Type: text/plain, X-Chunk-Encoding: base64
Body: Base64 chunk data
```
3. **Finalize Upload:**
```
POST /api/cf/upload/finalize?uploadSessionId=...&async=true
```
4. **Poll Deployment Status:**
```
GET /api/cf/deployment/status/{uploadSessionId}
(Every 5 seconds until COMPLETED or FAILED)
```
### Why Base64 Encoding?
The frontend sends chunks as Base64-encoded text because:
- It goes through a Java proxy that reads `@RequestBody String`
- Binary data gets corrupted when read as String
- Base64 ensures safe text transport through the proxy
- Backend automatically decodes Base64 back to binary
## Project Structure
```
frontend/
├── src/
│ ├── app/
│ │ ├── app.component.ts # Main component with form logic
│ │ ├── app.component.html # Template with form UI
│ │ ├── app.component.css # Component styles
│ │ └── deploy.service.ts # API service
│ ├── index.html # Main HTML file
│ ├── main.ts # Bootstrap file
│ └── styles.css # Global styles
├── helm/ # Helm chart
│ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ └── _helpers.tpl
├── nginx.conf # nginx configuration
├── Dockerfile # Multi-stage Docker build
├── angular.json # Angular CLI configuration
├── package.json # NPM dependencies
└── tsconfig.json # TypeScript configuration
```
## Development
### Prerequisites
- Node.js 20.x or higher
- npm 10.x or higher
- Angular CLI 19.x
### Install Angular CLI
```bash
npm install -g @angular/cli@19
```
### Code Structure
**app.component.ts:**
- Handles form state and validation
- Manages file uploads and chunking
- Polls deployment status
- Updates progress and logs
**deploy.service.ts:**
- Encapsulates all HTTP API calls
- Returns RxJS Observables converted to Promises
- Handles Base64 encoding headers
**Styling:**
- Responsive grid layout
- Mobile-first design
- Terminal-style logs with custom scrollbar
- Gradient progress bar
## Troubleshooting
### CORS Errors
If you see CORS errors in the browser console:
1. **Development:** Configure proxy in `angular.json`:
```json
{
"serve": {
"options": {
"proxyConfig": "proxy.conf.json"
}
}
}
```
Create `proxy.conf.json`:
```json
{
"/api": {
"target": "http://localhost:8080",
"secure": false
}
}
```
2. **Production:** nginx handles proxying (already configured)
### File Upload Fails
- Check that backend is running and accessible
- Verify `BACKEND_URL` environment variable
- Check browser console for error messages
- Enable DEBUG_MODE in backend to see detailed logs
### Deployment Timeout
- Default timeout is 10 minutes (120 attempts × 5 seconds)
- Increase `maxAttempts` in `pollDeploymentStatus()` if needed
- Check backend logs for actual deployment status
## Browser Support
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Mobile browsers (iOS Safari, Chrome Android)
## License
MIT License - see main project README

76
frontend/angular.json Normal file
View File

@@ -0,0 +1,76 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"cf-deployer-ui": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/cf-deployer-ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.json",
"assets": [],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "cf-deployer-ui:build:production"
},
"development": {
"buildTarget": "cf-deployer-ui:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
},
"cli": {
"analytics": false
}
}

6
frontend/helm/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: cf-deployer-ui
description: Cloud Foundry Deployer UI - Angular frontend
type: application
version: 1.0.0
appVersion: "1.0.0"

View File

@@ -0,0 +1,29 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "cf-deployer-ui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "cf-deployer-ui.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "cf-deployer-ui.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

View File

@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "cf-deployer-ui.fullname" . }}
labels:
app: {{ include "cf-deployer-ui.name" . }}
chart: {{ include "cf-deployer-ui.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
app: {{ include "cf-deployer-ui.name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ include "cf-deployer-ui.name" . }}
release: {{ .Release.Name }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
- name: BACKEND_URL
value: {{ .Values.backend.url | quote }}
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,44 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "cf-deployer-ui.fullname" . }}
labels:
app: {{ include "cf-deployer-ui.name" . }}
chart: {{ include "cf-deployer-ui.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "cf-deployer-ui.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "cf-deployer-ui.fullname" . }}
labels:
app: {{ include "cf-deployer-ui.name" . }}
chart: {{ include "cf-deployer-ui.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app: {{ include "cf-deployer-ui.name" . }}
release: {{ .Release.Name }}

61
frontend/helm/values.yaml Normal file
View File

@@ -0,0 +1,61 @@
# Default values for cf-deployer-ui
replicaCount: 2
image:
repository: cf-deployer-ui
pullPolicy: IfNotPresent
tag: "latest"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: false
name: ""
service:
type: ClusterIP
port: 80
targetPort: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: cf-deployer.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: cf-deployer-tls
# hosts:
# - cf-deployer.example.com
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
# Backend service URL
backend:
url: "http://cf-deployer-backend:8080"
nodeSelector: {}
tolerations: []
affinity: {}

45
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Angular app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass ${BACKEND_URL};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Increase timeout for long-running deployments
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Allow large file uploads
client_max_body_size 0;
}
}
}

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "cf-deployer-ui",
"version": "1.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration production"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.1.0",
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.1.0",
"@angular/platform-browser": "^19.1.0",
"@angular/platform-browser-dynamic": "^19.1.0",
"rxjs": "^7.8.0",
"tslib": "^2.6.0",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.1.0",
"@angular/cli": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"typescript": "~5.6.0"
}
}

38
frontend/quick-start.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
echo "CF Deployer UI - Quick Start"
echo "============================"
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "Error: Node.js is not installed"
echo "Please install Node.js 20.x or higher from https://nodejs.org"
exit 1
fi
echo "Node.js version: $(node --version)"
echo "npm version: $(npm --version)"
echo ""
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
echo "Installing dependencies..."
npm install
echo ""
fi
# Ask for backend URL
read -p "Enter backend URL (default: http://localhost:8080): " BACKEND_URL
BACKEND_URL=${BACKEND_URL:-http://localhost:8080}
echo ""
echo "Starting development server..."
echo "Backend URL: $BACKEND_URL"
echo ""
echo "The app will be available at: http://localhost:4200"
echo "Press Ctrl+C to stop"
echo ""
# Start dev server
npm start

View File

@@ -0,0 +1,442 @@
/* Dark Theme Variables */
:host {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--accent-primary: #58a6ff;
--accent-secondary: #1f6feb;
--success: #3fb950;
--error: #f85149;
--warning: #d29922;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 30px;
background: var(--bg-secondary);
color: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
header h1 {
margin: 0 0 10px 0;
font-size: 2.5em;
font-weight: 700;
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 25%, #ffaa00 50%, #ff6b35 75%, #f7931e 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 0 20px rgba(255, 107, 53, 0.3));
}
header p {
margin: 0;
opacity: 0.9;
font-size: 1.1em;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.card h2 {
margin: 0 0 20px 0;
color: var(--text-primary);
font-size: 1.5em;
}
.form-section {
margin-bottom: 30px;
}
.form-section h3 {
margin: 0 0 20px 0;
color: var(--text-primary);
font-size: 1.2em;
font-weight: 600;
padding-bottom: 10px;
border-bottom: 2px solid var(--border-color);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
box-sizing: border-box;
background: var(--bg-tertiary);
color: var(--text-primary);
transition: all 0.2s ease;
}
.form-group input[type="text"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.form-group input:disabled {
background-color: var(--bg-primary);
cursor: not-allowed;
opacity: 0.6;
}
/* Modern File Upload */
.file-upload-wrapper {
position: relative;
margin-bottom: 10px;
}
.file-input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.file-upload-btn {
display: inline-flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
background: var(--bg-tertiary);
border: 2px dashed var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.file-upload-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.file-upload-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.file-upload-btn svg {
flex-shrink: 0;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
}
.file-info svg {
flex-shrink: 0;
color: var(--accent-primary);
}
.file-size {
color: var(--text-secondary);
margin-left: auto;
}
.form-group.checkbox {
display: flex;
align-items: center;
}
.form-group.checkbox label {
margin: 0;
font-weight: normal;
cursor: pointer;
display: flex;
align-items: center;
color: var(--text-primary);
}
.form-group.checkbox input {
width: auto;
margin-right: 10px;
cursor: pointer;
width: 18px;
height: 18px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
justify-content: center;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
color: white;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 107, 53, 0.4);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.progress-section {
margin-top: 10px;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-weight: 600;
color: var(--text-primary);
font-size: 15px;
}
.progress-percent {
color: var(--accent-primary);
font-variant-numeric: tabular-nums;
}
.progress-bar {
height: 32px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1f6feb 0%, #8957e5 100%);
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 12px;
color: white;
font-weight: 600;
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
}
.status-badge {
margin-top: 20px;
text-align: center;
}
.badge {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: rgba(63, 185, 80, 0.15);
color: var(--success);
border: 1px solid var(--success);
}
.badge-error {
background: rgba(248, 81, 73, 0.15);
color: var(--error);
border: 1px solid var(--error);
}
.badge-info {
background: rgba(88, 166, 255, 0.15);
color: var(--accent-primary);
border: 1px solid var(--accent-primary);
}
.logs-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 5px;
border-radius: 6px;
transition: background 0.2s ease;
}
.logs-header:hover {
background: var(--bg-hover);
}
.logs-header h2 {
color: var(--text-primary);
margin: 0;
font-size: 1.3em;
}
.toggle-icon {
font-size: 1.2em;
color: var(--text-secondary);
transition: transform 0.2s ease;
}
.logs-output {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-top: 16px;
max-height: 500px;
overflow-y: auto;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
}
.log-line {
padding: 4px 0;
color: var(--text-secondary);
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
}
.log-line:last-child {
border-bottom: none;
}
/* Scrollbar styling */
.logs-output::-webkit-scrollbar {
width: 10px;
}
.logs-output::-webkit-scrollbar-track {
background: var(--bg-primary);
border-radius: 4px;
}
.logs-output::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
.logs-output::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 10px;
}
header {
padding: 20px;
}
header h1 {
font-size: 1.8em;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
.card {
padding: 20px;
}
.logs-output {
max-height: 300px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,217 @@
<div class="container">
<header>
<h1>Tanzu Deployer</h1>
<p>Deploy your applications to Tanzu Platform</p>
</header>
<div class="card">
<form (ngSubmit)="deploy()" #deployForm="ngForm">
<!-- Cloud Foundry Configuration -->
<div class="form-section">
<h3>Tanzu Configuration</h3>
<div class="form-group">
<label for="apiEndpoint">API Endpoint *</label>
<input
type="text"
id="apiEndpoint"
name="apiEndpoint"
[(ngModel)]="apiEndpoint"
placeholder="https://api.cf.example.com"
required
[disabled]="uploading">
</div>
<div class="form-row">
<div class="form-group">
<label for="username">Username *</label>
<input
type="text"
id="username"
name="username"
[(ngModel)]="username"
placeholder="your-username"
required
[disabled]="uploading">
</div>
<div class="form-group">
<label for="password">Password *</label>
<input
type="password"
id="password"
name="password"
[(ngModel)]="password"
placeholder="••••••••"
required
[disabled]="uploading">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="organization">Organization *</label>
<input
type="text"
id="organization"
name="organization"
[(ngModel)]="organization"
placeholder="your-org"
required
[disabled]="uploading">
</div>
<div class="form-group">
<label for="space">Space *</label>
<input
type="text"
id="space"
name="space"
[(ngModel)]="space"
placeholder="dev"
required
[disabled]="uploading">
</div>
</div>
<div class="form-group">
<label for="appName">Application Name *</label>
<input
type="text"
id="appName"
name="appName"
[(ngModel)]="appName"
placeholder="my-app"
required
[disabled]="uploading">
</div>
<div class="form-group checkbox" *ngIf="env.enableSslValidation">
<label>
<input
type="checkbox"
name="skipSsl"
[(ngModel)]="skipSsl"
[disabled]="uploading">
Skip SSL Validation
</label>
</div>
</div>
<!-- Files Upload -->
<div class="form-section">
<h3>Application Files</h3>
<div class="form-group">
<label for="jarFile">JAR File *</label>
<div class="file-upload-wrapper">
<input
type="file"
id="jarFile"
name="jarFile"
class="file-input"
(change)="onJarFileChange($event)"
accept=".jar"
required
[disabled]="uploading">
<label for="jarFile" class="file-upload-btn" [class.disabled]="uploading">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span *ngIf="!jarFile">Choose JAR File</span>
<span *ngIf="jarFile">Change File</span>
</label>
</div>
<div class="file-info" *ngIf="jarFile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<span>{{ jarFile.name }}</span>
<span class="file-size">({{ (jarFile.size / 1024 / 1024).toFixed(2) }}MB)</span>
</div>
</div>
<div class="form-group">
<label for="manifestFile">Manifest File *</label>
<div class="file-upload-wrapper">
<input
type="file"
id="manifestFile"
name="manifestFile"
class="file-input"
(change)="onManifestFileChange($event)"
accept=".yml,.yaml"
required
[disabled]="uploading">
<label for="manifestFile" class="file-upload-btn" [class.disabled]="uploading">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span *ngIf="!manifestFile">Choose Manifest File</span>
<span *ngIf="manifestFile">Change File</span>
</label>
</div>
<div class="file-info" *ngIf="manifestFile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<span>{{ manifestFile.name }}</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="!deployForm.form.valid || uploading">
<span *ngIf="!uploading">Deploy to Tanzu</span>
<span *ngIf="uploading">Deploying...</span>
</button>
<button type="button" class="btn btn-secondary" (click)="clearForm()" [disabled]="uploading">
Clear Form
</button>
</div>
</form>
</div>
<!-- Progress Section -->
<div class="card" *ngIf="uploading || deploymentStatus">
<h2>Deployment Progress</h2>
<div class="progress-section">
<div class="progress-label">
<span>{{ currentStep }}</span>
<span class="progress-percent">{{ uploadProgress }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress"></div>
</div>
<div class="status-badge" *ngIf="deploymentStatus">
<span class="badge" [class.badge-success]="deploymentStatus === 'COMPLETED'"
[class.badge-error]="deploymentStatus === 'FAILED'"
[class.badge-info]="deploymentStatus === 'IN_PROGRESS'">
{{ deploymentStatus }}
</span>
</div>
</div>
</div>
<!-- Logs Section -->
<div class="card logs-card" *ngIf="logs.length > 0">
<div class="logs-header" (click)="toggleLogs()">
<h2>Deployment Logs</h2>
<span class="toggle-icon">{{ logsExpanded ? '▼' : '▶' }}</span>
</div>
<div class="logs-output" *ngIf="logsExpanded">
<div class="log-line" *ngFor="let log of logs">{{ log }}</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,217 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DeployService } from './deploy.service';
import { environment } from '../environments/environment';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
// Environment config
env = environment;
// Form fields
apiEndpoint = '';
username = '';
password = '';
organization = '';
space = '';
appName = '';
skipSsl = false;
jarFile: File | null = null;
manifestFile: File | null = null;
// Upload state
uploading = false;
uploadProgress = 0;
currentStep = '';
logs: string[] = [];
logsExpanded = environment.defaultLogsExpanded;
deploymentStatus = '';
sessionId = '';
constructor(private deployService: DeployService) {}
onJarFileChange(event: any) {
this.jarFile = event.target.files[0];
}
onManifestFileChange(event: any) {
this.manifestFile = event.target.files[0];
}
addLog(message: string) {
const timestamp = new Date().toLocaleTimeString();
this.logs.push(`[${timestamp}] ${message}`);
}
async deploy() {
if (!this.jarFile || !this.manifestFile) {
alert('Please select both JAR and Manifest files');
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.logs = [];
this.deploymentStatus = '';
try {
// Step 1: Initialize upload session
this.currentStep = 'Initializing upload session...';
this.addLog('Step 1/5: Initializing upload session');
const config = {
apiEndpoint: this.apiEndpoint,
username: this.username,
password: this.password,
organization: this.organization,
space: this.space,
appName: this.appName,
skipSslValidation: this.skipSsl
};
const initResponse = await this.deployService.initUpload(config);
this.sessionId = initResponse.uploadSessionId;
this.addLog(`✓ Session created: ${this.sessionId}`);
this.uploadProgress = 10;
// Step 2: Upload JAR file
this.currentStep = 'Uploading JAR file...';
this.addLog(`Step 2/5: Uploading JAR file (${(this.jarFile.size / 1024 / 1024).toFixed(2)}MB)`);
await this.uploadFileInChunks(this.jarFile, 'jarFile', 10, 50);
this.addLog('✓ JAR file upload completed');
// Step 3: Upload manifest file
this.currentStep = 'Uploading manifest file...';
this.addLog('Step 3/5: Uploading manifest file');
await this.uploadFileInChunks(this.manifestFile, 'manifest', 50, 60);
this.addLog('✓ Manifest file upload completed');
// Step 4: Finalize and start deployment
this.currentStep = 'Starting deployment...';
this.addLog('Step 4/5: Starting async deployment');
const finalizeResponse = await this.deployService.finalizeUpload(this.sessionId);
this.addLog(`✓ Deployment started (Status: ${finalizeResponse.status})`);
this.uploadProgress = 70;
// Step 5: Poll deployment status
this.currentStep = 'Deploying application...';
this.addLog('Step 5/5: Polling deployment status');
await this.pollDeploymentStatus();
} catch (error: any) {
this.addLog(`✗ ERROR: ${error.message || error}`);
this.currentStep = 'Deployment failed';
this.deploymentStatus = 'FAILED';
this.uploading = false;
}
}
async uploadFileInChunks(file: File, fileType: string, startProgress: number, endProgress: number) {
const CHUNK_SIZE = environment.chunkSize;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// Convert chunk to base64
const base64Chunk = await this.fileToBase64(chunk);
await this.deployService.uploadChunk(
this.sessionId,
fileType,
file.name,
chunkIndex,
totalChunks,
base64Chunk
);
const progress = startProgress + ((chunkIndex + 1) / totalChunks) * (endProgress - startProgress);
this.uploadProgress = Math.round(progress);
this.addLog(` Chunk ${chunkIndex + 1}/${totalChunks} uploaded`);
}
}
async fileToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async pollDeploymentStatus() {
const maxAttempts = environment.maxPollAttempts;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, environment.pollInterval));
const status = await this.deployService.getDeploymentStatus(this.sessionId);
this.deploymentStatus = status.status;
const progressPercent = 70 + (status.progress || 0) * 0.3;
this.uploadProgress = Math.round(progressPercent);
this.addLog(` Status: ${status.status} - ${status.message || ''}`);
if (status.status === 'COMPLETED') {
this.currentStep = 'Deployment completed successfully!';
this.uploadProgress = 100;
this.uploading = false;
this.addLog('✓ Deployment completed successfully!');
return;
}
if (status.status === 'FAILED') {
this.currentStep = 'Deployment failed';
this.uploading = false;
this.addLog(`✗ Deployment failed: ${status.message}`);
if (status.error) {
this.addLog(` Error: ${status.error}`);
}
throw new Error('Deployment failed');
}
attempts++;
}
throw new Error('Deployment timeout after 10 minutes');
}
toggleLogs() {
this.logsExpanded = !this.logsExpanded;
}
clearForm() {
this.apiEndpoint = '';
this.username = '';
this.password = '';
this.organization = '';
this.space = '';
this.appName = '';
this.skipSsl = false;
this.jarFile = null;
this.manifestFile = null;
this.logs = [];
this.uploadProgress = 0;
this.currentStep = '';
this.deploymentStatus = '';
}
}

View File

@@ -0,0 +1,97 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../environments/environment';
export interface CfConfig {
apiEndpoint: string;
username: string;
password: string;
organization: string;
space: string;
appName: string;
skipSslValidation: boolean;
}
export interface InitUploadResponse {
success: boolean;
uploadSessionId: string;
message: string;
}
export interface ChunkUploadResponse {
success: boolean;
uploadSessionId: string;
message: string;
}
export interface FinalizeResponse {
uploadSessionId: string;
status: string;
message: string;
progress: number;
}
export interface DeploymentStatus {
uploadSessionId: string;
status: string;
message: string;
progress: number;
output?: string;
error?: string;
}
@Injectable({
providedIn: 'root'
})
export class DeployService {
private apiBase = environment.apiBase;
constructor(private http: HttpClient) {}
async initUpload(config: CfConfig): Promise<InitUploadResponse> {
const url = `${this.apiBase}/upload/init`;
return firstValueFrom(
this.http.post<InitUploadResponse>(url, config)
);
}
async uploadChunk(
sessionId: string,
fileType: string,
fileName: string,
chunkIndex: number,
totalChunks: number,
base64Data: string
): Promise<ChunkUploadResponse> {
const url = `${this.apiBase}/upload/chunk?uploadSessionId=${sessionId}&fileType=${fileType}&chunkIndex=${chunkIndex}&totalChunks=${totalChunks}&fileName=${encodeURIComponent(fileName)}`;
const headers = new HttpHeaders({
'Content-Type': 'text/plain',
'X-Chunk-Encoding': 'base64'
});
return firstValueFrom(
this.http.post<ChunkUploadResponse>(url, base64Data, { headers })
);
}
async finalizeUpload(sessionId: string): Promise<FinalizeResponse> {
const url = `${this.apiBase}/upload/finalize?uploadSessionId=${sessionId}&async=true`;
const headers = new HttpHeaders({
'Content-Length': '0'
});
return firstValueFrom(
this.http.post<FinalizeResponse>(url, null, { headers })
);
}
async getDeploymentStatus(sessionId: string): Promise<DeploymentStatus> {
const url = `${this.apiBase}/deployment/status/${sessionId}`;
return firstValueFrom(
this.http.get<DeploymentStatus>(url)
);
}
}

View File

@@ -0,0 +1,20 @@
export const environment = {
production: true,
// API Configuration
apiBase: '/api/cf',
// Upload Configuration
chunkSize: 1048576, // 1MB in bytes
// Feature Flags
enableSslValidation: false, // Show/hide SSL validation checkbox
// Polling Configuration
pollInterval: 5000, // 5 seconds in milliseconds
maxPollAttempts: 120, // 10 minutes total (120 * 5 seconds)
// UI Configuration
defaultLogsExpanded: true,
showDebugInfo: false
};

View File

@@ -0,0 +1,20 @@
export const environment = {
production: false,
// API Configuration
apiBase: '/api/cf',
// Upload Configuration
chunkSize: 1048576, // 1MB in bytes
// Feature Flags
enableSslValidation: false, // Show/hide SSL validation checkbox
// Polling Configuration
pollInterval: 5000, // 5 seconds in milliseconds
maxPollAttempts: 120, // 10 minutes total (120 * 5 seconds)
// UI Configuration
defaultLogsExpanded: true,
showDebugInfo: false
};

12
frontend/src/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CF Deployer</title>
<base href="/">
</head>
<body>
<app-root></app-root>
</body>
</html>

9
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient()
]
}).catch(err => console.error(err));

17
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,17 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #010409;
min-height: 100vh;
padding: 20px 0;
color: #c9d1d9;
}
* {
box-sizing: border-box;
}

30
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}