forked from toolshed/coop-cloud-backend
Compare commits
33
Commits
main
...
dockerfile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c61308655 | ||
|
|
66695c7e7d | ||
|
|
1961ba5a44 | ||
|
|
d1364c521e | ||
|
|
e01d9df2d7 | ||
|
|
932e36cc9b | ||
|
|
af31229c11 | ||
|
|
8f3e491be5 | ||
|
|
3b192f9113 | ||
|
|
72a76114f7 | ||
|
|
1d89c82563 | ||
|
|
33d3bfcebf | ||
|
|
aaaae0d0a3 | ||
|
|
4ecf49d0ce | ||
|
|
8333e0a118 | ||
|
|
1966d8cafe | ||
|
|
d2cb35e5ca | ||
|
|
6782b150bd | ||
|
|
2034e70229 | ||
|
|
61e54b958f | ||
|
|
99a62a20ea | ||
|
|
77a917c0dd | ||
|
|
dbd20fd75a | ||
|
|
aff1389e38 | ||
|
|
b3e3dd1a82 | ||
|
|
e9fcc8bd0b | ||
|
|
916c3036de | ||
|
|
3d658103b6 | ||
|
|
619b107a49 | ||
|
|
4a8d4b281c | ||
|
|
aa1bd46039 | ||
|
|
491d9ffc10 | ||
|
|
d46144c4f2 |
@@ -0,0 +1,54 @@
|
||||
name: Build And Publish nightly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short ${{ github.sha }})
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Login to Self-Hosted Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: git.coopcloud.tech
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and Push nightly+HASH
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly-${{ env.COMMIT_SHORT_SHA }}
|
||||
|
||||
- name: Build and Push nightly
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Build And Publish Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]*.[0-9]*.[0-9]*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Version tag (e.g., 0.1.0)"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get release tag
|
||||
id: vars
|
||||
run: |
|
||||
if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then
|
||||
TAG="${{ gitea.inputs.version }}"
|
||||
else
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
fi
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Login to Self-Hosted Registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: git.coopcloud.tech
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "web"]
|
||||
path = web
|
||||
url = https://git.coopcloud.tech/toolshed/coop-cloud-front.git
|
||||
|
||||
+13
-7
@@ -1,19 +1,25 @@
|
||||
FROM golang:1.25
|
||||
RUN mkdir /backend
|
||||
COPY --parents ["api", "cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
|
||||
COPY --parents ["cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
|
||||
RUN go build -C /backend -o gobackend
|
||||
|
||||
FROM node:slim
|
||||
FROM git.coopcloud.tech/toolshed/abra:latest AS abra
|
||||
|
||||
FROM node:26-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
openssh-client \
|
||||
ca-certificates \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir /home/node/wizard
|
||||
COPY --parents web /home/node/wizard
|
||||
COPY --from=0 /backend/gobackend /home/node/wizard/gobackend
|
||||
|
||||
USER node
|
||||
COPY ./command.sh /
|
||||
RUN curl https://install.abra.coopcloud.tech | bash
|
||||
ENV ABRA_BIN=/home/node/.local/bin/abra
|
||||
# RUN $ABRA_BIN recipe fetch -a
|
||||
COPY --from=abra /abra /usr/local/bin/abra
|
||||
COPY ./command.sh /command.sh
|
||||
|
||||
COPY --parents web /home/node/wizard
|
||||
WORKDIR /home/node/wizard/web
|
||||
USER root
|
||||
RUN npm install .
|
||||
|
||||
@@ -3,16 +3,18 @@ A Go service that exposes RESTful API endpoints to manage Abra programmatically.
|
||||
Integrates with https://git.coopcloud.tech/toolshed/coop-cloud-front.
|
||||
|
||||
## Starting the service with Docker
|
||||
Build the container:
|
||||
Pull the container:
|
||||
```bash
|
||||
docker build -t coop-cloud-wizard:latest
|
||||
docker pull git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
|
||||
```
|
||||
|
||||
Run the container:
|
||||
```bash
|
||||
docker run -u 1000:1000 -v "$HOME/.abra:/home/node/.abra" -v "$HOME/.ssh:/home/node/.ssh" coop-cloud-wizard
|
||||
docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh -v $HOME/.abra:/home/node/.abra coop-cloud-webui
|
||||
```
|
||||
Upstream container not working? You can build the container manually:
|
||||
```bash
|
||||
docker build -t coop-cloud-webui:latest .
|
||||
```
|
||||
|
||||
## Getting started with development
|
||||
- Clone the front end application `git clone https://git.coopcloud.tech/toolshed/coop-cloud-front.git`
|
||||
- Checkout the `dev-nomock` branch `cd coop-cloud-front && git checkout dev-nomock`
|
||||
@@ -20,29 +22,19 @@ docker run -u 1000:1000 -v "$HOME/.abra:/home/node/.abra" -v "$HOME/.ssh:/home/n
|
||||
- Start this Go app `go run .`
|
||||
- Navigate to the React App (http://localhost:5173)
|
||||
|
||||
> [!WARNING]
|
||||
> This is an extremely early prototype, only viewing/deploying apps is supported
|
||||
> and may fail for your local machine
|
||||
|
||||
## Notes:
|
||||
`api/` is a deprecated path, currently has no function
|
||||
|
||||
# Roadmap
|
||||
## General Roadmap:
|
||||
Currently refactored the backend to be much more Abra CLI reliant. This is ok but is not ideal from an engineering standpoint (probably).
|
||||
First step is just to polish this backend & frontend, estimate 1 - 2 months to be in a fairly usable state.
|
||||
Write test suite for the full stack combination.
|
||||
Then we want to package this app into a dockerized form, easily to deploy and use on one's own server.
|
||||
Slowly introduce full Abra CLI functionality into app.
|
||||
TODO
|
||||
Repackaging
|
||||
## Basic Abra Features
|
||||
| Feature | Status | Follow-up |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| More options for app deploy (esp chaos) | TODO | |
|
||||
| View and pull in recipes from catalogue | In Progress | |
|
||||
| Deploy should show deployment status similar to Abra CLI | In Progress | |
|
||||
| App screen should display services associated with app and their status | TODO | |
|
||||
| Easily show service logs | In Progress | |
|
||||
| Modify App config | TODO | |
|
||||
| Manage updates and rollbacks | TODO | |
|
||||
| View and pull in recipes from catalogue | Done | Refine Frontend |
|
||||
| Deploy should show deployment status similar to Abra CLI | Done | Make more responsive and have page render more accurately |
|
||||
| App screen should display services associated with app and their status | Done | |
|
||||
| Easily show service logs | Done | More ways to process logs? |
|
||||
| Modify App config | Done | |
|
||||
| Manage updates and rollbacks | Done | |
|
||||
| Manage secrets and Abra commands | In Progress | |
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
// Upgrader is used to upgrade HTTP connections to WebSocket connections.
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
func wsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error upgrading:", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
// Listen for incoming messages
|
||||
for {
|
||||
// Read message from the client
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
fmt.Println("Error reading message:", err)
|
||||
break
|
||||
}
|
||||
fmt.Printf("Received: %s\\n", message)
|
||||
// Echo the message back to the client
|
||||
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
fmt.Println("Error writing message:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type abraHandler struct{
|
||||
mux *http.ServeMux
|
||||
}
|
||||
func newAbraHandler() *abraHandler {
|
||||
h := &abraHandler{
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
h.mux.HandleFunc("/api/abra/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleListApps(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/abra/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Chaos") == "true"{
|
||||
internal.Chaos = true
|
||||
} else {
|
||||
internal.Chaos = false
|
||||
}
|
||||
|
||||
switch r.Method{
|
||||
case http.MethodPost:
|
||||
h.handleDeployApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/abra/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method{
|
||||
case http.MethodPost:
|
||||
h.handleUndeployApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/abra/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleListServers(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/abra/catalogue", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleListCatalogue(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
return h
|
||||
}
|
||||
func StartAPI() {
|
||||
http.HandleFunc("/ws", wsHandler)
|
||||
fmt.Println("WebSocket server started on :3001")
|
||||
err := http.ListenAndServe(":3001", nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error starting server:", err)
|
||||
}
|
||||
|
||||
h := newAbraHandler()
|
||||
fmt.Println("Server started on port 3000")
|
||||
http.ListenAndServe(":3000", h)
|
||||
}
|
||||
|
||||
func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Pre-processing: logging
|
||||
log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
// Delegate to internal mux
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipes := catalogue.Flatten()
|
||||
|
||||
jsonBytes, err := json.Marshal(recipes)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
-299
@@ -1,299 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
|
||||
"coopcloud.tech/abra/pkg/ui"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/deploy"
|
||||
|
||||
"net/http"
|
||||
"github.com/gorilla/websocket"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("App Id: %s", appName)
|
||||
app, err := GetApp(appName)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Printf("Error Connecting to Docker client: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error Connecting to Docker client: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Printf("Error checking deploy status: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error checking deploy status: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if deployMeta.IsDeployed {
|
||||
log.Printf("App already deployed\n")
|
||||
http.Error(w, "App already deployed\n", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// logic differs from CLI, we only want to take either
|
||||
// 1. the chaos version
|
||||
// 2. TODO: the version in the .env file
|
||||
// 3. the latest version
|
||||
// we never take: specific CLI verison (maybe will support this) or the deployed version
|
||||
internal.Chaos = false
|
||||
toDeployVersion, err := getDeployVersion(deployMeta, app)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSecrets(cl, app); err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deploy.MergeAbraShEnv(app.Recipe, app.Env); err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
|
||||
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
|
||||
if internal.Chaos {
|
||||
appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion)
|
||||
}
|
||||
|
||||
versionLabel := toDeployVersion
|
||||
if internal.Chaos {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName)
|
||||
// NOTE(d1): keep non-chaos version labbeling when doing chaos ops
|
||||
versionLabel = service.Deploy.Labels[labelKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
appPkg.SetVersionLabel(compose, stackName, versionLabel)
|
||||
|
||||
newRecipeWithDeployVersion := fmt.Sprintf("%s:%s", app.Recipe.Name, toDeployVersion)
|
||||
appPkg.ExposeAllEnv(stackName, compose, app.Env, newRecipeWithDeployVersion)
|
||||
|
||||
envVars, err := appPkg.CheckEnv(app)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// doesn't really get used at all right now
|
||||
deployWarnMessages := []string{}
|
||||
for _, envVar := range envVars {
|
||||
if !envVar.Present {
|
||||
deployWarnMessages = append(deployWarnMessages,
|
||||
fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//skipping domain checks like crazy
|
||||
//commented code is to show deploy overview before deploy
|
||||
|
||||
/*
|
||||
deployedVersion := config.MISSING_DEFAULT
|
||||
if deployMeta.IsDeployed {
|
||||
deployedVersion = deployMeta.Version
|
||||
if deployMeta.IsChaos {
|
||||
deployedVersion = deployMeta.ChaosVersion
|
||||
}
|
||||
}
|
||||
ShowUnchanged := false
|
||||
secretInfo, err := deploy.GatherSecretsForDeploy(cl, app, ShowUnchanged)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Gather configs
|
||||
configInfo, err := deploy.GatherConfigsForDeploy(cl, app, compose, app.Env, ShowUnchanged)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Gather images
|
||||
imageInfo, err := deploy.GatherImagesForDeploy(cl, app, compose, ShowUnchanged)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}*/
|
||||
|
||||
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// in future allow user input?
|
||||
DontWaitConverge := true
|
||||
NoInput := true
|
||||
if err := stack.RunDeploy(
|
||||
cl,
|
||||
deployOpts,
|
||||
compose,
|
||||
app.Name,
|
||||
app.Server,
|
||||
DontWaitConverge,
|
||||
NoInput,
|
||||
f,
|
||||
); err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Implement my own WaitOnServices in order to wrap ui.Model in order to emit JSON updates
|
||||
// over Websocket connection
|
||||
var serviceIDs []ui.ServiceMeta
|
||||
namespace := convert.NewNamespace(deployOpts.Namespace)
|
||||
|
||||
existingServices, err := stack.GetStackServices(context.Background(), cl, namespace.Name())
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, service := range existingServices {
|
||||
serviceIDs = append(serviceIDs, ui.ServiceMeta{
|
||||
Name: service.Spec.Name,
|
||||
ID: service.ID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
waitOpts := WaitOpts{
|
||||
Services: serviceIDs,
|
||||
AppName: app.Name,
|
||||
ServerName: app.Server,
|
||||
NoInput: NoInput,
|
||||
Filters: f,
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type StateEmitter interface {
|
||||
Emit(DeployState)
|
||||
}
|
||||
|
||||
type WebsocketEmitter struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
func (w *WebsocketEmitter) Emit(state DeployState) {
|
||||
b, _ := json.Marshal(state)
|
||||
w.conn.WriteMessage(websocket.TextMessage, b)
|
||||
}
|
||||
|
||||
type WrappedModel struct {
|
||||
inner tea.Model
|
||||
emitter StateEmitter
|
||||
}
|
||||
|
||||
func (m WrappedModel) Init() tea.Cmd {
|
||||
return m.inner.Init()
|
||||
}
|
||||
|
||||
func (m WrappedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
innerState, cmds := m.inner.Update(msg)
|
||||
|
||||
m.inner = innerState
|
||||
|
||||
m.emitter.Emit(s.State())
|
||||
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
func (m WrappedModel) View() string {
|
||||
return m.inner.View()
|
||||
}
|
||||
|
||||
func (m *ui.Model) State() DeployState {
|
||||
var streams []DeployStream
|
||||
if m.Streams != nil {
|
||||
for _, s := range *m.Streams {
|
||||
streams = append(streams, DeployStream{
|
||||
Name: s.Name,
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
retries: s.retries,
|
||||
health: s.health,
|
||||
rollback: s.rollback,
|
||||
})
|
||||
}
|
||||
}
|
||||
var logs []string
|
||||
if m.Logs != nil {
|
||||
logs = *m.Logs
|
||||
}
|
||||
return DeployState{
|
||||
AppName: m.appName,
|
||||
Streams: streams,
|
||||
Logs: logs,
|
||||
Failed: m.Failed,
|
||||
TimedOut: m.TimedOut,
|
||||
Quit: m.Quit,
|
||||
Count: m.count,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
func InternalServerErrorHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("500 Internal Server Error"))
|
||||
}
|
||||
|
||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404 Not Found"))
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/json"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coop-cloud-backend/internal"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) {
|
||||
appNames, err := GetAppNames()
|
||||
if err != nil {
|
||||
log.Printf("Error getting app names: %s\n", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
// counts the number of apps in a server to initialize slices later
|
||||
serverAppCount := make(map[string]int)
|
||||
|
||||
remoteApps := make([]appPkg.App, 0, len(appNames))
|
||||
for _, appName := range appNames {
|
||||
remoteApp, err := GetApp(appName)
|
||||
serverAppCount[remoteApp.Server] += 1
|
||||
if err != nil {
|
||||
log.Printf("Error getting app %s: %s\n", appName, err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
remoteApps = append(remoteApps, remoteApp)
|
||||
}
|
||||
|
||||
appStatuses, err := GetAppStatuses(remoteApps)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
abraAppResponse := ServerAppsResponse{}
|
||||
for _, app := range remoteApps {
|
||||
serverApps, ok := abraAppResponse[app.Server]
|
||||
if !ok { // create the slice
|
||||
// ever other field initializes to 0
|
||||
serverApps = ServerApps{
|
||||
Apps: make([]AbraApp, 0, serverAppCount[app.Server]),
|
||||
}
|
||||
}
|
||||
appInfo, ok := appStatuses[app.StackName()]
|
||||
if ok {
|
||||
log.Printf("app %s is deployed\n", app.Name)
|
||||
serverApps.Apps = append(serverApps.Apps, appTranspose(app, appInfo))
|
||||
serverApps.AppCount += 1
|
||||
// assume these are true rn idk
|
||||
serverApps.VersionCount += 1
|
||||
serverApps.LatestCount += 1
|
||||
} else {
|
||||
log.Printf("app %s is undeployed\n", app.Name)
|
||||
appT, err := appTransposeUndeployed(app)
|
||||
if err != nil {
|
||||
log.Printf("app transpose failed: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("app transpose failed: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serverApps.Apps = append(serverApps.Apps, appT)
|
||||
serverApps.AppCount += 1
|
||||
// assume these are true rn idk
|
||||
serverApps.VersionCount += 1
|
||||
serverApps.LatestCount += 1
|
||||
}
|
||||
abraAppResponse[app.Server] = serverApps
|
||||
}
|
||||
jsonBytes, err := json.Marshal(abraAppResponse)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
func appTransposeUndeployed(app appPkg.App) (AbraApp, error) {
|
||||
config, err := app.Recipe.GetComposeConfig(app.Env)
|
||||
if err != nil {
|
||||
return AbraApp{}, err
|
||||
}
|
||||
version := GetLabel(config, app.StackName(), "version")
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
return AbraApp{
|
||||
AppName: app.Name,
|
||||
Server: app.Server,
|
||||
Recipe: app.Recipe.Name,
|
||||
Domain: app.Domain,
|
||||
Chaos: "false",
|
||||
Status: "undeployed",
|
||||
ChaosVersion: "unknown",
|
||||
Version: version,
|
||||
Upgrade: "latest",
|
||||
}, nil
|
||||
}
|
||||
func appTranspose(app appPkg.App, psInfo map[string]string) AbraApp {
|
||||
return AbraApp{
|
||||
AppName: app.Name,
|
||||
Server: app.Server,
|
||||
Recipe: app.Recipe.Name,
|
||||
Domain: app.Domain,
|
||||
Chaos: psInfo["chaos"],
|
||||
Status: psInfo["status"],
|
||||
ChaosVersion: getOrDefault(psInfo, "chaosVersion", "unknown"),
|
||||
Version: psInfo["version"],
|
||||
Upgrade: "latest",
|
||||
}
|
||||
}
|
||||
|
||||
func getOrDefault(m map[string]string, key, def string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request) {
|
||||
servers, err := GetServers()
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serverNames, err := ReadServerNames()
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
abraServers := make([]AbraServer, 0, len(servers))
|
||||
for i := range len(servers) {
|
||||
abraServers = append(abraServers,
|
||||
AbraServer{
|
||||
Name: serverNames[i],
|
||||
Host: servers[i],
|
||||
},
|
||||
)
|
||||
}
|
||||
jsonBytes, err := json.Marshal(abraServers)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package api
|
||||
|
||||
// Represents an App, follows the format of
|
||||
// https://git.coopcloud.tech/toolshed/coop-cloud-front
|
||||
type AbraApp struct {
|
||||
Server string `json:"server"`
|
||||
Recipe string `json:"recipe`
|
||||
AppName string `json:"appName"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
Chaos string `json:"chaos"`
|
||||
ChaosVersion string `json:"chaosVersion"`
|
||||
Version string `json:"version"`
|
||||
Upgrade string `json:"upgrade"`
|
||||
}
|
||||
|
||||
type AbraServer struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type ServerAppsResponse map[string]ServerApps
|
||||
|
||||
type ServerApps struct {
|
||||
Apps []AbraApp `json:"apps"`
|
||||
AppCount int `json:"appCount"`
|
||||
VersionCount int `json:"versionCount"`
|
||||
UnversionedCount int `json:"unversionedCount"`
|
||||
LatestCount int `json:"latestCount"`
|
||||
UpgradeCount int `json:"upgradeCount"`
|
||||
}
|
||||
|
||||
type DeployState struct {
|
||||
AppName string `json:"appName"`
|
||||
Streams []DeployStream `json:"streams"`
|
||||
Logs []string `json:"logs"`
|
||||
Failed bool `json:"failed"`
|
||||
TimedOut bool `json:"timedOut"`
|
||||
Quit bool `json:"quit"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DeployStream struct {
|
||||
Name string `json:"Name"`
|
||||
id string `json:"id"`
|
||||
status string `json:"status"`
|
||||
retries int `json:"retries"`
|
||||
health string `json:"health"`
|
||||
rollback bool `json:"rollback"`
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipes := catalogue.Flatten()
|
||||
|
||||
jsonBytes, err := json.Marshal(recipes)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("App Id: %s", appName)
|
||||
app, err := GetApp(appName)
|
||||
if err != nil {
|
||||
log.Printf("Error getting app %s: %s\n", appName, err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
// think this just checks to make sure we have the recipe for this app
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatal("%s is not deployed?", app.Name)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
version := deployMeta.Version
|
||||
if deployMeta.IsChaos {
|
||||
version = deployMeta.ChaosVersion
|
||||
}
|
||||
|
||||
rmOpts := stack.Remove{
|
||||
Namespaces: []string{app.StackName()},
|
||||
Detach: false,
|
||||
}
|
||||
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.WriteRecipeVersion(version, false); err != nil {
|
||||
log.Printf("writing recipe version failed: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("writing recipe version failed: %s\n", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"log"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
formatter "coopcloud.tech/abra/pkg/formatter"
|
||||
secret "coopcloud.tech/abra/pkg/secret"
|
||||
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
func getDeployVersion(deployMeta stack.DeployMeta, app appPkg.App) (string, error) {
|
||||
if internal.Chaos {
|
||||
v, err := app.Recipe.ChaosVersion()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Printf("version: taking chaos version: %s", v)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
v, err := getLatestVersionOrCommit(app)
|
||||
log.Printf("version: taking new recipe version: %s", v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
func getLatestVersionOrCommit(app appPkg.App) (string, error) {
|
||||
recipeVersions, warnings, err := app.Recipe.GetRecipeVersions()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, warning := range warnings {
|
||||
log.Printf(warning)
|
||||
}
|
||||
|
||||
if len(recipeVersions) > 0 && !internal.Chaos {
|
||||
latest := recipeVersions[len(recipeVersions)-1]
|
||||
for tag := range latest {
|
||||
log.Printf("selected latest recipe version: %s (from %d available versions)", tag, len(recipeVersions))
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
head, err := app.Recipe.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return formatter.SmallSHA(head.String()), nil
|
||||
}
|
||||
|
||||
func validateSecrets(cl *dockerClient.Client, app appPkg.App) error {
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secretsConfig, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secStats, err := secret.PollSecretsStatus(cl, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, secStat := range secStats {
|
||||
if !secStat.CreatedOnRemote {
|
||||
secretConfig := secretsConfig[secStat.LocalName]
|
||||
if secretConfig.SkipGenerate {
|
||||
return errors.New(fmt.Sprintf("secret not inserted (#generate=false): %s", secStat.LocalName))
|
||||
}
|
||||
return errors.New(fmt.Sprintf("secret not generated: %s", secStat.LocalName))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
)
|
||||
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
|
||||
return appPkg.GetLabel(compose, stackName, label)
|
||||
}
|
||||
|
||||
func GetAppStatuses(apps []appPkg.App) (map[string]map[string]string, error) {
|
||||
return appPkg.GetAppStatuses(apps, true)
|
||||
}
|
||||
func GetApp(appName string) (appPkg.App, error) {
|
||||
return appPkg.Get(appName)
|
||||
}
|
||||
|
||||
func GetAppNames() ([]string, error) {
|
||||
return appPkg.GetAppNames()
|
||||
}
|
||||
|
||||
func GetAppServiceNames(appName string) ([]string, error) {
|
||||
return appPkg.GetAppServiceNames(appName)
|
||||
}
|
||||
|
||||
func GetServers() ([]string, error) {
|
||||
return config.GetServers()
|
||||
}
|
||||
|
||||
func ReadServerNames() ([]string, error) {
|
||||
return config.ReadServerNames()
|
||||
}
|
||||
+146
-58
@@ -1,49 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"coop-cloud-backend/cli/external"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/gorilla/websocket"
|
||||
"os/exec"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
// Upgrader is used to upgrade HTTP connections to WebSocket connections.
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
func wsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error upgrading:", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
// Listen for incoming messages
|
||||
for {
|
||||
// Read message from the client
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
fmt.Println("Error reading message:", err)
|
||||
break
|
||||
}
|
||||
fmt.Printf("Received: %s\\n", message)
|
||||
// Echo the message back to the client
|
||||
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
fmt.Println("Error writing message:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// // Upgrader is used to upgrade HTTP connections to WebSocket connections.
|
||||
// var upgrader = websocket.Upgrader{
|
||||
// CheckOrigin: func(r *http.Request) bool {
|
||||
// return true
|
||||
// },
|
||||
// }
|
||||
// func wsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// // Upgrade the HTTP connection to a WebSocket connection
|
||||
// conn, err := upgrader.Upgrade(w, r, nil)
|
||||
// if err != nil {
|
||||
// fmt.Println("Error upgrading:", err)
|
||||
// return
|
||||
// }
|
||||
// defer conn.Close()
|
||||
// // Listen for incoming messages
|
||||
// for {
|
||||
// // Read message from the client
|
||||
// _, message, err := conn.ReadMessage()
|
||||
// if err != nil {
|
||||
// fmt.Println("Error reading message:", err)
|
||||
// break
|
||||
// }
|
||||
// fmt.Printf("Received: %s\\n", message)
|
||||
// // Echo the message back to the client
|
||||
// if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
// fmt.Println("Error writing message:", err)
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
type MockCommandRunner struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type abraHandler struct{
|
||||
mux *http.ServeMux
|
||||
func (m *MockCommandRunner) Run(name string, args ...string) ([]byte, error) {
|
||||
callArgs := []interface{}{name}
|
||||
for _, arg := range args {
|
||||
callArgs = append(callArgs, arg)
|
||||
}
|
||||
|
||||
resp := m.Called(callArgs...)
|
||||
return resp.Get(0).([]byte), resp.Error(1)
|
||||
}
|
||||
|
||||
// abstracted out interface into this runner so i can write tests
|
||||
type CommandRunner interface {
|
||||
Run(name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
func (ExecRunner) Run(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).CombinedOutput()
|
||||
}
|
||||
|
||||
type abraHandler struct {
|
||||
mux *http.ServeMux
|
||||
cmd CommandRunner
|
||||
secret SecretUtilsWrapper
|
||||
ap external.AppPackage
|
||||
sp external.ServerPackage
|
||||
rp external.RecipePackage
|
||||
}
|
||||
|
||||
func newAbraHandler() *abraHandler {
|
||||
h := &abraHandler{
|
||||
mux: http.NewServeMux(),
|
||||
mux: http.NewServeMux(),
|
||||
cmd: ExecRunner{},
|
||||
secret: &SecretUtils{},
|
||||
ap: &external.AbraAppPackage{},
|
||||
sp: &external.AbraServerPackage{},
|
||||
rp: &external.AbraRecipePackage{},
|
||||
}
|
||||
h.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("API Path not found")
|
||||
@@ -51,30 +90,48 @@ func newAbraHandler() *abraHandler {
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
case http.MethodGet:
|
||||
h.handleListApps(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Chaos") == "true"{
|
||||
internal.Chaos = true
|
||||
} else {
|
||||
internal.Chaos = false
|
||||
}
|
||||
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleDeployApp(w, r, r.PathValue("appId"))
|
||||
h.handleDeployApp(w, r, r.PathValue("appId"), r.URL.Query().Get("chaos") == "true")
|
||||
case http.MethodGet:
|
||||
h.getDeployLogs(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/upgrade", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleUpgradeApp(w, r, r.PathValue("appId"), r.URL.Query().Get("force") == "true")
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/rollback", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleRollbackApp(w, r, r.PathValue("appId"), r.URL.Query().Get("force") == "true")
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/backup/create", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleBackupCreateApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleUndeployApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
@@ -82,7 +139,7 @@ func newAbraHandler() *abraHandler {
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/remove", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleRemoveApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
@@ -91,7 +148,7 @@ func newAbraHandler() *abraHandler {
|
||||
})
|
||||
h.mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
case http.MethodGet:
|
||||
h.handleListServers(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
@@ -99,15 +156,14 @@ func newAbraHandler() *abraHandler {
|
||||
})
|
||||
h.mux.HandleFunc("/api/catalogue", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
case http.MethodGet:
|
||||
h.handleListCatalogue(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
h.mux.HandleFunc("/api/apps/{appId}/new", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleNewApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
@@ -115,7 +171,7 @@ func newAbraHandler() *abraHandler {
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handlePostConfig(w, r, r.PathValue("appId"))
|
||||
case http.MethodGet:
|
||||
@@ -124,8 +180,32 @@ func newAbraHandler() *abraHandler {
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/backup/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleBackupListApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/backup/restore", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleBackupRestoreApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/backup/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleBackupDownloadSnapshot(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleInsertAppSecret(w, r, r.PathValue("appId"))
|
||||
case http.MethodGet:
|
||||
@@ -135,7 +215,7 @@ func newAbraHandler() *abraHandler {
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/services", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleGetAppServices(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
@@ -143,22 +223,30 @@ func newAbraHandler() *abraHandler {
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/{serviceId}/logs", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method{
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleGetLogs(w, r, r.PathValue("appId"), r.PathValue("serviceId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/versions", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleListVersions(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
return h
|
||||
}
|
||||
func StartAPI() {
|
||||
/*http.HandleFunc("/ws", wsHandler)
|
||||
fmt.Println("WebSocket server started on :3001")
|
||||
err := http.ListenAndServe(":3001", nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error starting server:", err)
|
||||
}*/
|
||||
fmt.Println("WebSocket server started on :3001")
|
||||
err := http.ListenAndServe(":3001", nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error starting server:", err)
|
||||
}*/
|
||||
|
||||
h := newAbraHandler()
|
||||
fmt.Println("Server started on port 3000")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"coopcloud.tech/abra/pkg/i18n"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func CopyFromContainer(cl *dockerClient.Client, containerID, srcPath string) (io.ReadCloser, int64, error) {
|
||||
log.Printf("%v %v", containerID, srcPath)
|
||||
content, stat, err := cl.CopyFromContainer(context.Background(), containerID, srcPath)
|
||||
if err != nil {
|
||||
return nil, 0, errors.New(i18n.G("copy: %s", err))
|
||||
}
|
||||
return content, stat.Size, nil
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
// used for replacing abra code
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/i18n"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
"github.com/docker/docker/api/types"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
)
|
||||
|
||||
func (h *abraHandler) pingBackupBot(w http.ResponseWriter, r *http.Request, server string) {
|
||||
cl, err := client.New(server)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
_, err = RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
func (h *abraHandler) handleBackupCreateApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("Handling App Backup!")
|
||||
args := []string{}
|
||||
|
||||
_, err := handleBackupCommand(appName, args, "create")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
log.Printf("Finishing app backup!")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleBackupRestoreApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("Handling Backup Restore!")
|
||||
|
||||
args := []string{}
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields()
|
||||
|
||||
body := struct {
|
||||
Snapshot *string `json:"snapshot"`
|
||||
}{}
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
log.Printf("error: %v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
if body.Snapshot != nil {
|
||||
args = append(args, fmt.Sprintf("SNAPSHOT=%s", *body.Snapshot))
|
||||
}
|
||||
args = append(args, fmt.Sprintf("NONINTERACTIVE=%v", true))
|
||||
_, err = handleBackupCommand(appName, args, "restore")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
log.Printf("Finishing app restore!")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleBackupListApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("Handling App List Backup!")
|
||||
args := []string{}
|
||||
output, err := handleBackupCommand(appName, args, "snapshots")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
snapshots := strings.Split(output, "\n")
|
||||
resp := []AppSnapshot{}
|
||||
for _, snapshot := range snapshots {
|
||||
items := strings.Split(snapshot, " ")
|
||||
if len(items) < 3 {
|
||||
break
|
||||
}
|
||||
resp = append(resp, AppSnapshot{
|
||||
CreatedAt: items[0],
|
||||
Snapshot: items[1],
|
||||
Version: items[2],
|
||||
})
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
func (h *abraHandler) handleBackupDownloadSnapshot(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("Handling Snapshot Download!")
|
||||
app, err := app.Get(appName)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
targetContainer, err := RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
// TODO: add args
|
||||
_, err = RunBackupCmdRemote(cl, "download", targetContainer.ID, execEnv)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
remoteBackupDir := "/tmp/backup.tar.gz"
|
||||
content, size, err := CopyFromContainer(cl, targetContainer.ID, remoteBackupDir)
|
||||
if err != nil {
|
||||
InternalServerError(w, fmt.Errorf("error copying from container: %v", err))
|
||||
}
|
||||
defer content.Close()
|
||||
|
||||
w.Header().Set("Content-type", "application/x-tar")
|
||||
tr := tar.NewReader(content)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
if filepath.Base(header.Name) == "backup.tar.gz" {
|
||||
w.Header().Set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="backup.tar.gz"`,
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set(
|
||||
"Content-Length",
|
||||
strconv.FormatInt(size, 10),
|
||||
)
|
||||
if _, err := io.Copy(w, tr); err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleBackupCommand(appName string, args []string, action string) (string, error) {
|
||||
app, err := app.Get(appName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetContainer, err := RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
for _, arg := range args {
|
||||
execEnv = append(execEnv, arg)
|
||||
}
|
||||
output, err := RunBackupCmdRemote(cl, action, targetContainer.ID, execEnv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// copied from https://git.coopcloud.tech/toolshed/abra/src/branch/main/cli/internal/backup.go
|
||||
// tty is set to false
|
||||
func RunBackupCmdRemote(
|
||||
cl *dockerClient.Client,
|
||||
backupCmd string,
|
||||
containerID string,
|
||||
execEnv []string) (string, error) {
|
||||
execBackupListOpts := containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"/usr/bin/backup", "--", backupCmd},
|
||||
Env: execEnv,
|
||||
Tty: false,
|
||||
}
|
||||
|
||||
stdout, err := Exec(context.Background(), cl, containerID, &execBackupListOpts)
|
||||
log.Printf("stdout: %v | err: %v", stdout, err)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stdout, nil
|
||||
}
|
||||
|
||||
func RetrieveBackupBotContainer(cl *dockerClient.Client) (types.Container, error) {
|
||||
ctx := context.Background()
|
||||
chosenService, err := service.GetServiceByLabel(ctx, cl, config.BackupbotLabel, true)
|
||||
if err != nil {
|
||||
return types.Container{}, errors.New(i18n.G("no backupbot discovered, is it deployed?"))
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", chosenService.Spec.Name)
|
||||
targetContainer, err := containerPkg.GetContainer(
|
||||
ctx,
|
||||
cl,
|
||||
filters,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return types.Container{}, err
|
||||
}
|
||||
|
||||
return targetContainer, nil
|
||||
}
|
||||
|
||||
func Exec(ctx context.Context, cli *dockerClient.Client, containerID string, execOptions *container.ExecOptions) (string, error) {
|
||||
execResp, err := cli.ContainerExecCreate(ctx, containerID, *execOptions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := cli.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{
|
||||
Tty: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
_, err = stdcopy.StdCopy(&stdout, &stderr, resp.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
inspect, err := cli.ContainerExecInspect(ctx, execResp.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if inspect.ExitCode != 0 {
|
||||
return stdout.String(),
|
||||
fmt.Errorf("command exited with status %d and error %s", inspect.ExitCode, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
+3
-6
@@ -1,9 +1,8 @@
|
||||
package cli
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"log"
|
||||
"maps"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
@@ -20,12 +19,10 @@ func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request
|
||||
|
||||
jsonBytes, err := json.Marshal(vals)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
}
|
||||
+3
-4
@@ -46,7 +46,7 @@ func (h *abraHandler) handleGetConfig(w http.ResponseWriter, r *http.Request, ap
|
||||
func (h *abraHandler) handlePostConfig(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
files, err := appPkg.LoadAppFiles("")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,9 +61,8 @@ func (h *abraHandler) handlePostConfig(w http.ResponseWriter, r *http.Request, a
|
||||
var config FileResponse
|
||||
err = d.Decode(&config)
|
||||
if err != nil {
|
||||
log.Printf("err: %s", err);
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(appFile.Path, []byte(config.Content), 0644)
|
||||
|
||||
+44
-44
@@ -1,69 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"coop-cloud-backend/internal"
|
||||
"coop-cloud-backend/cli/status"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"context"
|
||||
"coop-cloud-backend/cli/status"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
//"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"coopcloud.tech/abra/pkg/ui"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
dclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/api/types"
|
||||
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
|
||||
//"github.com/gorilla/websocket"
|
||||
)
|
||||
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
|
||||
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string, chaos bool) {
|
||||
log.Printf("Handling App Deploy!")
|
||||
args := []string{"app", "deploy", appName, "-n", "-c"}
|
||||
if internal.Chaos {
|
||||
if chaos {
|
||||
args = append(args, "-C")
|
||||
}
|
||||
cmd := exec.Command("abra", args...)
|
||||
output, err := cmd.Output()
|
||||
output, err := h.cmd.Run("abra", args...)
|
||||
if err != nil {
|
||||
log.Printf("Error: ", string(output))
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, errors.New(fmt.Sprintf("Error: %s", string(output))))
|
||||
return
|
||||
}
|
||||
log.Printf("Finishing app deploy!")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
output, err := h.cmd.Run("abra", "app", "undeploy", appName, "-n")
|
||||
if err != nil {
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("Get deploy logs!")
|
||||
app, err := appPkg.Get(appName)
|
||||
app, err := appPkg.Get(appName)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
// Implement my own WaitOnServices in order to wrap ui.Model in order to emit JSON updates
|
||||
// over Websocket connection
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
// STEP 1: collect information about app being deployed
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,8 +76,7 @@ func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appN
|
||||
log.Printf("stack name: %s | namespace: %s", stackName, namespace.Name())
|
||||
existingServices, err := GetStackServices(context.Background(), cl, namespace.Name())
|
||||
if err != nil {
|
||||
log.Printf("Error: %s\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,38 +84,37 @@ func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appN
|
||||
log.Printf("existingServices contains: %s", service.Spec.Name)
|
||||
serviceIDs = append(serviceIDs, ui.ServiceMeta{
|
||||
Name: service.Spec.Name,
|
||||
ID: service.ID,
|
||||
ID: service.ID,
|
||||
})
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
log.Printf("Waiting on service...")
|
||||
|
||||
stream := make (chan status.StreamEvent, 50)
|
||||
|
||||
stream := make(chan status.StreamEvent, 50)
|
||||
go status.WaitOnServices(ctx, cl, serviceIDs, f, stream)
|
||||
for {
|
||||
select{
|
||||
case <- ctx.Done():
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("deploy cancelled or done")
|
||||
return
|
||||
case msg, ok := <- stream:
|
||||
case msg, ok := <-stream:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
test, err := json.MarshalIndent(msg, "", " ")
|
||||
fmt.Printf(string(test), err);
|
||||
fmt.Printf(string(test), err)
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
// TODO: send error through onerror handler
|
||||
log.Printf("error?: %s", err)
|
||||
OnError(err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
@@ -124,7 +125,6 @@ func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appN
|
||||
|
||||
}
|
||||
|
||||
|
||||
func getStackFilter(namespace string) filters.Args {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", convert.LabelNamespace+"="+namespace)
|
||||
@@ -136,4 +136,4 @@ func getStackServiceFilter(namespace string) filters.Args {
|
||||
|
||||
func GetStackServices(ctx context.Context, dockerclient dclient.APIClient, namespace string) ([]swarm.Service, error) {
|
||||
return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: getStackServiceFilter(namespace)})
|
||||
}
|
||||
}
|
||||
|
||||
+14
-7
@@ -1,13 +1,20 @@
|
||||
package cli
|
||||
import (
|
||||
"net/http"
|
||||
"net/http"
|
||||
"log"
|
||||
)
|
||||
func InternalServerErrorHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("500 Internal Server Error"))
|
||||
func OnError(err error) {
|
||||
// handle errors that continue
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
func InternalServerError(w http.ResponseWriter, err error) {
|
||||
log.Printf("Error: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404 Not Found"))
|
||||
func NotFound(w http.ResponseWriter, err error) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
func BadRequest(w http.ResponseWriter, err error) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AppPackage interface {
|
||||
GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error)
|
||||
GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error)
|
||||
StackName(app appPkg.App) string
|
||||
}
|
||||
|
||||
type AbraAppPackage struct{}
|
||||
|
||||
func (a *AbraAppPackage) GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error) {
|
||||
appFiles, err := appPkg.LoadAppFiles(servers...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apps, err := appPkg.GetApps(appFiles, recipeFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (a *AbraAppPackage) GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error) {
|
||||
return appPkg.GetAppStatuses(apps, MachineReadable)
|
||||
}
|
||||
|
||||
func (a *AbraAppPackage) StackName(app appPkg.App) string {
|
||||
return app.StackName()
|
||||
}
|
||||
|
||||
type MockAppPackage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (a *MockAppPackage) GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error) {
|
||||
callArgs := []interface{}{recipeFilter}
|
||||
for _, arg := range servers {
|
||||
callArgs = append(callArgs, arg)
|
||||
}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).([]appPkg.App), args.Error(1)
|
||||
|
||||
}
|
||||
|
||||
func (a *MockAppPackage) GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error) {
|
||||
callArgs := []interface{}{}
|
||||
for _, arg := range apps {
|
||||
callArgs = append(callArgs, arg)
|
||||
}
|
||||
callArgs = append(callArgs, MachineReadable)
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).(map[string]map[string]string), args.Error(1)
|
||||
}
|
||||
|
||||
func (a *MockAppPackage) StackName(app appPkg.App) string {
|
||||
callArgs := []interface{}{app}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).(string)
|
||||
}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type DockerClient interface {
|
||||
GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error)
|
||||
}
|
||||
type AbraDockerClient struct{}
|
||||
|
||||
func (a *AbraDockerClient) GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error) {
|
||||
return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: a.getStackServiceFilter(namespace)})
|
||||
}
|
||||
|
||||
func (a *AbraDockerClient) getStackFilter(namespace string) filters.Args {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", convert.LabelNamespace+"="+namespace)
|
||||
return filter
|
||||
}
|
||||
|
||||
func (a *AbraDockerClient) getStackServiceFilter(namespace string) filters.Args {
|
||||
return a.getStackFilter(namespace)
|
||||
}
|
||||
|
||||
type MockDockerClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (a *MockDockerClient) GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error) {
|
||||
callArgs := []interface{}{ctx}
|
||||
callArgs = append(callArgs, dockerclient)
|
||||
callArgs = append(callArgs, namespace)
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).([]swarm.Service), args.Error(1)
|
||||
}
|
||||
|
||||
func (a *MockDockerClient) getStackFilter(namespace string) filters.Args {
|
||||
callArgs := []interface{}{namespace}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).(filters.Args)
|
||||
}
|
||||
|
||||
func (a *MockDockerClient) getStackServiceFilter(namespace string) filters.Args {
|
||||
callArgs := []interface{}{namespace}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).(filters.Args)
|
||||
}
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type RecipePackage interface {
|
||||
EnsureExists(appPkg.App) error
|
||||
Tags(appPkg.App) ([]string, error)
|
||||
}
|
||||
|
||||
type AbraRecipePackage struct{}
|
||||
|
||||
func (a *AbraRecipePackage) EnsureExists(app appPkg.App) error {
|
||||
return app.Recipe.EnsureExists()
|
||||
}
|
||||
func (a *AbraRecipePackage) Tags(app appPkg.App) ([]string, error) {
|
||||
return app.Recipe.Tags()
|
||||
}
|
||||
|
||||
type MockRecipePackage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (a *MockRecipePackage) EnsureExists(app appPkg.App) error {
|
||||
callArgs := []interface{}{app}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (a *MockRecipePackage) Tags(app appPkg.App) ([]string, error) {
|
||||
callArgs := []interface{}{app}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
Vendored
+72
@@ -0,0 +1,72 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type ServerPackage interface {
|
||||
GetReachableServers(apps []appPkg.App) (map[string]bool, error)
|
||||
}
|
||||
|
||||
type AbraServerPackage struct{}
|
||||
|
||||
func (a *AbraServerPackage) GetReachableServers(apps []appPkg.App) (map[string]bool, error) {
|
||||
// servers that are unreachable..
|
||||
reachable := make(map[string]bool)
|
||||
servers, err := config.ReadServerNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
reachableCh := make(chan string)
|
||||
//errCh := make(chan error)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(servers))
|
||||
|
||||
for _, server := range servers {
|
||||
// check if its unreachable..
|
||||
// this connection is retried, so takes 10 seconds per connection
|
||||
// TODO: figure out a way to make this faster
|
||||
// this is retried every server and connection so it really should be quicker
|
||||
go func(server string) {
|
||||
defer wg.Done()
|
||||
timeout := client.WithTimeout(3)
|
||||
if _, err := client.New(server, timeout); err != nil {
|
||||
//errCh <- err
|
||||
} else {
|
||||
reachableCh <- server
|
||||
}
|
||||
}(server)
|
||||
}
|
||||
go func() {
|
||||
for server := range reachableCh {
|
||||
reachable[server] = true
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("Checking server availability took %s", elapsed)
|
||||
return reachable, nil
|
||||
}
|
||||
|
||||
type MockServerPackage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (a *MockServerPackage) GetReachableServers(apps []appPkg.App) (map[string]bool, error) {
|
||||
callArgs := []interface{}{}
|
||||
for _, arg := range apps {
|
||||
callArgs = append(callArgs, arg)
|
||||
}
|
||||
args := a.Called(callArgs...)
|
||||
return args.Get(0).(map[string]bool), args.Error(1)
|
||||
}
|
||||
+84
-43
@@ -1,22 +1,24 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/tagcmp"
|
||||
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) {
|
||||
cmd := exec.Command("abra", "app", "ls", "-m")
|
||||
output, err := cmd.CombinedOutput()
|
||||
func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) {
|
||||
output, err := h.cmd.Run("abra", "app", "ls", "-m")
|
||||
if err != nil {
|
||||
log.Printf("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
var resp ServerAppsResponse
|
||||
@@ -25,33 +27,36 @@ import (
|
||||
recipeFilter := ""
|
||||
|
||||
if err := json.Unmarshal(output, &resp); err != nil {
|
||||
http.Error(w, "invalid JSON from CLI", http.StatusInternalServerError)
|
||||
InternalServerError(w, fmt.Errorf("invalid JSON from CLI"))
|
||||
return
|
||||
}
|
||||
|
||||
appFiles, err := appPkg.LoadAppFiles(listAppServer)
|
||||
// Get abra apps from remote
|
||||
apps, err := h.ap.GetApps(recipeFilter, listAppServer)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
// Returns map of all servers that are reachable/don't time out
|
||||
reachable, err := h.sp.GetReachableServers(apps)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
apps, err := appPkg.GetApps(appFiles, recipeFilter)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("Checking server availability took %s", elapsed)
|
||||
// TODO: I think this will check every server for every app, need to remove some first.
|
||||
// rapps is all reachable apps
|
||||
rapps := lo.Filter(apps, func(app appPkg.App, index int) bool {
|
||||
_, ok := reachable[app.Server]
|
||||
return ok
|
||||
})
|
||||
statuses := make(map[string]map[string]string)
|
||||
alreadySeen := make(map[string]bool)
|
||||
|
||||
for _, app := range apps {
|
||||
if _, ok := alreadySeen[app.Server]; !ok {
|
||||
alreadySeen[app.Server] = true
|
||||
}
|
||||
}
|
||||
|
||||
statuses, err = appPkg.GetAppStatuses(apps, true)
|
||||
statuses, err = h.ap.GetAppStatuses(rapps, true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
// TODO: if a server is unreachable, skip its apps, maybe flag something?
|
||||
for _, app := range apps {
|
||||
var stats ServerApps
|
||||
|
||||
@@ -62,12 +67,28 @@ import (
|
||||
appStats = &sapp
|
||||
}
|
||||
}
|
||||
|
||||
_, ok := reachable[app.Server]
|
||||
if !ok {
|
||||
appStats.Chaos = "unknown"
|
||||
appStats.ChaosVersion = "unknown"
|
||||
appStats.Version = "unknown"
|
||||
appStats.Status = "Server unreachable"
|
||||
for i, sapp := range stats.Apps {
|
||||
if sapp.AppName == app.Name {
|
||||
stats.Apps[i] = *appStats
|
||||
}
|
||||
}
|
||||
resp[app.Server] = stats
|
||||
continue
|
||||
}
|
||||
if app.Recipe.Name == "backup-bot-two" {
|
||||
stats.BackupEnabled = true
|
||||
}
|
||||
status := "unknown"
|
||||
version := "unknown"
|
||||
chaos := "unknown"
|
||||
chaosVersion := "unknown"
|
||||
if statusMeta, ok := statuses[app.StackName()]; ok {
|
||||
if statusMeta, ok := statuses[h.ap.StackName(app)]; ok {
|
||||
if currentVersion, exists := statusMeta["version"]; exists {
|
||||
if currentVersion != "" {
|
||||
version = currentVersion
|
||||
@@ -86,6 +107,7 @@ import (
|
||||
} else {
|
||||
stats.UnversionedCount++
|
||||
}
|
||||
stats.AppCount++
|
||||
|
||||
appStats.Status = status
|
||||
appStats.Chaos = chaos
|
||||
@@ -95,13 +117,12 @@ import (
|
||||
|
||||
var newUpdates []string
|
||||
if version != "unknown" && chaos == "false" {
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Printf("unable to clone %s: %s", app.Name, err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
if err := h.rp.EnsureExists(app); err != nil {
|
||||
InternalServerError(w, fmt.Errorf("unable to clone %s: %s", app.Name, err))
|
||||
return
|
||||
}
|
||||
|
||||
updates, err := app.Recipe.Tags()
|
||||
updates, err := h.rp.Tags(app)
|
||||
if err != nil {
|
||||
localApp = false
|
||||
} else {
|
||||
@@ -131,7 +152,7 @@ import (
|
||||
if len(newUpdates) == 0 {
|
||||
if version == "unknown" {
|
||||
appStats.Upgrade = "unknown"
|
||||
} else if localApp {
|
||||
} else if localApp {
|
||||
appStats.Upgrade = "latest"
|
||||
stats.LatestCount++
|
||||
} else {
|
||||
@@ -142,7 +163,7 @@ import (
|
||||
appStats.Upgrade = strings.Join(newUpdates, "\n")
|
||||
stats.UpgradeCount++
|
||||
}
|
||||
|
||||
|
||||
for i, sapp := range stats.Apps {
|
||||
if sapp.AppName == app.Name {
|
||||
stats.Apps[i] = *appStats
|
||||
@@ -152,20 +173,17 @@ import (
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, fmt.Errorf("JSON conversion failed: %s\n", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request) {
|
||||
cmd := exec.Command("abra", "server", "ls", "-m")
|
||||
abraServers, err := cmd.CombinedOutput()
|
||||
func (h *abraHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
|
||||
abraServers, err := h.cmd.Run("abra", "server", "ls", "-m")
|
||||
if err != nil {
|
||||
log.Printf("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -173,6 +191,29 @@ func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request)
|
||||
w.Write(abraServers)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleListVersions(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
app, err := appPkg.Get(appName)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
versions, err := app.Recipe.Tags()
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(versions)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("%v", versions)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
func SortVersionsDesc(versions []string) []string {
|
||||
var tags []tagcmp.Tag
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"coop-cloud-backend/cli/external"
|
||||
"coop-cloud-backend/data"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func ToAny[T any](s []T) []any {
|
||||
out := make([]any, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The mock call flow is
|
||||
// h.cmd.Run -> h.ap.getApps -> h.ap.GetReachableServers -> h.ap.GetAppStatuses
|
||||
// -(for each reachable app)> h.ap.StackName(app)
|
||||
// -(reachable apps with version != "unknown" && chaos == "false")> h.rp.EnsureExists(app) and h.rp.Tags(app)
|
||||
func TestListApps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
// these are all of our "external" package function calls
|
||||
getAppsArgs []string
|
||||
getAppsOut []appPkg.App
|
||||
getAppsErr error
|
||||
getServsArgs []appPkg.App
|
||||
getServsOut map[string]bool
|
||||
getServsErr error
|
||||
getStatusesArgs []interface{}
|
||||
getStatusesOut map[string]map[string]string
|
||||
getStatusesErr error
|
||||
stackNameArgs []appPkg.App
|
||||
stackNameOut []string
|
||||
// these are arrays for all apps
|
||||
ensureArgs []appPkg.App
|
||||
ensureErrs []error
|
||||
tagsArgs []appPkg.App
|
||||
tagsOuts [][]string
|
||||
tagsErrs []error
|
||||
// these are the details for h.cmd.Run's output. Luckily the input isalways fixed
|
||||
reqBody string
|
||||
runOutput []byte
|
||||
runErr error
|
||||
// this is the final information i guess, but i need the final json thingy too
|
||||
expectedBody ServerAppsResponse
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "success one simple app",
|
||||
getAppsArgs: []string{"", ""},
|
||||
getAppsOut: []appPkg.App{data.App()},
|
||||
getServsArgs: []appPkg.App{data.App()},
|
||||
getServsOut: map[string]bool{"example.com": true},
|
||||
getStatusesArgs: []interface{}{data.App(), true},
|
||||
getStatusesOut: data.AppStatus(),
|
||||
stackNameArgs: []appPkg.App{data.App()},
|
||||
stackNameOut: []string{"nextcloud"},
|
||||
ensureArgs: []appPkg.App{data.App()},
|
||||
ensureErrs: []error{nil},
|
||||
tagsArgs: []appPkg.App{data.App()},
|
||||
tagsOuts: [][]string{{"1.2.0"}},
|
||||
tagsErrs: []error{nil},
|
||||
|
||||
runOutput: []byte(`
|
||||
{
|
||||
"example.com": {
|
||||
"apps": [{
|
||||
"server": "example.com",
|
||||
"recipe": "nextcloud",
|
||||
"appName": "nextcloud.example.com",
|
||||
"domain": "nextcloud.example.com",
|
||||
"status": "",
|
||||
"chaos": "",
|
||||
"chaosVersion": "",
|
||||
"version": "",
|
||||
"upgrade": ""
|
||||
}]
|
||||
}
|
||||
}
|
||||
`),
|
||||
expectedCode: http.StatusOK,
|
||||
expectedBody: ServerAppsResponse{
|
||||
"example.com": {
|
||||
Apps: []AbraApp{AbraApp{
|
||||
Server: "example.com",
|
||||
Recipe: "nextcloud",
|
||||
AppName: "nextcloud.example.com",
|
||||
Domain: "nextcloud.example.com",
|
||||
Status: "running",
|
||||
Chaos: "false",
|
||||
ChaosVersion: "unknown",
|
||||
Version: "1.0.0",
|
||||
Upgrade: "1.2.0",
|
||||
}},
|
||||
AppCount: 1,
|
||||
VersionCount: 1,
|
||||
UnversionedCount: 0,
|
||||
LatestCount: 0,
|
||||
UpgradeCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
mockAppPkg := new(external.MockAppPackage)
|
||||
mockServerPkg := new(external.MockServerPackage)
|
||||
mockRecipePkg := new(external.MockRecipePackage)
|
||||
|
||||
mockCmd.
|
||||
On("Run", "abra", "app", "ls", "-m").
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
mockAppPkg.
|
||||
On("GetApps", ToAny(tt.getAppsArgs)...).
|
||||
Return(tt.getAppsOut, tt.getAppsErr)
|
||||
|
||||
mockServerPkg.
|
||||
On("GetReachableServers", ToAny(tt.getServsArgs)...).
|
||||
Return(tt.getServsOut, tt.getServsErr)
|
||||
|
||||
mockAppPkg.
|
||||
On("GetAppStatuses", tt.getStatusesArgs...).
|
||||
Return(tt.getStatusesOut, tt.getStatusesErr)
|
||||
|
||||
for i := range tt.stackNameArgs {
|
||||
mockAppPkg.
|
||||
On("StackName", []interface{}{tt.stackNameArgs[i]}...).
|
||||
Return(tt.stackNameOut[i])
|
||||
}
|
||||
|
||||
for i, ensureArg := range tt.ensureArgs {
|
||||
mockRecipePkg.
|
||||
On("EnsureExists", ensureArg).
|
||||
Return(tt.ensureErrs[i])
|
||||
log.Printf("tag output %v", tt.tagsOuts[i])
|
||||
mockRecipePkg.
|
||||
On("Tags", tt.tagsArgs[i]).
|
||||
Return(tt.tagsOuts[i], tt.tagsErrs[i])
|
||||
}
|
||||
|
||||
h := &abraHandler{cmd: mockCmd, ap: mockAppPkg, sp: mockServerPkg, rp: mockRecipePkg}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleListApps(rec, req)
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
var got ServerAppsResponse
|
||||
if assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) {
|
||||
assert.Equal(t, tt.expectedBody, got)
|
||||
}
|
||||
|
||||
// may need to have "expectedRun" flags later
|
||||
|
||||
mockCmd.AssertExpectations(t)
|
||||
mockAppPkg.AssertExpectations(t)
|
||||
mockServerPkg.AssertExpectations(t)
|
||||
mockRecipePkg.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -16,36 +16,36 @@ import (
|
||||
func (h *abraHandler) handleGetLogs(w http.ResponseWriter, r *http.Request, appName string, serviceName string) {
|
||||
app, err := appPkg.Get(appName)
|
||||
if err != nil {
|
||||
log.Printf("error: %s", err)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Printf(fmt.Sprintf("%s is not deployed?", app.Name))
|
||||
log.Printf("%s is not deployed?", app.Name)
|
||||
return
|
||||
}
|
||||
serviceNames := []string{serviceName}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -54,7 +54,7 @@ func (h *abraHandler) handleGetLogs(w http.ResponseWriter, r *http.Request, appN
|
||||
types.ServiceListOptions{Filters: f},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+50
-42
@@ -1,69 +1,77 @@
|
||||
package cli
|
||||
|
||||
// Represents an App, follows the format of
|
||||
// Represents an App, follows the format of
|
||||
// https://git.coopcloud.tech/toolshed/coop-cloud-front
|
||||
type AbraApp struct {
|
||||
Server string `json:"server"`
|
||||
Recipe string `json:"recipe`
|
||||
AppName string `json:"appName"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
Chaos string `json:"chaos"`
|
||||
ChaosVersion string `json:"chaosVersion"`
|
||||
Version string `json:"version"`
|
||||
Upgrade string `json:"upgrade"`
|
||||
Server string `json:"server"`
|
||||
Recipe string `json:"recipe`
|
||||
AppName string `json:"appName"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
Chaos string `json:"chaos"`
|
||||
ChaosVersion string `json:"chaosVersion"`
|
||||
Version string `json:"version"`
|
||||
Upgrade string `json:"upgrade"`
|
||||
}
|
||||
|
||||
type AbraServer struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type ServerAppsResponse map[string]ServerApps
|
||||
|
||||
type ServerApps struct {
|
||||
Apps []AbraApp `json:"apps"`
|
||||
AppCount int `json:"appCount"`
|
||||
VersionCount int `json:"versionCount"`
|
||||
UnversionedCount int `json:"unversionedCount"`
|
||||
LatestCount int `json:"latestCount"`
|
||||
UpgradeCount int `json:"upgradeCount"`
|
||||
Apps []AbraApp `json:"apps"`
|
||||
AppCount int `json:"appCount"`
|
||||
VersionCount int `json:"versionCount"`
|
||||
UnversionedCount int `json:"unversionedCount"`
|
||||
LatestCount int `json:"latestCount"`
|
||||
UpgradeCount int `json:"upgradeCount"`
|
||||
BackupEnabled bool `json:"backupEnabled"`
|
||||
}
|
||||
|
||||
type DeployState struct {
|
||||
AppName string `json:"appName"`
|
||||
AppName string `json:"appName"`
|
||||
Streams []DeployStream `json:"streams"`
|
||||
Logs []string `json:"logs"`
|
||||
Failed bool `json:"failed"`
|
||||
TimedOut bool `json:"timedOut"`
|
||||
Quit bool `json:"quit"`
|
||||
Count int `json:"count"`
|
||||
Logs []string `json:"logs"`
|
||||
Failed bool `json:"failed"`
|
||||
TimedOut bool `json:"timedOut"`
|
||||
Quit bool `json:"quit"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DeployStream struct {
|
||||
Name string `json:"Name"`
|
||||
id string `json:"id"`
|
||||
status string `json:"status"`
|
||||
retries int `json:"retries"`
|
||||
health string `json:"health"`
|
||||
rollback bool `json:"rollback"`
|
||||
}
|
||||
Name string `json:"Name"`
|
||||
id string `json:"id"`
|
||||
status string `json:"status"`
|
||||
retries int `json:"retries"`
|
||||
health string `json:"health"`
|
||||
rollback bool `json:"rollback"`
|
||||
}
|
||||
|
||||
type AppSecret struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Value string `json:"value"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
type AbraAppService struct {
|
||||
Service string `json:"service"`
|
||||
Chaos bool `json:"chaos"`
|
||||
Created string `json:"created"`
|
||||
Image string `json:"image"`
|
||||
Ports string `json:"ports"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Service string `json:"service"`
|
||||
Chaos bool `json:"chaos"`
|
||||
Created string `json:"created"`
|
||||
Image string `json:"image"`
|
||||
Ports string `json:"ports"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AppSnapshot struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Snapshot string `json:"snapshot"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// type RecipeMeta struct {
|
||||
|
||||
+31
-27
@@ -1,15 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
@@ -19,23 +19,24 @@ func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appNa
|
||||
|
||||
// anonymous struct type: handy for one-time use
|
||||
body := struct {
|
||||
Domain *string `json:"domain"`
|
||||
Server *string `json:"server"`
|
||||
Chaos *bool `json:"chaos"`
|
||||
Secrets *bool `json:"secrets"`
|
||||
Domain *string `json:"domain"`
|
||||
Server *string `json:"server"`
|
||||
Chaos *bool `json:"chaos"`
|
||||
Secrets *bool `json:"secrets"`
|
||||
}{}
|
||||
|
||||
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
// bad JSON or unrecognized json field
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("%v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
if body.Domain == nil {
|
||||
if body.Domain == nil {
|
||||
http.Error(w, "missing field 'domain' from JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Server == nil {
|
||||
if body.Server == nil {
|
||||
http.Error(w, "missing field 'server' from JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -45,33 +46,31 @@ func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appNa
|
||||
args = append(args, "-C")
|
||||
}
|
||||
log.Printf("%v", args)
|
||||
cmd := exec.Command("abra", args...)
|
||||
output, err := cmd.Output()
|
||||
output, err := h.cmd.Run("abra", args...)
|
||||
if err != nil {
|
||||
log.Printf("Error: ", string(output))
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
var fs []AppSecret
|
||||
if body.Secrets != nil && *body.Secrets == true {
|
||||
appSecrets, err := createSecrets(appName, *body.Domain, *body.Server)
|
||||
log.Printf("testing: %v %v", *body.Domain, *body.Server)
|
||||
appSecrets, err := h.secret.createSecrets(appName, *body.Domain, *body.Server)
|
||||
if err != nil {
|
||||
log.Printf("Error creating secrets: %s", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
for k, v := range appSecrets {
|
||||
fs = append(fs, AppSecret{
|
||||
Name: k,
|
||||
Value: v,
|
||||
Name: k,
|
||||
Value: v,
|
||||
Version: "v1",
|
||||
Created: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
jsonBytes, err := json.Marshal(fs)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -79,14 +78,19 @@ func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appNa
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func createSecrets(appName string, domain string, server string) (map[string]string, error) {
|
||||
type SecretUtilsWrapper interface {
|
||||
createSecrets(appName string, domain string, server string) (map[string]string, error)
|
||||
}
|
||||
|
||||
type SecretUtils struct{}
|
||||
|
||||
func (s *SecretUtils) createSecrets(appName string, domain string, server string) (map[string]string, error) {
|
||||
cl, err := client.New(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipe := recipe.Get(appName)
|
||||
|
||||
|
||||
sampleEnv, err := recipe.SampleEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -112,4 +116,4 @@ func createSecrets(appName string, domain string, server string) (map[string]str
|
||||
return nil, err
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockSecretUtils struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (su *MockSecretUtils) createSecrets(appName string, domain string, server string) (map[string]string, error) {
|
||||
args := su.Called(appName, domain, server)
|
||||
return args.Get(0).(map[string]string), args.Error(1)
|
||||
|
||||
}
|
||||
func TestHandleNewApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reqBody string
|
||||
runOutput []byte
|
||||
runErr error
|
||||
runSecrets bool
|
||||
expectedSecretArgs []string
|
||||
secretOutput map[string]string
|
||||
secretErr error
|
||||
expectedArgs []string
|
||||
expectedRun bool
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "success no chaos no secrets",
|
||||
reqBody: `{
|
||||
"domain": "app.xyz",
|
||||
"server": "server.xyz",
|
||||
"chaos": false,
|
||||
"secrets": false
|
||||
}`,
|
||||
runOutput: []byte(nil),
|
||||
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz"},
|
||||
expectedRun: true,
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "success with chaos no secrets",
|
||||
reqBody: `{
|
||||
"domain": "app.xyz",
|
||||
"server": "server.xyz",
|
||||
"chaos": true,
|
||||
"secrets": false
|
||||
}`,
|
||||
runOutput: []byte(nil),
|
||||
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz", "-C"},
|
||||
expectedRun: true,
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "success no chaos with secrets",
|
||||
reqBody: `{
|
||||
"domain": "app.xyz",
|
||||
"server": "server.xyz",
|
||||
"chaos": false,
|
||||
"secrets": true
|
||||
}`,
|
||||
runSecrets: true,
|
||||
expectedSecretArgs: []string{"myapp", "app.xyz", "server.xyz"},
|
||||
secretOutput: map[string]string{
|
||||
"name": "secret",
|
||||
"value": "secret-value",
|
||||
},
|
||||
runOutput: []byte(nil),
|
||||
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz"},
|
||||
expectedRun: true,
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
mockSecrets := new(MockSecretUtils)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
secretArgs := []interface{}{}
|
||||
for _, arg := range tt.expectedSecretArgs {
|
||||
secretArgs = append(secretArgs, arg)
|
||||
}
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
if tt.runSecrets {
|
||||
mockSecrets.
|
||||
On("createSecrets", secretArgs...).
|
||||
Return(tt.secretOutput, tt.secretErr)
|
||||
}
|
||||
|
||||
h := &abraHandler{cmd: mockCmd, secret: mockSecrets}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleNewApp(rec, req, "myapp")
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
|
||||
if tt.expectedRun {
|
||||
mockCmd.AssertExpectations(t)
|
||||
}
|
||||
if tt.runSecrets {
|
||||
mockSecrets.AssertExpectations(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -1,35 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"log"
|
||||
"sort"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
func (h *abraHandler) handleGetAppServices (w http.ResponseWriter, r *http.Request, appName string) {
|
||||
|
||||
func (h *abraHandler) handleGetAppServices(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
var tmpMsg map[string]map[string]string
|
||||
cmd := exec.Command("abra", "app", "ps", appName, "-m")
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := h.cmd.Run("abra", "app", "ps", appName, "-m")
|
||||
if err != nil {
|
||||
log.Printf("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(output, &tmpMsg); err != nil {
|
||||
log.Printf("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
var services []AbraAppService
|
||||
for _,v := range tmpMsg {
|
||||
for _, v := range tmpMsg {
|
||||
services = append(services, AbraAppService{
|
||||
Service: v["service"],
|
||||
Chaos: v["chaos"] == "true",
|
||||
Chaos: v["chaos"] == "true",
|
||||
Created: v["created"],
|
||||
Image: v["image"],
|
||||
Ports: v["ports"],
|
||||
State: v["state"],
|
||||
Status: v["status"],
|
||||
Image: v["image"],
|
||||
Ports: v["ports"],
|
||||
State: v["state"],
|
||||
Status: v["status"],
|
||||
Version: v["version"],
|
||||
})
|
||||
}
|
||||
@@ -38,11 +35,10 @@ func (h *abraHandler) handleGetAppServices (w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
jsonBytes, err := json.Marshal(services)
|
||||
if err != nil {
|
||||
log.Printf("JSON conversion failed: %s\n", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandlePing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
runOutput []byte
|
||||
runErr error
|
||||
expectedArgs []string
|
||||
expectedBody []AbraAppService
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
runOutput: []byte(`
|
||||
{"app":{
|
||||
"chaos":"false",
|
||||
"created":"3 days ago",
|
||||
"image":"image:4.1.0-22",
|
||||
"ports":"1880/tcp",
|
||||
"service":"app",
|
||||
"state":"running",
|
||||
"status":"Up 3 days (healthy)",
|
||||
"version":"1.0.0+4.1.0-22"}
|
||||
}
|
||||
`),
|
||||
expectedArgs: []string{"app", "ps", "myapp", "-m"},
|
||||
expectedBody: []AbraAppService{{
|
||||
Service: "app",
|
||||
Chaos: false,
|
||||
Created: "3 days ago",
|
||||
Image: "image:4.1.0-22",
|
||||
Ports: "1880/tcp",
|
||||
State: "running",
|
||||
Status: "Up 3 days (healthy)",
|
||||
Version: "1.0.0+4.1.0-22",
|
||||
}},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "success sorted",
|
||||
runOutput: []byte(`
|
||||
{"app":{
|
||||
"chaos":"false",
|
||||
"created":"3 days ago",
|
||||
"image":"image:4.1.0-22",
|
||||
"ports":"1880/tcp",
|
||||
"service":"app",
|
||||
"state":"running",
|
||||
"status":"Up 3 days (healthy)",
|
||||
"version":"1.0.0+4.1.0-22"
|
||||
},
|
||||
"base": {
|
||||
"chaos":"false",
|
||||
"created":"3 days ago",
|
||||
"image":"image:6.1.0",
|
||||
"ports":"1880/tcp",
|
||||
"service":"base",
|
||||
"state":"running",
|
||||
"status":"Up 3 days (healthy)",
|
||||
"version":"1.0.0+6.1.0"
|
||||
}
|
||||
}`),
|
||||
expectedArgs: []string{"app", "ps", "myapp", "-m"},
|
||||
expectedBody: []AbraAppService{{
|
||||
Service: "app",
|
||||
Chaos: false,
|
||||
Created: "3 days ago",
|
||||
Image: "image:4.1.0-22",
|
||||
Ports: "1880/tcp",
|
||||
State: "running",
|
||||
Status: "Up 3 days (healthy)",
|
||||
Version: "1.0.0+4.1.0-22",
|
||||
}, {
|
||||
Service: "base",
|
||||
Chaos: false,
|
||||
Created: "3 days ago",
|
||||
Image: "image:6.1.0",
|
||||
Ports: "1880/tcp",
|
||||
State: "running",
|
||||
Status: "Up 3 days (healthy)",
|
||||
Version: "1.0.0+6.1.0",
|
||||
}},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
h := &abraHandler{cmd: mockCmd}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleGetAppServices(rec, req, "myapp")
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
|
||||
mockCmd.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -1,15 +1,14 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleRemoveApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
cmd := exec.Command("abra", "app", "remove", appName, "-n")
|
||||
output, err := cmd.Output()
|
||||
output, err := h.cmd.Run("abra", "app", "remove", appName, "-n")
|
||||
if err != nil {
|
||||
log.Printf("Error: ", string(output))
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
+64
-24
@@ -1,21 +1,65 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleGetAppSecrets(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
cmd := exec.Command("abra", "app", "secret", "list", appName, "-m")
|
||||
secrets, err := cmd.CombinedOutput()
|
||||
log.Printf("handling secrets!")
|
||||
secrets, err := h.cmd.Run("abra", "app", "secret", "list", appName, "-m")
|
||||
if err != nil {
|
||||
log.Printf("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
log.Printf("??")
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := []struct {
|
||||
Created *string `json:"created on server"`
|
||||
GenName *string `json:"generated name"`
|
||||
Name *string `json:"name"`
|
||||
Version *string `json:"version"`
|
||||
}{}
|
||||
|
||||
err = json.Unmarshal(secrets, &resp)
|
||||
if err != nil {
|
||||
log.Printf("???")
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
fs := []AppSecret{}
|
||||
for _, secret := range resp {
|
||||
if secret.Created == nil {
|
||||
InternalServerError(w, errors.New("Failed to decode field 'created on server' from abra response"))
|
||||
return
|
||||
}
|
||||
if secret.Name == nil {
|
||||
InternalServerError(w, errors.New("Failed to decode field 'name' from abra response"))
|
||||
return
|
||||
}
|
||||
if secret.Version == nil {
|
||||
InternalServerError(w, errors.New("Failed to decode field 'version' from abra response"))
|
||||
return
|
||||
}
|
||||
fs = append(fs, AppSecret{
|
||||
Name: *secret.Name,
|
||||
Version: *secret.Version,
|
||||
Created: *secret.Created == "true",
|
||||
Value: "",
|
||||
})
|
||||
}
|
||||
log.Printf("%v", fs)
|
||||
jsonBytes, err := json.Marshal(fs)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(secrets)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleInsertAppSecret(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
@@ -24,38 +68,34 @@ func (h *abraHandler) handleInsertAppSecret(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// anonymous struct type: handy for one-time use
|
||||
body := struct {
|
||||
Name *string `json:"name"`
|
||||
Version *string `json:"version"`
|
||||
Value *string `json:"value"`
|
||||
Name *string `json:"name"`
|
||||
Version *string `json:"version"`
|
||||
Value *string `json:"value"`
|
||||
}{}
|
||||
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
log.Printf("???\n")
|
||||
// bad JSON or unrecognized json field
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("%v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if body.Name == nil {
|
||||
http.Error(w, "missing field 'name' from JSON object", http.StatusBadRequest)
|
||||
if body.Name == nil {
|
||||
BadRequest(w, errors.New("missing field 'name' from JSON object"))
|
||||
return
|
||||
}
|
||||
if body.Version == nil {
|
||||
http.Error(w, "missing field 'version' from JSON object", http.StatusBadRequest)
|
||||
if body.Version == nil {
|
||||
BadRequest(w, errors.New("missing field 'version' from JSON object"))
|
||||
return
|
||||
}
|
||||
if body.Value == nil {
|
||||
http.Error(w, "missing field 'value' from JSON object", http.StatusBadRequest)
|
||||
if body.Value == nil {
|
||||
BadRequest(w, errors.New("missing field 'value' from JSON object"))
|
||||
return
|
||||
}
|
||||
log.Printf("%s %s %s", *body.Name, *body.Version, *body.Value)
|
||||
cmd := exec.Command("abra", "app", "secret", "insert", appName, *body.Name, *body.Version, *body.Value)
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := h.cmd.Run("abra", "app", "secret", "insert", appName, *body.Name, *body.Version, *body.Value)
|
||||
if err != nil {
|
||||
log.Printf("Error: ", string(output))
|
||||
InternalServerErrorHandler(w, r)
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleGetSecrets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
runOutput []byte
|
||||
runErr error
|
||||
expectedArgs []string
|
||||
expectedCode int
|
||||
expectedBody []AppSecret
|
||||
}{
|
||||
{
|
||||
name: "success no secrets",
|
||||
runOutput: []byte(`[]`),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusOK,
|
||||
expectedBody: []AppSecret{},
|
||||
},
|
||||
{
|
||||
name: "success with secrets",
|
||||
runOutput: []byte(`[
|
||||
{
|
||||
"created on server": "true",
|
||||
"generated name": "myapp_secret",
|
||||
"name": "secret",
|
||||
"version": "v1"
|
||||
},
|
||||
{
|
||||
"created on server": "false",
|
||||
"generated name": "myapp_unknown",
|
||||
"name": "unknown",
|
||||
"version": "v1"
|
||||
}
|
||||
]`),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusOK,
|
||||
expectedBody: []AppSecret{
|
||||
{
|
||||
Name: "secret",
|
||||
Version: "v1",
|
||||
Value: "",
|
||||
Created: true},
|
||||
{
|
||||
Name: "unknown",
|
||||
Version: "v1",
|
||||
Value: "",
|
||||
Created: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failure bad json",
|
||||
runOutput: []byte(`
|
||||
[
|
||||
{
|
||||
"created on server": "true",
|
||||
}
|
||||
]`),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "failure missing fields",
|
||||
runOutput: []byte(`
|
||||
[
|
||||
{
|
||||
"created on server": "true",
|
||||
"generated name": "myapp_fail"
|
||||
}
|
||||
]`),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "failure null field",
|
||||
runOutput: []byte(`
|
||||
[
|
||||
{
|
||||
"created on server": null,
|
||||
"generated name": "myapp_nil",
|
||||
"name": "nil",
|
||||
"version": "v1"
|
||||
}
|
||||
]`),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "command failure",
|
||||
runOutput: []byte("error"),
|
||||
runErr: errors.New("failed"),
|
||||
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
h := &abraHandler{cmd: mockCmd}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleGetAppSecrets(rec, req, "myapp")
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
if tt.expectedCode == http.StatusOK {
|
||||
var res []AppSecret
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedBody, res)
|
||||
}
|
||||
|
||||
mockCmd.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInsert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reqBody string
|
||||
runOutput []byte
|
||||
runErr error
|
||||
expectedArgs []string
|
||||
expectedRun bool
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
reqBody: `{
|
||||
"name": "myapp_secret",
|
||||
"version": "v1",
|
||||
"value": "secret"
|
||||
}`,
|
||||
runOutput: []byte(nil),
|
||||
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
|
||||
expectedRun: true,
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "failure missing fields",
|
||||
reqBody: `{
|
||||
"name": "myapp_secret",
|
||||
}`,
|
||||
runOutput: []byte(nil),
|
||||
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
|
||||
expectedRun: false,
|
||||
expectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "failure abra",
|
||||
reqBody: `{
|
||||
"name": "myapp_secret",
|
||||
"version": "v1",
|
||||
"value": "secret"
|
||||
}`,
|
||||
runOutput: []byte(nil),
|
||||
runErr: errors.New("error"),
|
||||
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
|
||||
expectedRun: true,
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
h := &abraHandler{cmd: mockCmd}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleInsertAppSecret(rec, req, "myapp")
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
|
||||
if tt.expectedRun {
|
||||
mockCmd.AssertExpectations(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// package ui
|
||||
// import (
|
||||
// "github.com/gorilla/websocket"
|
||||
// "time"
|
||||
// "encoding/json"
|
||||
// "github.com/docker/docker/pkg/jsonmessage"
|
||||
|
||||
// )
|
||||
// type WSMessage struct {
|
||||
// MsgType string `json:"msgType"`
|
||||
// Timestamp time.Time `json:"timestamp"`
|
||||
// Payload interface{} `json:"payload"`
|
||||
// }
|
||||
// type DonePayload struct {
|
||||
// Success bool `json:"success"`
|
||||
// Failed bool `json:"failed"`
|
||||
// TimedOut bool `json:"timed_out"`
|
||||
// }
|
||||
|
||||
// func makeWsEmit(conn *websocket.Conn) func([]byte) error {
|
||||
// return func(b []byte) error {
|
||||
// if conn == nil {
|
||||
// return nil
|
||||
// }
|
||||
// // Send as a text message
|
||||
// return conn.WriteMessage(websocket.TextMessage, b)
|
||||
// }
|
||||
// }
|
||||
// func send(wsEmit func([]byte) error, msgType string, payload interface{}) {
|
||||
// if wsEmit == nil {
|
||||
// return
|
||||
// }
|
||||
// msg := WSMessage{
|
||||
// MsgType: msgType,
|
||||
// Timestamp: time.Now().UTC(),
|
||||
// Payload: payload,
|
||||
// }
|
||||
|
||||
// b, _ := json.Marshal(msg)
|
||||
// _ = wsEmit(b)
|
||||
// }
|
||||
@@ -1,16 +0,0 @@
|
||||
package cli
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"net/http"
|
||||
)
|
||||
func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
cmd := exec.Command("abra", "app", "undeploy", appName, "-n")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Printf("Error: ", string(output))
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleUpgradeApp(w http.ResponseWriter, r *http.Request, appName string, force bool) {
|
||||
log.Printf("Handling App Upgrade!")
|
||||
|
||||
args := []string{"app", "upgrade", appName, "-n", "-c"}
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
output, err := h.cmd.Run("abra", args...)
|
||||
if err != nil {
|
||||
InternalServerError(w, errors.New(fmt.Sprintf("Error: %s", string(output))))
|
||||
return
|
||||
}
|
||||
log.Printf("Finishing app upgrade!")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleRollbackApp(w http.ResponseWriter, r *http.Request, appName string, force bool) {
|
||||
log.Printf("Handling App rollback!")
|
||||
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields()
|
||||
|
||||
body := struct {
|
||||
Version *string `json:"version"`
|
||||
}{}
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
log.Printf("error: %v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
if body.Version == nil {
|
||||
BadRequest(w, fmt.Errorf("Error: no version for rollback"))
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{"app", "rollback", appName, "-n", "-c"}
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
if body.Version != nil {
|
||||
if *body.Version == "Auto" {
|
||||
log.Printf("leaving abra to determine rollback version")
|
||||
} else {
|
||||
args = append(args, *body.Version)
|
||||
}
|
||||
}
|
||||
|
||||
output, err := h.cmd.Run("abra", args...)
|
||||
if err != nil {
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
log.Printf("Finishing app rollback!")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandleUpgradeApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
force bool
|
||||
runOutput []byte
|
||||
runErr error
|
||||
expectedArgs []string
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
force: false,
|
||||
runOutput: []byte("ok"),
|
||||
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c"},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "success with force",
|
||||
force: true,
|
||||
runOutput: []byte("ok"),
|
||||
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c", "-f"},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "command failure",
|
||||
force: false,
|
||||
runOutput: []byte("error"),
|
||||
runErr: errors.New("failed"),
|
||||
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr)
|
||||
|
||||
h := &abraHandler{cmd: mockCmd}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleUpgradeApp(rec, req, "myapp", tt.force)
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
|
||||
mockCmd.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRollbackApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
force bool
|
||||
runOutput []byte
|
||||
runErr error
|
||||
expectedArgs []string
|
||||
expectedCode int
|
||||
expectedRun bool
|
||||
}{
|
||||
{
|
||||
name: "success with auto version no force",
|
||||
body: `{
|
||||
"version": "Auto"
|
||||
}`,
|
||||
force: false,
|
||||
runOutput: []byte("ok"),
|
||||
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
|
||||
expectedCode: http.StatusOK,
|
||||
expectedRun: true,
|
||||
},
|
||||
{
|
||||
name: "success with auto version with force",
|
||||
body: `{
|
||||
"version": "Auto"
|
||||
}`,
|
||||
force: true,
|
||||
runOutput: []byte("ok"),
|
||||
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c", "-f"},
|
||||
expectedCode: http.StatusOK,
|
||||
expectedRun: true,
|
||||
},
|
||||
{
|
||||
name: "failure no body",
|
||||
body: `{
|
||||
}`,
|
||||
force: false,
|
||||
runOutput: []byte("ok"),
|
||||
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
expectedRun: false,
|
||||
},
|
||||
{
|
||||
name: "failure unknown field",
|
||||
body: `{
|
||||
"unknown": "unknown"
|
||||
}`,
|
||||
force: false,
|
||||
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
expectedRun: false,
|
||||
},
|
||||
{
|
||||
name: "failure abra error",
|
||||
body: `{
|
||||
"version": "1.0.0"
|
||||
}`,
|
||||
force: false,
|
||||
runOutput: []byte("error"),
|
||||
runErr: errors.New("failed"),
|
||||
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c", "1.0.0"},
|
||||
expectedCode: http.StatusInternalServerError,
|
||||
expectedRun: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockCmd := new(MockCommandRunner)
|
||||
|
||||
args := []interface{}{"abra"}
|
||||
for _, arg := range tt.expectedArgs {
|
||||
args = append(args, arg)
|
||||
}
|
||||
if tt.expectedRun {
|
||||
mockCmd.
|
||||
On("Run", args...).
|
||||
Return(tt.runOutput, tt.runErr).
|
||||
Once()
|
||||
}
|
||||
|
||||
h := &abraHandler{cmd: mockCmd}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleRollbackApp(rec, req, "myapp", tt.force)
|
||||
|
||||
assert.Equal(t, tt.expectedCode, rec.Code)
|
||||
|
||||
mockCmd.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,4 +4,4 @@ export PATH=/home/node/.local/bin/:$PATH
|
||||
|
||||
/home/node/wizard/gobackend &
|
||||
|
||||
npm run dev -- --host
|
||||
npm run dev -- --host 0.0.0.0 --port 5173
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
)
|
||||
|
||||
type AppModifier func(*app.App)
|
||||
|
||||
// func App() app.App {
|
||||
// return app.App{
|
||||
// Name: "nextcloud",
|
||||
// Domain: "cloud.example.com",
|
||||
// Server: "server.example.com",
|
||||
// Path: "/home/test/.abra/servers/hetzner/cloud.example.com.env",
|
||||
// Env: envfile.AppEnv{
|
||||
// "DOMAIN": "cloud.example.com",
|
||||
// "LETS_ENCRYPT_ENABLED": "1",
|
||||
// },
|
||||
// Recipe: recipe.Recipe{
|
||||
// Name: "nextcloud",
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
func App(modifiers ...AppModifier) app.App {
|
||||
a := app.App{
|
||||
Name: "nextcloud.example.com",
|
||||
Domain: "cloud.example.com",
|
||||
Server: "example.com",
|
||||
Path: "/home/test/.abra/servers/hetzner/cloud.example.com.env",
|
||||
Env: envfile.AppEnv{
|
||||
"DOMAIN": "cloud.example.com",
|
||||
"LETS_ENCRYPT_ENABLED": "1",
|
||||
},
|
||||
Recipe: recipe.Recipe{
|
||||
Name: "nextcloud",
|
||||
},
|
||||
}
|
||||
for _, mod := range modifiers {
|
||||
mod(&a)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func RecipeName(name string) AppModifier {
|
||||
return func(a *app.App) {
|
||||
domainName := fmt.Sprintf("%s.%s", name, a.Server)
|
||||
a.Name = name
|
||||
a.Domain = domainName
|
||||
a.Recipe.Name = name
|
||||
a.Env["DOMAIN"] = domainName
|
||||
}
|
||||
}
|
||||
|
||||
func Broken() AppModifier {
|
||||
return func(a *app.App) {
|
||||
delete(a.Env, "DOMAIN")
|
||||
}
|
||||
}
|
||||
func WithServer(server string) AppModifier {
|
||||
return func(a *app.App) {
|
||||
a.Server = server
|
||||
}
|
||||
}
|
||||
|
||||
func WithDomain(domain string) AppModifier {
|
||||
return func(a *app.App) {
|
||||
a.Domain = domain
|
||||
a.Env["DOMAIN"] = domain
|
||||
}
|
||||
}
|
||||
|
||||
// version, chaosVersion, chaos, status
|
||||
type AppStatusModifier func(map[string]map[string]string)
|
||||
|
||||
func AppStatus(modifiers ...AppStatusModifier) map[string]map[string]string {
|
||||
a := map[string]map[string]string{
|
||||
"nextcloud": {
|
||||
"version": "1.0.0",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"status": "running",
|
||||
},
|
||||
}
|
||||
for _, mod := range modifiers {
|
||||
mod(a)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func WithChaos(app string) AppStatusModifier {
|
||||
return func(a map[string]map[string]string) {
|
||||
a[app]["chaos"] = "true"
|
||||
a[app]["chaosVersion"] = "version"
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containers/image v3.0.2+incompatible // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decentral1se/passgen v1.0.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
@@ -48,6 +50,7 @@ require (
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -59,6 +62,7 @@ require (
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
@@ -72,9 +76,12 @@ require (
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leonelquinteros/gotext v1.7.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -83,9 +90,16 @@ require (
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.2 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.6.2 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
@@ -97,43 +111,53 @@ require (
|
||||
github.com/opencontainers/runtime-spec v1.1.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/schollz/progressbar/v3 v3.18.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/cobra v1.10.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect
|
||||
|
||||
@@ -116,6 +116,8 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -167,6 +169,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
|
||||
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
@@ -211,6 +215,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-sql-driver/mysql v1.3.0 h1:pgwjLi/dvffoP9aabwkT3AKpXQM93QARkjFhDDqC1UE=
|
||||
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -242,6 +248,7 @@ github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519
|
||||
github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -301,6 +308,8 @@ github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -320,9 +329,13 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
|
||||
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -355,14 +368,29 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
||||
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0=
|
||||
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
@@ -418,6 +446,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
@@ -462,6 +492,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/schollz/progressbar/v3 v3.14.4 h1:W9ZrDSJk7eqmQhd3uxFNNcTr0QL+xuGNI9dEMrw0r74=
|
||||
github.com/schollz/progressbar/v3 v3.14.4/go.mod h1:aT3UQ7yGm+2ZjeXPqsjTenwL3ddUiuZ0kfQ/2tHlyNI=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
@@ -470,6 +502,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
@@ -477,6 +511,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
|
||||
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
@@ -509,6 +545,10 @@ github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/y
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -518,10 +558,17 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
|
||||
github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
@@ -540,6 +587,8 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
@@ -551,6 +600,8 @@ go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 h1:U2guen0GhqH8o/G2un8f/aG/y++OuW6MyCo6hT9prXk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod h1:yeGZANgEcpdx/WK0IvvRFC+2oLiMS2u4L/0Rj2M2Qr0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||
@@ -569,6 +620,8 @@ go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6b
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
@@ -581,6 +634,8 @@ go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||
@@ -607,6 +662,8 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
|
||||
@@ -638,6 +695,8 @@ golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -656,10 +715,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -678,6 +739,8 @@ golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -689,6 +752,8 @@ golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -700,6 +765,8 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
|
||||
-1
Submodule web deleted from e09aa9a1ca
@@ -0,0 +1,3 @@
|
||||
VITE_API_URL=http://localhost:3000/api
|
||||
VITE_WS_URL=ws://localhost:3000
|
||||
VITE_MOCK_AUTH=true
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,2 @@
|
||||
pnpm 10.20.0
|
||||
nodejs 22.12.0
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "97e400e8",
|
||||
"configHash": "539cfeba",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "b743bb36",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Coop Cloud Front
|
||||
|
||||
Frontend for Coop Cloud — a web UI wrapper around the `abra` CLI for
|
||||
managing VPSs, containers and application deployments.
|
||||
|
||||
This repository contains a Vite + React + TypeScript app styled
|
||||
with SCSS.
|
||||
|
||||
## Quick start
|
||||
|
||||
- Install dependencies (pnpm is recommended):
|
||||
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
```
|
||||
|
||||
The app uses Vite; start the dev server with `pnpm dev`.
|
||||
|
||||
## Environment
|
||||
|
||||
- The app reads runtime flags from Vite env variables. The default build uses mocked api data.
|
||||
-To deploy on a real API without mocking, run:
|
||||
pnpm start:prod
|
||||
|
||||
Modify `.env` file at the project root if you need to override values for
|
||||
development (see Vite docs for env var conventions).
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — start dev server
|
||||
- `pnpm build` — run TypeScript and build production assets
|
||||
- `pnpm preview` — preview production build locally
|
||||
- `pnpm lint` — run ESLint
|
||||
- `pnpm lint:scss` — run Stylelint
|
||||
- `pnpm format` — format with Prettier
|
||||
|
||||
## Notes about local development
|
||||
|
||||
- The repo contains mock JSON and a `mockApi` service to develop UI without
|
||||
a backend. Toggle mock mode with `VITE_MOCK_AUTH=true`.
|
||||
- Routes are client-side using `react-router-dom`.
|
||||
|
||||
## Next steps / suggested work items
|
||||
|
||||
- Add form validation and better UX for `RecipeForm`.
|
||||
- Improve accessibility.
|
||||
- Add unit tests.
|
||||
- Improve responsive layouts and card grid behaviour on narrow screens.
|
||||
- Add deployment docs and production environment variables.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,50 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
|
||||
// TypeScript specific
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// Import organization
|
||||
'sort-imports': ['warn', {
|
||||
ignoreCase: true,
|
||||
ignoreDeclarationSort: true,
|
||||
}],
|
||||
|
||||
// React best practices
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
// Code quality
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'prefer-const': 'warn',
|
||||
'no-var': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>coopcloud-frontend</title>
|
||||
<link rel="preload" href="/fonts/manrope.light.woff2" as="font" type="font/woff2">
|
||||
<link rel="preload" href="/fonts/manrope.medium.woff2" as="font" type="font/woff2">
|
||||
<link rel="preload" href="/fonts/manrope.bold.woff2" as="font" type="font/woff2">
|
||||
<link rel="preload" href="/fonts/Lora-Italic.woff2" as="font" type="font/woff2">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "coopcloud-front",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"start:prod": "VITE_MOCK_AUTH=false pnpm start",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"lint:scss": "stylelint '**/*.scss'",
|
||||
"lint:scss:fix": "stylelint '**/*.scss' --fix",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx,scss,css,json}'",
|
||||
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"niceware": "^4.0.0",
|
||||
"niceware-ts": "^0.0.5",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"react-simple-code-editor": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.93.3",
|
||||
"stylelint": "^16.25.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
inotify: false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Dashboard } from './routes/Dashboard/Dashboard';
|
||||
import { Apps } from './routes/Apps/Apps';
|
||||
import { AppDetail } from './routes/Apps/App';
|
||||
import { Servers } from './routes/Servers/Servers';
|
||||
import { Server } from './routes/Servers/Server';
|
||||
import { Recipes } from './routes/Recipes/Recipes';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/apps" element={<Apps />} />
|
||||
<Route path="/apps/:server/:appName" element={<AppDetail />} />
|
||||
<Route path="/servers" element={<Servers />} />
|
||||
<Route path="/servers/:serverName" element={<Server />} />
|
||||
<Route path="/recipes" element={<Recipes />} />
|
||||
|
||||
{/* 404 catch-all */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,31 @@
|
||||
@font-face {
|
||||
font-family: 'Lora';
|
||||
src: url('/fonts/Lora-Italic.woff2') format('woff2');
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
src: url('/fonts/manrope.light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
src: url('/fonts/manrope.medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
src: url('/fonts/manrope.bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// global page layout styles
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: $spacing-2xl $spacing-xl;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: $spacing-xl $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
h1 {
|
||||
font-size: $font-size-3xl;
|
||||
margin: 0 0 $spacing-sm;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// global state styles
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $spacing-3xl;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
// Stats grid and cards
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-lg;
|
||||
margin-bottom: $spacing-2xl;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-lg;
|
||||
padding: $spacing-xl;
|
||||
transition: transform $transition-base, box-shadow $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
|
||||
// Modifier classes for colored borders
|
||||
&.upgrade {
|
||||
border-left: 4px solid $primary-light;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
&.chaos {
|
||||
border-left: 4px solid $primary-dark;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-left: 4px solid $primary;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
.stat-number {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
margin: $spacing-xs 0 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters component
|
||||
.filters {
|
||||
@include card;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background-color: $bg-primary;
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-base;
|
||||
transition: border-color $transition-base;
|
||||
color: $text-primary;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-base;
|
||||
background-color: $bg-primary;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-base;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status badges
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.status-deployed {
|
||||
background-color: rgba($success, 0.1);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.status-stopped {
|
||||
background-color: rgba($text-muted, 0.1);
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
background-color: rgba($error, 0.1);
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
// global badge styles
|
||||
.recipe-badge,
|
||||
.server-badge {
|
||||
display: inline-block;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.recipe-badge {
|
||||
background-color: rgba($info, 0.1);
|
||||
color: $info;
|
||||
}
|
||||
|
||||
.server-badge {
|
||||
background-color: rgba($text-secondary, 0.1);
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
// Ensure stat-chip shows primary-dark outline on hover and when active
|
||||
.stat-chip {
|
||||
&:hover:not(:disabled),
|
||||
&.active {
|
||||
border-color: $primary-dark;
|
||||
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
|
||||
}
|
||||
}
|
||||
.filter-chip {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Results count
|
||||
.results-count {
|
||||
text-align: center;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
// No results message
|
||||
.no-results {
|
||||
@include card;
|
||||
text-align: center;
|
||||
padding: $spacing-3xl;
|
||||
color: $text-secondary;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
// Navigation link button (for clickable cards)
|
||||
.nav-link {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $primary;
|
||||
outline-offset: 2px;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
}
|
||||
|
||||
.bland-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
// Flexbox center
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Responsive breakpoints
|
||||
@mixin respond-to($breakpoint) {
|
||||
@if $breakpoint == 'sm' {
|
||||
@media (min-width: $breakpoint-sm) { @content; }
|
||||
} @else if $breakpoint == 'md' {
|
||||
@media (min-width: $breakpoint-md) { @content; }
|
||||
} @else if $breakpoint == 'lg' {
|
||||
@media (min-width: $breakpoint-lg) { @content; }
|
||||
} @else if $breakpoint == 'xl' {
|
||||
@media (min-width: $breakpoint-xl) { @content; }
|
||||
}
|
||||
}
|
||||
|
||||
// Card style
|
||||
@mixin card {
|
||||
background: $bg-primary;
|
||||
box-shadow: $shadow-md;
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
|
||||
// Ensure cards occupy consistent vertical space and can stretch horizontally
|
||||
@mixin card-dimensions($min-width: 320px, $min-height: 180px) {
|
||||
min-width: $min-width;
|
||||
min-height: $min-height;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/// Standard vertical stack for card-style list rows (dashboard recent apps, server detail apps, etc.)
|
||||
@mixin card-list-vertical($gap: $spacing-md) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
/// Hover lift used by dashboard list cards, server grid cards, and similar surfaces
|
||||
@mixin card-hover-lift($translate-y: -2px, $hover-shadow: $shadow-lg) {
|
||||
transition: transform $transition-base, box-shadow $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: translateY($translate-y);
|
||||
box-shadow: $hover-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Card shell with horizontal scroll (e.g. data tables)
|
||||
@mixin scrollable-card {
|
||||
@include card;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/// Header block inside a card: title area with a bottom rule (server cards, etc.)
|
||||
@mixin card-header-rule {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 2px solid $bg-secondary;
|
||||
}
|
||||
|
||||
/// Primary title + muted monospace/meta line (server cards, resource headers)
|
||||
@mixin card-title-stack(
|
||||
$title-size: $font-size-xl,
|
||||
$title-weight: $font-weight-bold,
|
||||
$meta-size: $font-size-sm
|
||||
) {
|
||||
h3 {
|
||||
margin: 0 0 $spacing-xs;
|
||||
font-size: $title-size;
|
||||
color: $text-primary;
|
||||
font-weight: $title-weight;
|
||||
}
|
||||
|
||||
.server-host {
|
||||
font-size: $meta-size;
|
||||
color: $text-muted;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/// Label + value row inside a card body (server stats, etc.)
|
||||
@mixin card-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1px solid $bg-secondary;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-width edge bleed for highlighted rows inside padded cards (matches card horizontal padding)
|
||||
@mixin card-row-highlight-bleed($clear-bottom-border: true) {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
margin: $spacing-sm (-$spacing-xl);
|
||||
padding-left: calc($spacing-xl + $spacing-md);
|
||||
padding-right: calc($spacing-xl + $spacing-md);
|
||||
|
||||
@if $clear-bottom-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Button base
|
||||
@mixin button-base {
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient background
|
||||
@mixin gradient-primary {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
|
||||
// Truncate text
|
||||
@mixin truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Status badge
|
||||
@mixin status-badge($color) {
|
||||
padding: $spacing-xs $spacing-md;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
background-color: rgba($color, 0.1);
|
||||
// color: darken($color, 20%);
|
||||
}
|
||||
|
||||
// Compact chip used for stat chips and small interactive chips
|
||||
@mixin chip(
|
||||
$bg: white,
|
||||
$border-color: $border-color,
|
||||
$radius: $radius-md,
|
||||
$padding: $spacing-md $spacing-lg,
|
||||
$gap: $spacing-md,
|
||||
$hover-translate: -2px,
|
||||
$hover-shadow: $shadow-sm,
|
||||
$font-size: $font-size-base
|
||||
) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap;
|
||||
padding: $padding;
|
||||
background: $bg;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
font-size: $font-size;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $primary;
|
||||
transform: translateY($hover-translate);
|
||||
box-shadow: $hover-shadow;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable action button styles; accepts border width and radius
|
||||
@mixin action-btn($border-width: 1px, $radius: $radius-sm, $padding: $spacing-xs $spacing-md, $font-size: $font-size-sm) {
|
||||
background: none;
|
||||
border: $border-width solid $border-color;
|
||||
padding: $padding;
|
||||
border-radius: $radius;
|
||||
cursor: pointer;
|
||||
font-size: $font-size;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary, 0.05);
|
||||
color: $text-primary;
|
||||
transform: translateY(-1px);
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Colors
|
||||
$primary: #EFEFEF;
|
||||
$primary-dark: #6A9CFF;
|
||||
$primary-light: #ff4f88;
|
||||
$secondary: #363636;
|
||||
|
||||
$success: #10b981;
|
||||
$warning: #f59e0b;
|
||||
$error: #ef4444;
|
||||
$info: #3b82f6;
|
||||
|
||||
$text-primary: #363636;
|
||||
$text-secondary: #4a4a4a
|
||||
;
|
||||
$text-muted: #999;
|
||||
|
||||
$bg-primary: #ffffff;
|
||||
$bg-secondary: #f5f5f5;
|
||||
$bg-tertiary: #e0e0e0;
|
||||
|
||||
$border-color: #e0e0e0;
|
||||
|
||||
// Spacing
|
||||
$spacing-2xs: 0.1rem;
|
||||
$spacing-xs: 0.25rem;
|
||||
$spacing-sm: 0.5rem;
|
||||
$spacing-md: 1rem;
|
||||
$spacing-lg: 1.5rem;
|
||||
$spacing-xl: 2rem;
|
||||
$spacing-2xl: 3rem;
|
||||
$spacing-3xl: 4rem;
|
||||
|
||||
// Typography
|
||||
|
||||
$font-family-body: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
$font-family-heading: 'Lora', serif;
|
||||
|
||||
$font-size-xs: 0.75rem;
|
||||
$font-size-sm: 0.875rem;
|
||||
$font-size-base: 1rem;
|
||||
$font-size-lg: 1.125rem;
|
||||
$font-size-xl: 1.25rem;
|
||||
$font-size-2xl: 1.5rem;
|
||||
$font-size-3xl: 2rem;
|
||||
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// Border radius
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 6px;
|
||||
$radius-lg: 8px;
|
||||
$radius-xl: 12px;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// Shadows
|
||||
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
$shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
$shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 0.15s ease;
|
||||
$transition-base: 0.2s ease;
|
||||
$transition-slow: 0.3s ease;
|
||||
|
||||
// Breakpoints
|
||||
$breakpoint-sm: 640px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
$breakpoint-xl: 1280px;
|
||||
@@ -0,0 +1,7 @@
|
||||
// 1. Configuration & helpers (no CSS output)
|
||||
@use 'variables';
|
||||
@use 'mixins';
|
||||
@use 'fonts';
|
||||
|
||||
// 2. Global base styles
|
||||
@use 'global';
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { apiService } from '../../services/api';
|
||||
import Editor from "react-simple-code-editor";
|
||||
import Prism from "prismjs";
|
||||
|
||||
// import a theme + language
|
||||
import "prismjs/themes/prism.css";
|
||||
import "prismjs/components/prism-javascript";
|
||||
|
||||
interface EditorProps {
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export const EditorPage: React.FC<EditorProps> = ({ appName }) => {
|
||||
const [code, setCode] = useState("");
|
||||
useEffect(() => {
|
||||
document.body.classList.add("modal-open");
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove("modal-open");
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const fetchCode = async () => {
|
||||
const config = await apiService.getConfig(appName);
|
||||
setCode(config);
|
||||
}
|
||||
fetchCode();
|
||||
}, []);
|
||||
|
||||
const saveFile = async () => {
|
||||
await apiService.writeConfig(appName, code)
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={code => setCode(code)}
|
||||
highlight={code => Prism.highlight(code, Prism.languages.javascript, "javascript")}
|
||||
padding={10}
|
||||
style={{
|
||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||
fontSize: 14,
|
||||
border: "1px solid #ccc",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
/>
|
||||
<button onClick={saveFile}>Save</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
@use "sass:color";
|
||||
.secrets {
|
||||
color: $text-primary
|
||||
}
|
||||
.status {
|
||||
white-space: nowrap;
|
||||
min-width: 350px;
|
||||
.manage-secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
&.created {
|
||||
color: $success;
|
||||
}
|
||||
&.not-created {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gen-btn {
|
||||
@include action-btn(1px, $radius-md, $spacing-xs $spacing-md, $font-size-sm);
|
||||
white-space: nowrap;
|
||||
&.auto {
|
||||
|
||||
padding: $spacing-xs;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
&.create {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: $success;
|
||||
color: white;
|
||||
border-color: $success;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($success, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
&.modify {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: $warning;
|
||||
color: white;
|
||||
border-color: $warning;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($warning, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
&.cancel {
|
||||
background: $error;
|
||||
color: white;
|
||||
border-color: $error;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($error, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.secret-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.secret-input {
|
||||
flex: 1;
|
||||
min-width: 350px;
|
||||
padding: $spacing-xs;
|
||||
padding-right: calc(
|
||||
16px +
|
||||
16px +
|
||||
6px +
|
||||
16px
|
||||
);
|
||||
font-size: $font-size-sm;
|
||||
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: $radius-md;
|
||||
|
||||
background: #fff;
|
||||
color: #24292f;
|
||||
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.secret-input::placeholder {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.secret-input:focus {
|
||||
outline: none;
|
||||
border-color: #2f81f7;
|
||||
box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.15);
|
||||
}
|
||||
.right-icons {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.right-icons button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.right-icons button:hover {
|
||||
color: #37393b;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraAppSecret } from '../../types'
|
||||
import './Secrets.scss';
|
||||
import {generatePassphrase} from "niceware-ts";
|
||||
import { FaCopy, FaEye, FaEyeSlash, FaDice } from "react-icons/fa";
|
||||
|
||||
|
||||
|
||||
|
||||
interface SecretProps {
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export const SecretsPage: React.FC<SecretProps> = ({ appName }) => {
|
||||
const [secrets, setSecrets] = useState<AbraAppSecret[]>();
|
||||
const [editingSecrets, setEditingSecrets] = useState(new Set());
|
||||
const [secretValues, setSecretValues] = useState({});
|
||||
const [hidden, setHidden] = useState(new Set());
|
||||
const [loadingSecrets, setLoadingSecrets] = useState(new Set());
|
||||
const [ellipses, setEllipses] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setEllipses(prev => {
|
||||
if (prev.length >= 3) return "";
|
||||
return prev + ".";
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async (value: string) => {
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("modal-open");
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove("modal-open");
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const fetchSecrets = async () => {
|
||||
const secretInfo = await apiService.getSecrets(appName);
|
||||
setSecrets(secretInfo);
|
||||
}
|
||||
fetchSecrets();
|
||||
}, []);
|
||||
const generateSecret = (name: string) => {
|
||||
setSecretValues(prev => ({
|
||||
...prev,
|
||||
[name]: generatePassphrase(12).join("-"),
|
||||
}))
|
||||
};
|
||||
const generateOrUpdateSecret = async (name: string, version: string, value: string) => {
|
||||
// TODO: handle updating secret instead of generating
|
||||
console.log(appName, name, version, value);
|
||||
setEditingSecrets(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
setSecretValues(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
if (hidden.has(name)) {
|
||||
setHidden(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setLoadingSecrets(prev => new Set(prev).add(name));
|
||||
try {
|
||||
await apiService.writeSecret(appName, name, version, value);
|
||||
const secretInfo = await apiService.getSecrets(appName);
|
||||
setSecrets(secretInfo);
|
||||
} catch (err) {
|
||||
// TODO: better error handling
|
||||
console.log("Error in setting new secret")
|
||||
}
|
||||
setLoadingSecrets(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal">
|
||||
{(secrets == null && <p> Fetching Secrets...</p>)}
|
||||
{(secrets != null && secrets.length == 0 && <p> Found no secrets </p>)}
|
||||
{(secrets != null && secrets.length > 0 &&
|
||||
<div className="secrets">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Secret Name</th>
|
||||
<th> Version </th>
|
||||
<th> Status </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secrets.map((secret) => (
|
||||
<tr key={secret.name}>
|
||||
<td>{secret.name}</td>
|
||||
<td>{secret.version}</td>
|
||||
<td className="status">
|
||||
{loadingSecrets.has(secret.name) && <div> <span>modifying secret{ellipses}</span></div>}
|
||||
{!loadingSecrets.has(secret.name) && (<div className={secret.created ? "manage-secret created" : "manage-secret not-created"}>
|
||||
{!editingSecrets.has(secret.name) && !loadingSecrets.has(secret.name) && <span> {secret.created ? "created" : "not created"}</span> }
|
||||
{editingSecrets.has(secret.name) &&
|
||||
<div className="secret-input-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="gen-btn auto"
|
||||
title="Click to generate automatically"
|
||||
onClick={() => {generateSecret(secret.name)}}
|
||||
>
|
||||
Auto
|
||||
</button>
|
||||
<input
|
||||
className="secret-input"
|
||||
name="secret-value"
|
||||
placeholder="Secret Value"
|
||||
value={(hidden.has(secret.name) ? '*'.repeat(secretValues[secret.name].length): secretValues[secret.name] )|| ""}
|
||||
onChange={(e) =>
|
||||
setSecretValues(prev => ({
|
||||
...prev,
|
||||
[secret.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="right-icons">
|
||||
<button
|
||||
type="button"
|
||||
title="Copy"
|
||||
onClick={copyToClipboard(secretValues[secret.name])}
|
||||
>
|
||||
<FaCopy />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={hidden.has(secret.name) ? "Show" : "Hide"}
|
||||
onClick={() => setHidden((prev) => {
|
||||
if(prev.has(secret.name)) {
|
||||
const next = new Set(prev);
|
||||
next.delete(secret.name);
|
||||
return next;
|
||||
}
|
||||
return new Set(prev).add(secret.name);
|
||||
})}
|
||||
>
|
||||
{hidden.has(secret.name) ? <FaEye /> : <FaEyeSlash />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
className={secret.created ? "gen-btn modify" : "gen-btn create"}
|
||||
onClick={() => editingSecrets.has(secret.name) ? generateOrUpdateSecret(secret.name, secret.version, secretValues[secret.name])
|
||||
: setEditingSecrets(prev => new Set(prev).add(secret.name))}>
|
||||
{secret.created ? "Update" : "Create"}
|
||||
</button>
|
||||
|
||||
{ editingSecrets.has(secret.name) &&
|
||||
<button
|
||||
className="gen-btn cancel"
|
||||
onClick={() => {
|
||||
setEditingSecrets(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(secret.name);
|
||||
return next;
|
||||
});
|
||||
setSecretValues(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[secret.name];
|
||||
return next;
|
||||
});
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
</div>)}
|
||||
</td>
|
||||
<td>
|
||||
<span>{secret.value}</span>
|
||||
<button
|
||||
className="copy-btn"
|
||||
onClick={() => navigator.clipboard.writeText(secret.value)}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './_Header.scss';
|
||||
import logo from '../../assets/coopcloud_logo_grey.svg';
|
||||
|
||||
|
||||
interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
return(
|
||||
<header className="layout-header">
|
||||
<div className="header-content">
|
||||
<h1 onClick={() => navigate('/dashboard')} className="logo">
|
||||
<img className="logo" src={logo}/>
|
||||
</h1>
|
||||
<nav className="nav">
|
||||
<button onClick={() => navigate('/apps')} className="nav-link">
|
||||
Apps
|
||||
</button>
|
||||
<button onClick={() => navigate('/servers')} className="nav-link">
|
||||
Servers
|
||||
</button>
|
||||
<button onClick={() => navigate('/recipes')} className="nav-link">
|
||||
Recipes
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -0,0 +1,83 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
|
||||
.layout-header {
|
||||
background-color: $primary-light;
|
||||
color: $text-primary;
|
||||
padding: $spacing-lg 0;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $spacing-xl;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 100px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $text-primary;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-md;
|
||||
transition: all $transition-base;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-medium;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Active indicator
|
||||
&.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
height: 2px;
|
||||
background-color: $bg-primary;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
font-size: $font-size-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
|
||||
.loader {
|
||||
display: inline-flex; // instead of block
|
||||
align-items: center; // center SVG inside
|
||||
line-height: 1; // prevents extra vertical space
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
rect {
|
||||
fill: $primary-light;
|
||||
}
|
||||
|
||||
&--style7 {
|
||||
rect {
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "./Loader.scss";
|
||||
|
||||
export const Loader = () => {
|
||||
return (
|
||||
<div className="loader loader--style6" title="5">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="24px" height="30px" viewBox="0 0 24 30" style={{ enableBackground: "new 0 0 50 50" }} xml:space="preserve">
|
||||
<rect x="0" y="13" width="4" height="5" fill="#333">
|
||||
<animate attributeName="height" attributeType="XML"
|
||||
values="5;21;5"
|
||||
begin="0s" dur="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" attributeType="XML"
|
||||
values="13; 5; 13"
|
||||
begin="0s" dur="0.6s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
<rect x="10" y="13" width="4" height="5" fill="#333">
|
||||
<animate attributeName="height" attributeType="XML"
|
||||
values="5;21;5"
|
||||
begin="0.15s" dur="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" attributeType="XML"
|
||||
values="13; 5; 13"
|
||||
begin="0.15s" dur="0.6s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
<rect x="20" y="13" width="4" height="5" fill="#333">
|
||||
<animate attributeName="height" attributeType="XML"
|
||||
values="5;21;5"
|
||||
begin="0.3s" dur="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="y" attributeType="XML"
|
||||
values="13; 5; 13"
|
||||
begin="0.3s" dur="0.6s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// magic spinner stuff
|
||||
|
||||
$offset: 187;
|
||||
$duration: 1.4s;
|
||||
|
||||
.spinner {
|
||||
margin-top: 10px;
|
||||
animation: rotator $duration linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotator {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(270deg); }
|
||||
}
|
||||
|
||||
.path {
|
||||
stroke-dasharray: $offset;
|
||||
stroke-dashoffset: 0;
|
||||
transform-origin: center;
|
||||
animation:
|
||||
dash $duration ease-in-out infinite,
|
||||
colors ($duration*4) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes colors {
|
||||
0% { stroke: #4285F4; }
|
||||
25% { stroke: #DE3E35; }
|
||||
50% { stroke: #F7C223; }
|
||||
75% { stroke: #1B9A59; }
|
||||
100% { stroke: #4285F4; }
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% { stroke-dashoffset: $offset; }
|
||||
50% {
|
||||
stroke-dashoffset: $offset/4;
|
||||
transform:rotate(135deg);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: $offset;
|
||||
transform:rotate(450deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./Spinner.scss"
|
||||
|
||||
export const Spinner = () => {
|
||||
return (
|
||||
<svg className="spinner" width="40px" height="40px" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle className="path" fill="none" stroke-width="6" stroke-linecap="round" cx="33" cy="33" r="30"></circle>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { LogEntry } from '../../services/mockApi'; // Import from mockApi (or api for real)
|
||||
import './_Terminal.scss';
|
||||
|
||||
interface TerminalProps {
|
||||
logs: LogEntry[];
|
||||
isActive: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const indexRef = useRef(0);
|
||||
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
// Stream logs in with delays for realistic effect
|
||||
useEffect(() => {
|
||||
if (!isActive || logs.length === 0) {
|
||||
indexRef.current = 0;
|
||||
setDisplayedLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const streamLogs = () => {
|
||||
if (indexRef.current < logs.length) {
|
||||
setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
|
||||
indexRef.current++;
|
||||
|
||||
// Variable delay based on log type
|
||||
const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
|
||||
logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
|
||||
|
||||
setTimeout(streamLogs, delay);
|
||||
}
|
||||
};
|
||||
|
||||
streamLogs();
|
||||
}, [logs, isActive]);
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
}
|
||||
}, [displayedLogs]);
|
||||
|
||||
if (!isActive && displayedLogs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="terminal-container">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-controls">
|
||||
<span className="terminal-control close" onClick={onClose}></span>
|
||||
<span className="terminal-control minimize"></span>
|
||||
<span className="terminal-control maximize"></span>
|
||||
</div>
|
||||
<div className="terminal-title">abra CLI</div>
|
||||
<div className="terminal-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div className="terminal-content" ref={terminalRef}>
|
||||
{displayedLogs.filter(log => log && log.type).map((log, index) => (
|
||||
<div key={index} className={`terminal-line terminal-${log.type}`}>
|
||||
<span className="terminal-text">{log.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{isActive && displayedLogs.length > 0 && (
|
||||
<div className="terminal-cursor">▊</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
|
||||
.terminal-container {
|
||||
background: #1e1e1e;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
|
||||
margin-bottom: $spacing-xl;
|
||||
animation: terminal-appear 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes terminal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: #323232;
|
||||
border-bottom: 1px solid #404040;
|
||||
user-select: none;
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.terminal-control {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.close {
|
||||
background: #ff5f56;
|
||||
}
|
||||
|
||||
&.minimize {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
&.maximize {
|
||||
background: #27c93f;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: #a0a0a0;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.terminal-spacer {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-content {
|
||||
padding: $spacing-md;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
display: flex;
|
||||
margin-bottom: 2px;
|
||||
animation: terminal-line-appear 0.2s ease-out;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
@keyframes terminal-line-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-timestamp {
|
||||
color: #6a6a6a;
|
||||
margin-right: $spacing-sm;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.terminal-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Line type styles */
|
||||
&.terminal-info {
|
||||
.terminal-text {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal-success {
|
||||
.terminal-text {
|
||||
color: #81c784;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal-error {
|
||||
.terminal-text {
|
||||
color: #e57373;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal-warning {
|
||||
.terminal-text {
|
||||
color: #ffb74d;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal-command {
|
||||
.terminal-text {
|
||||
color: #ba68c8;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal-output {
|
||||
.terminal-text {
|
||||
color: #d4d4d4;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-cursor {
|
||||
display: inline-block;
|
||||
color: #81c784;
|
||||
margin-left: 4px;
|
||||
animation: terminal-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes terminal-cursor-blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.terminal-content {
|
||||
font-size: 12px;
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
.terminal-timestamp {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@use './assets/scss/variables' as *;
|
||||
@use './assets/scss/mixins' as *;
|
||||
@use './assets/scss/global' as *;
|
||||
|
||||
// Global root styles
|
||||
:root {
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: $text-primary;
|
||||
background-color: $bg-primary;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// Global element resets
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// Global link styles
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: $primary;
|
||||
text-decoration: none;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// Global heading styles
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: $font-size-3xl;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: $font-size-2xl;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $font-size-xl;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// Global button styles
|
||||
button {
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: inherit;
|
||||
background-color: $primary;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary-light;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.scss'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,591 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
@use "sass:color";
|
||||
|
||||
|
||||
.app-detail-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.app-detail-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// App header section
|
||||
.app-header {
|
||||
@include card;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xl;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.app-title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 $spacing-md 0;
|
||||
font-size: $font-size-3xl;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.app-meta {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
.action-btn {
|
||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
|
||||
|
||||
&.primary {
|
||||
background: $primary;
|
||||
color: white;
|
||||
border-color: $primary-light;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $primary-light;
|
||||
border-color: $primary-light;
|
||||
}
|
||||
}
|
||||
&.service {
|
||||
&:hover:not(:disabled) {
|
||||
background: #3fff5c9d;
|
||||
|
||||
}
|
||||
}
|
||||
&.secondary {
|
||||
background: $bg-secondary;
|
||||
color: $text-primary;
|
||||
border-color: $border-color;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: $error;
|
||||
color: white;
|
||||
border-color: $error;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($error, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
&.modify {
|
||||
background: $warning;
|
||||
color: white;
|
||||
border-color: $warning;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($warning, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@extend .action-btn;
|
||||
@extend .secondary;
|
||||
}
|
||||
|
||||
// Content grid layout
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: $spacing-xl;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.backup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
// Info cards
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
font-size: $font-size-xl;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
// service grid
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 2fr));
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
@include card;
|
||||
transition: transform $transition-base, box-shadow $transition-base;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: $spacing-md;
|
||||
.service-name {
|
||||
h3 {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
.service-row {
|
||||
flex-grow: 1;
|
||||
flex: 1;
|
||||
|
||||
.service-value {
|
||||
font-size: $font-size-sm;
|
||||
display: -webkit-box;
|
||||
}
|
||||
}
|
||||
&.dark-green {
|
||||
background-color: #3fff5c;
|
||||
}
|
||||
&.green {
|
||||
background-color: #3fff5c9d;
|
||||
}
|
||||
&.red {
|
||||
background-color: #ff1313;
|
||||
}
|
||||
&.gray {
|
||||
background-color: rgb(187, 187, 187)
|
||||
}
|
||||
}
|
||||
|
||||
// Info grid
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
label {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-secondary;
|
||||
font-weight: $font-weight-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: $font-size-base;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chaos-active {
|
||||
background: color.adjust($info, $lightness: -10%);
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.domain-link {
|
||||
color: $primary-light;
|
||||
text-decoration: none;
|
||||
font-size: $font-size-base;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.server-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $primary-light;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: $font-size-base;
|
||||
text-align: left;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.server-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $primary-dark;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: $font-size-base;
|
||||
text-align: left;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Version information
|
||||
.version-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
|
||||
.version-current {
|
||||
label {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-secondary;
|
||||
font-weight: $font-weight-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background: $bg-secondary;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.version-upgrades {
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-secondary;
|
||||
font-weight: $font-weight-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.upgrade-count {
|
||||
background: $warning;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.upgrade-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-md;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: $warning;
|
||||
}
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.upgrade-btn {
|
||||
padding: $spacing-xs $spacing-md;
|
||||
background: $warning;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($warning, $lightness: -10%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-latest {
|
||||
padding: $spacing-md;
|
||||
background: rgba($success, 0.1);
|
||||
background: color.adjust($success, $lightness: -10%);
|
||||
border-radius: $radius-md;
|
||||
text-align: center;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
.backup-info {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
grid-template-columns: 2fr 1fr 2fr 100px;
|
||||
gap: $spacing-md;
|
||||
|
||||
}
|
||||
.backup-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
|
||||
.create-btn {
|
||||
width: 100%;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
border: 2px solid $text-muted;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $warning;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.backup-item {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
grid-template-columns: 2fr 1fr 2fr 100px;
|
||||
gap: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-md;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: $warning;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
font-family: monospace;
|
||||
}
|
||||
.backup-btn {
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
border: none;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&.delete-btn {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Quick actions list
|
||||
.action-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.action-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
border: 2px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: white;
|
||||
border-color: $primary;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $error;
|
||||
background: rgba($error, 0.05);
|
||||
}
|
||||
}
|
||||
.action-text {
|
||||
flex: 1;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health status
|
||||
.health-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.health-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.health-value {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// editor modal overlay
|
||||
// locks scrolling
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
.secrets {
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-modal {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.editor-header {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
overflow: auto; // 👈 THIS is key
|
||||
display: flex;
|
||||
|
||||
// make your code editor stretch
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.secrets {
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// navigation back to apps page
|
||||
.back-navigation {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.rollback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.select-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
color: #24292f;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { Terminal } from '../../components/Terminal/Terminal';
|
||||
import { EditorPage } from '../../components/Editor/Editor';
|
||||
import { SecretsPage } from '../../components/Editor/Secrets';
|
||||
import { apiService } from '../../services/api';
|
||||
import { type AppSnapshot, type AbraApp, type AbraAppService, type AbraServiceState } from '../../types';
|
||||
import type { LogEntry } from '../../services/mockApi';
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArchiveRestore, Download, Trash2, SquarePlus } from "lucide-react";
|
||||
|
||||
import './App.scss';
|
||||
|
||||
export const AppDetail: React.FC = () => {
|
||||
const { server, appName } = useParams<{ server: string; appName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [app, setApp] = useState<AbraApp | null>(null);
|
||||
const [deployState, setDeployState] = useState("undeployed | deploying | deployed | failed");
|
||||
const [serviceState, setServiceState] = useState<Record<string, AbraServiceState>>({});
|
||||
const [services, setServices] = useState<AbraAppService[]>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [openEditor, setOpenEditor] = useState(false);
|
||||
const [openSecrets, setOpenSecrets] = useState(false);
|
||||
const [backupEnabled, setBackupEnabled] = useState(false);
|
||||
const [backupSnapshots, setBackupSnapshots] = useState<AppSnapshot[]>();
|
||||
const [rbVersion, setRbVersion] = useState("Auto");
|
||||
|
||||
// Terminal state
|
||||
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
||||
const [terminalActive, setTerminalActive] = useState(false);
|
||||
|
||||
// Stream state
|
||||
const stopRef = useRef<null | (() => void)>(null);
|
||||
const deployRef = useRef<null | (() => void)>(null);
|
||||
|
||||
// App Information
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
// Use to refresh page
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
const getServiceClass = (state: string, status: string) => {
|
||||
if (state === "running" && status.includes("\(healthy\)")) return "service-card dark-green";
|
||||
if (status.includes("unhealthy")) return "service-card red"
|
||||
if (state === "running") return "service-card green";
|
||||
return "service-card gray";
|
||||
};
|
||||
const getServiceClassDeploying = (state: string, status: string) => {
|
||||
if (state.includes("converged") && status.includes("\(healthy\)")) return "service-card dark-green";
|
||||
if (status.includes("unhealthy")) return "service-card red"
|
||||
if (state.includes("converged")) return "service-card green";
|
||||
return "service-card gray";
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchApp = async () => {
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
const appsData = await mockApiService.getAppsGrouped();
|
||||
|
||||
const serverApps = appsData[server || ''];
|
||||
const foundApp = serverApps?.apps.find(a => a.appName === appName);
|
||||
|
||||
if (foundApp) {
|
||||
setApp(foundApp);
|
||||
} else {
|
||||
setError('App not found');
|
||||
}
|
||||
} else {
|
||||
console.log('fetching app...');
|
||||
const appsData = await apiService.getAppsGrouped();
|
||||
if (appsData[server || ''].backupEnabled) {
|
||||
setBackupEnabled(true);
|
||||
}
|
||||
const serverApps = appsData[server || ''];
|
||||
const foundApp = serverApps?.apps.find(a => a.appName === appName);
|
||||
|
||||
if (foundApp) {
|
||||
setApp(foundApp);
|
||||
const versionData = await apiService.getAppVersions(foundApp.appName);
|
||||
if (versionData) {
|
||||
setVersions(versionData);
|
||||
}
|
||||
// when the app is deploying it should handle setting the deploy state itself after success/failure.
|
||||
if (deployState !== "deploying") {
|
||||
setDeployState(foundApp.status === "deployed" ? "deployed" : "undeployed");
|
||||
}
|
||||
} else {
|
||||
setError('App not found');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load app');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchApp();
|
||||
}, [server, appName, isMockMode, refreshKey]);
|
||||
// checks status of app containers
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const heartbeat = async () => {
|
||||
while (isMounted) {
|
||||
if (deployState === "deployed" && appName) {
|
||||
const services = await apiService.getServices(appName);
|
||||
if (services) {
|
||||
setServices(services);
|
||||
} else {
|
||||
setServices([]);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
}
|
||||
};
|
||||
heartbeat();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [deployState, appName]);
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
if (backupEnabled && app) {
|
||||
console.log('test');
|
||||
// grab backupSnapshots here
|
||||
const snapshots = await apiService.getBackupList(app.appName);
|
||||
setBackupSnapshots(snapshots);
|
||||
console.log(snapshots);
|
||||
}
|
||||
}
|
||||
fetchSnapshots();
|
||||
}, [backupEnabled])
|
||||
function EditorModal({ onClose }) {
|
||||
return createPortal(
|
||||
<div className="editor-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="editor-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="editor-header">
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
<EditorPage
|
||||
appName={app ? app.appName : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
function SecretsModal({ onClose }) {
|
||||
return createPortal(
|
||||
<div className="editor-overlay secrets" onClick={onClose}>
|
||||
<div
|
||||
className="editor-modal secrets"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="editor-header">
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
<SecretsPage
|
||||
appName={app ? app.appName : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
const waitForLogs = (appName: string) => {
|
||||
setDeployState("deploying");
|
||||
console.log("deploying");
|
||||
deployRef.current = apiService.deployLogs(appName,
|
||||
(update) => {
|
||||
if (update.type === "service") {
|
||||
console.log(update.data.name)
|
||||
const serviceName = update.data.name.slice(appName.length+1)
|
||||
setServiceState(prev => ({
|
||||
...prev,
|
||||
[serviceName]: {
|
||||
...prev[serviceName] ?? {},
|
||||
...update.data
|
||||
}
|
||||
}))
|
||||
}
|
||||
if (update.type === "done") {
|
||||
if (update.data.failed) {
|
||||
setDeployState('failed');
|
||||
} else {
|
||||
setDeployState("deployed");
|
||||
}
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// TODO
|
||||
const handleDeleteSnapshot = async () => {}
|
||||
const handleDownloadSnapshot = async (appName: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = `http://localhost:3000/api/apps/${appName}/backup/download`
|
||||
a.click()
|
||||
// console.log("downloading...")
|
||||
// const blob = await apiService.downloadSnapshot(appName)
|
||||
|
||||
// const url = URL.createObjectURL(blob)
|
||||
|
||||
// const a = document.createElement('a')
|
||||
// a.href = url
|
||||
// a.download = `${appName}.tar.gz`
|
||||
// a.click()
|
||||
|
||||
// URL.revokeObjectURL(url)
|
||||
}
|
||||
// RESTORE UNTESTED
|
||||
const handleRestoreSnapshot = async (appName: string, snapshot?: string) => {
|
||||
apiService.restoreBackup(appName, snapshot)
|
||||
}
|
||||
|
||||
const handleAction = async (action: string, version?: string) => {
|
||||
if (!app) return;
|
||||
|
||||
setActionLoading(action);
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
|
||||
const onLog = (log: LogEntry) => {
|
||||
setTerminalLogs(prev => [...prev, log]);
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'deploy':
|
||||
await mockApiService.deployApp(app.appName, onLog);
|
||||
break;
|
||||
case 'stop':
|
||||
await mockApiService.stopApp(app.appName, onLog);
|
||||
break;
|
||||
case 'upgrade':
|
||||
if (version) {
|
||||
await mockApiService.upgradeApp(app.appName, version, onLog);
|
||||
}
|
||||
break;
|
||||
case 'remove':
|
||||
await mockApiService.removeApp(app.appName, onLog);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Real API calls
|
||||
switch (action) {
|
||||
case 'stop':
|
||||
await apiService.stopApp(app.appName);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
break;
|
||||
case 'upgrade':
|
||||
await apiService.upgradeApp(app.appName);
|
||||
waitForLogs(app.appName);
|
||||
break;
|
||||
case 'deploy':
|
||||
await apiService.deployApp(app.appName);
|
||||
waitForLogs(app.appName);
|
||||
break;
|
||||
case 'logs':
|
||||
setTerminalActive(true);
|
||||
setTerminalLogs([]);
|
||||
if (version) {
|
||||
stopRef.current = apiService.getLogs(app.appName, version,
|
||||
(line) => {
|
||||
setTerminalLogs(prev => [...prev, {
|
||||
type: 'info',
|
||||
text: `${line}`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
)
|
||||
}
|
||||
break;
|
||||
case 'remove':
|
||||
await apiService.removeApp(app.appName);
|
||||
setApp(null);
|
||||
break;
|
||||
case 'config':
|
||||
setOpenEditor(true);
|
||||
break;
|
||||
case 'secrets':
|
||||
setOpenSecrets(true);
|
||||
break;
|
||||
case 'rollback':
|
||||
if (version) {
|
||||
console.log(version);
|
||||
apiService.rollbackApp(app.appName, version);
|
||||
waitForLogs(app.appName);
|
||||
} else {
|
||||
console.log("no version for rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err);
|
||||
setTerminalLogs(prev => [...prev, {
|
||||
type: 'error',
|
||||
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-detail-page">
|
||||
<Header />
|
||||
<main className="app-detail-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div>Loading application</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !app) {
|
||||
return (
|
||||
<div className="app-detail-page">
|
||||
<Header />
|
||||
<main className="app-detail-content">
|
||||
<div className="error">{error || 'App not found'}</div>
|
||||
<button onClick={() => navigate('/apps')} className="back-button">
|
||||
Back to Apps
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// TODO: make sure this makes sense when app.upgrade is unknown
|
||||
const upgradeVersions = (app.upgrade !== 'latest' && app.upgrade !== 'unknown') ? app.upgrade.split('\n') : [];
|
||||
|
||||
return (
|
||||
<div className="app-detail-page">
|
||||
{openEditor && (
|
||||
<EditorModal onClose={() => setOpenEditor(false)} />
|
||||
)}
|
||||
{openSecrets && (
|
||||
<SecretsModal onClose={() => setOpenSecrets(false)} />
|
||||
)}
|
||||
<Header />
|
||||
<main className="app-detail-content">
|
||||
<div className="back-navigation">
|
||||
<button
|
||||
onClick={() => navigate(`/apps`)}
|
||||
className="server-link"
|
||||
>
|
||||
← Back to Apps
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-header">
|
||||
<div className="app-title-section">
|
||||
<h1>{app.appName}</h1>
|
||||
<div className="app-meta">
|
||||
<span className="recipe-badge">{app.recipe}</span>
|
||||
<span className={`status-badge status-${app.status}`}>{app.status}</span>
|
||||
{app.chaos === 'true' && (
|
||||
<span className="chaos-badge" title="Chaos mode enabled"> Chaos</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-actions">
|
||||
<button
|
||||
className="action-btn danger"
|
||||
onClick={() => handleAction('stop')}
|
||||
disabled={!!actionLoading || deployState === "undeployed"}
|
||||
>
|
||||
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => handleAction('deploy')}
|
||||
disabled={!!actionLoading || deployState === "deployed"}
|
||||
>
|
||||
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Component */}
|
||||
<Terminal
|
||||
logs={terminalLogs}
|
||||
isActive={terminalActive}
|
||||
onClose={() => {
|
||||
stopRef.current?.();
|
||||
stopRef.current = null;
|
||||
setTerminalActive(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="content-grid">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="main-column">
|
||||
<section className="info-card">
|
||||
<h2>Application Details</h2>
|
||||
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>App Name</label>
|
||||
<span>{app.appName}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Domain</label>
|
||||
{app.domain ? (
|
||||
<a href={`https://${app.domain}`} target="_blank" rel="noopener noreferrer" className="link">
|
||||
{app.domain} ↗
|
||||
</a>
|
||||
) : (
|
||||
<span className="no-value">No domain configured</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label htmlFor="server-link">Server</label>
|
||||
<Link
|
||||
id="server-link"
|
||||
to={`/servers/${app.server}`}
|
||||
className="link"
|
||||
>
|
||||
{app.server} ↗
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Recipe</label>
|
||||
<span>{app.recipe}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Status</label>
|
||||
<span className={`status-badge status-${app.status}`}>
|
||||
{app.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Chaos Mode</label>
|
||||
<span className={app.chaos === 'true' ? 'chaos-active' : ''}>
|
||||
{app.chaos === 'true' ? '☠️ Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{(deployState === "deployed" || deployState === "failed") && (
|
||||
<section className="info-card">
|
||||
<h2> Service Information </h2>
|
||||
<div className="service-grid">
|
||||
{services === undefined || services?.length === 0 ? (
|
||||
<div className="no-services"> Loading services... </div>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<div
|
||||
key={service.service}
|
||||
className={getServiceClass(service.state, service.status)}
|
||||
>
|
||||
<div className="service-name">
|
||||
<h3> {service.service} </h3>
|
||||
</div>
|
||||
<div className="service-row">
|
||||
<span className="service-value">{service.state}</span>
|
||||
<span className="service-value">{service.status}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => handleAction('logs', service.service)}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>)}
|
||||
{(deployState === "deploying") && (
|
||||
<section className="info-card">
|
||||
<h2> Service Information </h2>
|
||||
<div className="service-grid">
|
||||
{serviceState === undefined || Object.keys(serviceState).length === 0 ? (
|
||||
<div className="no-services"> Preparing services... </div>
|
||||
) : (
|
||||
Object.keys(serviceState).map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className={getServiceClass(serviceState[name].status, serviceState[name].health)}
|
||||
>
|
||||
<div className="service-name">
|
||||
<h3> {name} </h3>
|
||||
</div>
|
||||
<div className="service-row">
|
||||
<span className="service-value">{serviceState[name].status}</span>
|
||||
<span className="service-value">{serviceState[name].health}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => handleAction('logs', appName)}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>)}
|
||||
<section className="info-card">
|
||||
<h2>Version Information</h2>
|
||||
|
||||
<div className="version-info">
|
||||
<div className="version-current">
|
||||
<label>Current Version</label>
|
||||
<code>{app.version}</code>
|
||||
</div>
|
||||
|
||||
{app.chaosVersion !== 'unknown' && (
|
||||
<div className="version-current">
|
||||
<label>Chaos Version</label>
|
||||
<code>{app.chaosVersion}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upgradeVersions.length > 0 && (
|
||||
<div className="version-upgrades">
|
||||
<label>
|
||||
Available Upgrades
|
||||
<span className="upgrade-count">{upgradeVersions.length}</span>
|
||||
</label>
|
||||
<div className="upgrade-list">
|
||||
{upgradeVersions.map((version, idx) => (
|
||||
<div key={idx} className="upgrade-item">
|
||||
<code>{version}</code>
|
||||
<button
|
||||
className="upgrade-btn"
|
||||
onClick={() => handleAction('upgrade', version)}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
{actionLoading === 'upgrade' ? 'Upgrading...' : 'Upgrade'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.upgrade === 'unknown' && (
|
||||
<div className="version-upgrades">
|
||||
<label>
|
||||
Available Upgrades
|
||||
</label>
|
||||
<div className="upgrade-list">
|
||||
<div className="upgrade-item">
|
||||
<code>None</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{app.upgrade === 'latest' && (
|
||||
<div className="version-latest">
|
||||
✓ Running latest version
|
||||
</div>
|
||||
)}
|
||||
<div className="rollback">
|
||||
<button
|
||||
className="action-btn modify"
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to rollback ${app.appName} to version ${rbVersion}?\nThis could be destructive, please ensure you have a backup beforehand`)) {
|
||||
handleAction('rollback', rbVersion);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rollback To
|
||||
</button>
|
||||
<select
|
||||
className="select-input"
|
||||
name="version"
|
||||
value={rbVersion || ""}
|
||||
onChange={(e) => setRbVersion(e.target.value)}
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
{versions.map((version) => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="info-card">
|
||||
{backupEnabled && (
|
||||
<div>
|
||||
<div className="backup-header">
|
||||
<h2> Backups </h2>
|
||||
<span className={`status-badge status-deployed`}>
|
||||
backupbot deployed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="backup-info">
|
||||
<span>Time Created</span>
|
||||
<span>Snapshot ID</span>
|
||||
<span>Version</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="backup-list">
|
||||
<button
|
||||
className="create-btn"
|
||||
onClick={() => {
|
||||
apiService.createBackup(app.appName)
|
||||
}}
|
||||
aria-label="create new backup"
|
||||
title="create new backup"
|
||||
>
|
||||
<SquarePlus size={20} />
|
||||
</button>
|
||||
{backupSnapshots?.map((snapshot, idx) => (
|
||||
<div key={snapshot.createdAt} className="backup-item">
|
||||
<span> {snapshot.createdAt} </span>
|
||||
<span> {snapshot.snapshot} </span>
|
||||
<code>{snapshot.version}</code>
|
||||
<div className="backup-actions">
|
||||
<button
|
||||
className="backup-btn"
|
||||
onClick={() => handleRestoreSnapshot(app.appName, snapshot.snapshot)}
|
||||
aria-label="Restore backup"
|
||||
title="Restore backup"
|
||||
>
|
||||
<ArchiveRestore size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="backup-btn"
|
||||
onClick={() => handleDownloadSnapshot(app.appName)}
|
||||
aria-label="Download backup"
|
||||
title="Download backup"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
{ /* Not convinced this is necesary...
|
||||
<button
|
||||
className="backup-btn delete-btn"
|
||||
onClick={() => handleDeleteSnapshot()}
|
||||
aria-label="Delete backup"
|
||||
title="Delete backup"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button> */ }
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions & Stats */}
|
||||
<div className="sidebar-column">
|
||||
<section className="info-card">
|
||||
<h2>Quick Actions</h2>
|
||||
|
||||
<div className="action-list">
|
||||
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('deploy')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Deploy Application</span>
|
||||
</button>
|
||||
{ /* TODO: make upgrade have force option*/}
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('upgrade')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Upgrade Application</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('stop')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Stop Application</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="action-list-item danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to remove ${app.appName}?\nThis will delete all data and config`)) {
|
||||
handleAction('remove');
|
||||
}
|
||||
}}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Remove Application</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('config')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Manage Config</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('secrets')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Manage Secrets</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="info-card">
|
||||
<h2>Health Status</h2>
|
||||
<div className="health-status">
|
||||
<div className="health-item">
|
||||
<span className="health-label">CPU Usage</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">Memory</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">Uptime</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
|
||||
// Extend global page wrapper
|
||||
.apps-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.apps-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// Compact stats row
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
@include chip();
|
||||
|
||||
&.active {
|
||||
border-color: $primary-dark;
|
||||
background: $bg-primary;
|
||||
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: $text-primary;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-bold;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* reset-filters-btn removed — outline on stat-chip indicates active filters */
|
||||
|
||||
// Apps table specific styles
|
||||
.apps-table-container {
|
||||
@include scrollable-card;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.apps-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
background-color: $bg-tertiary;
|
||||
|
||||
th {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
text-align: left;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
font-size: $font-size-sm;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid $border-color;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid $border-color;
|
||||
transition: background-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary, 0.05);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-size: $font-size-base;
|
||||
color: $text-primary;
|
||||
|
||||
&.no-results {
|
||||
text-align: center;
|
||||
padding: $spacing-3xl;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App table cell styles
|
||||
.app-name-cell {
|
||||
.app-name {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-primary;
|
||||
}
|
||||
.domain-link {
|
||||
color: $primary-dark;
|
||||
text-decoration: none;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.no-domain {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.version-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.version {
|
||||
font-family: monospace;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.chaos-badge,
|
||||
.upgrade-available {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.action-btn {
|
||||
@include action-btn(1px, $radius-sm, $spacing-xs $spacing-md, $font-size-sm);
|
||||
|
||||
&:hover {
|
||||
background-color: $primary;
|
||||
color: white;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&.upgrade {
|
||||
border-color: $warning;
|
||||
color: $warning;
|
||||
|
||||
&:hover {
|
||||
background-color: $warning;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
// TODOS:
|
||||
// make the two filters non-exlusive
|
||||
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types';
|
||||
import './Apps.scss';
|
||||
|
||||
export const Apps: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterServer, setFilterServer] = useState<string>('all');
|
||||
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
|
||||
const [showChaosOnly, setShowChaosOnly] = useState(false);
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
const data = await mockApiService.getAppsGrouped();
|
||||
setAppsData(data);
|
||||
} else {
|
||||
const data = await apiService.getAppsGrouped();
|
||||
setAppsData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isMockMode]);
|
||||
|
||||
// Flatten and enrich apps data
|
||||
const allApps: AppWithServer[] = useMemo(() => {
|
||||
if (!appsData) return [];
|
||||
|
||||
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
|
||||
serverData.apps.map(app => ({
|
||||
...app,
|
||||
serverStats: {
|
||||
appCount: serverData.appCount,
|
||||
upgradeCount: serverData.upgradeCount,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}, [appsData]);
|
||||
|
||||
// Get unique servers
|
||||
const servers = useMemo(() => {
|
||||
if (!appsData) return [];
|
||||
return Object.keys(appsData);
|
||||
}, [appsData]);
|
||||
|
||||
// Filter apps (additive filters: upgrades AND chaos can be applied together)
|
||||
const filteredApps = useMemo(() => {
|
||||
return allApps.filter(app => {
|
||||
const matchesSearch =
|
||||
(app.appName || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(app.recipe || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesServer = filterServer === 'all' || app.server === filterServer;
|
||||
const matchesChaos = !showChaosOnly || app.chaos === 'true';
|
||||
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest';
|
||||
|
||||
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
|
||||
});
|
||||
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = allApps.length;
|
||||
const needsUpgrade = allApps.filter(app => app.upgrade !== 'latest').length;
|
||||
const chaosApps = allApps.filter(app => app.chaos === 'true').length;
|
||||
const totalServers = servers.length;
|
||||
|
||||
return { total, needsUpgrade, chaosApps, totalServers };
|
||||
}, [allApps, servers]);
|
||||
|
||||
const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev);
|
||||
const toggleChaos = () => setShowChaosOnly(prev => !prev);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="apps-page">
|
||||
<Header />
|
||||
<main className="apps-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div>Loading apps</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="apps-page">
|
||||
<Header />
|
||||
<main className="apps-content">
|
||||
<div className="error">Error: {error}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apps-page">
|
||||
<Header />
|
||||
<main className="apps-content">
|
||||
<div className="page-header">
|
||||
<h1>Applications</h1>
|
||||
<p className="subtitle">{stats.total} apps across {stats.totalServers} servers</p>
|
||||
</div>
|
||||
|
||||
{/* Compact Stats Overview */}
|
||||
<div className="stats-row">
|
||||
<button
|
||||
className="stat-chip"
|
||||
onClick={() => navigate('/servers')}
|
||||
title="View servers"
|
||||
>
|
||||
<span className="stat-label">Servers</span>
|
||||
<span className="stat-value">{stats.totalServers}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
|
||||
onClick={toggleUpgrades}
|
||||
title="Click to toggle apps with upgrades available"
|
||||
disabled={stats.needsUpgrade === 0}
|
||||
>
|
||||
<span className="stat-label">Upgrades</span>
|
||||
<span className="stat-value">{stats.needsUpgrade}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`}
|
||||
onClick={toggleChaos}
|
||||
title="Click to toggle chaos mode apps"
|
||||
disabled={stats.chaosApps === 0}
|
||||
>
|
||||
<span className="stat-label">Chaos</span>
|
||||
<span className="stat-value">{stats.chaosApps}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search apps by name, recipe, or domain..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
|
||||
<select value={filterServer} onChange={(e) => setFilterServer(e.target.value)}>
|
||||
<option value="all">All Servers</option>
|
||||
{servers.map(server => (
|
||||
<option key={server} value={server}>{server}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Apps Table */}
|
||||
<div className="apps-table-container">
|
||||
<table className="apps-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App Name</th>
|
||||
<th>Recipe</th>
|
||||
<th>Domain</th>
|
||||
<th>Server</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
{/* <th>Actions</th> */}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredApps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="no-results">
|
||||
No apps found matching your filters
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredApps.map((app) => (
|
||||
<tr
|
||||
key={`${app.server}/${app.appName}`}
|
||||
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td className="app-name-cell">
|
||||
<span className="app-name">{app.appName}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="recipe-badge">{app.recipe}</span>
|
||||
</td>
|
||||
<td>
|
||||
{app.domain ? (
|
||||
<a
|
||||
href={`https://${app.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="domain-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{app.domain}
|
||||
</a>
|
||||
) : (
|
||||
<span className="no-domain">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="server-badge">{app.server}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="version-cell">
|
||||
<span className="version">{app.version}</span>
|
||||
{app.chaos === 'true' && (
|
||||
<span className="chaos-badge" title="Chaos mode enabled"></span>
|
||||
)}
|
||||
{app.upgrade !== 'latest' && (
|
||||
<span className="upgrade-available" title="Upgrade available">
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${app.status}`}>
|
||||
{app.status}
|
||||
</span>
|
||||
</td>
|
||||
{/* <td>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="action-btn"
|
||||
title="View details"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/apps/${app.server}/${app.appName}`);
|
||||
}}
|
||||
>
|
||||
details
|
||||
</button>
|
||||
{app.upgrade !== 'latest' && (
|
||||
<button
|
||||
className="action-btn upgrade"
|
||||
title="Upgrade"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Handle upgrade action
|
||||
}}
|
||||
>
|
||||
upgrade
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td> */}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="results-count">
|
||||
Showing {filteredApps.length} of {allApps.length} apps
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraApp, AbraServer } from '../../types';
|
||||
import './_Dashboard.scss';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [apps, setApps] = useState<AbraApp[]>([]);
|
||||
const [servers, setServers] = useState<AbraServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isMockMode) {
|
||||
// Use mock API in development
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
const [appsData, serversData] = await Promise.all([
|
||||
mockApiService.getAppsGrouped(),
|
||||
mockApiService.getServers(),
|
||||
]);
|
||||
|
||||
// Flatten the grouped apps data
|
||||
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
|
||||
setApps(flatApps);
|
||||
setServers(serversData);
|
||||
} else {
|
||||
// Use real API in production
|
||||
const [appsData, serversData] = await Promise.all([
|
||||
apiService.getAppsGrouped(),
|
||||
apiService.getServers(),
|
||||
]);
|
||||
|
||||
// Flatten the grouped apps data
|
||||
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
|
||||
setApps(flatApps);
|
||||
setServers(serversData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isMockMode]);
|
||||
|
||||
// Calculate stats
|
||||
const deployedAppsCount = apps.filter(a => a.status === 'deployed').length;
|
||||
const serversWithAppsCount = new Set(apps.map(a => a.server)).size;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<Header />
|
||||
<main className="dashboard-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div>Loading dashboard</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<Header />
|
||||
<main className="dashboard-content">
|
||||
<div className="error">Error: {error}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<Header />
|
||||
<main className="dashboard-content">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<button onClick={() => navigate('/apps')} className="nav-link bland-button">
|
||||
<div className="stat-card">
|
||||
<h3>Apps</h3>
|
||||
<p className="stat-label">
|
||||
{deployedAppsCount} deployed
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button onClick={() => navigate('/servers')} className="nav-link bland-button">
|
||||
<div className="stat-card">
|
||||
<h3>Servers</h3>
|
||||
<p className="stat-label">
|
||||
{serversWithAppsCount} connected
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="recent-apps">
|
||||
<h3>Recent Applications</h3>
|
||||
<div className="apps-list">
|
||||
{apps.slice(0, 5).map((app, index) => (
|
||||
<div
|
||||
key={`${app.server}-${app.appName}-${index}`}
|
||||
className="app-item"
|
||||
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
|
||||
>
|
||||
<div className="app-info">
|
||||
<h4>{app.appName}</h4>
|
||||
<p className="app-domain">{app.domain || 'No domain'}</p>
|
||||
<p className="app-server">{app.server}</p>
|
||||
</div>
|
||||
<span className={`status-badge status-${app.status}`}>
|
||||
{app.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
|
||||
// Extend global page wrapper
|
||||
.dashboard-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// Dashboard-specific styles
|
||||
.recent-apps {
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
h3 {
|
||||
font-size: $font-size-2xl;
|
||||
margin: 0 0 $spacing-lg;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.apps-list {
|
||||
@include card-list-vertical;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
@include card;
|
||||
@include card-hover-lift(-2px, $shadow-lg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.app-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.app-domain {
|
||||
margin: 0 0 $spacing-xs;
|
||||
color: $text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.app-server {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
|
||||
.recipe-form {
|
||||
@include card;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
padding: $spacing-lg;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: $font-size-xl;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
&.field-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
label {
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input:not([type]),
|
||||
select {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-base;
|
||||
background: $bg-primary;
|
||||
color: $text-primary;
|
||||
transition: border-color $transition-base, box-shadow $transition-base;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
box-shadow: 0 0 0 4px rgba($primary, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
justify-content: flex-end;
|
||||
|
||||
.action-btn {
|
||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: $font-size-base;
|
||||
color: $text-primary;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: rgba($error, 0.08);
|
||||
color: $error;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
width: min(100%, 800px);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal .close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 0, 0, 0.308);
|
||||
}
|
||||
|
||||
.modal .close-btn:hover {
|
||||
background: rgba(255, 0, 0, 0.797);
|
||||
}
|
||||
|
||||
.modal .close-btn:focus {
|
||||
outline: 2px solid #888;
|
||||
}
|
||||
|
||||
.secrets table {
|
||||
width: 90%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.secrets th,
|
||||
.secrets td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.secrets th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.secrets tr:nth-child(even) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraServer, AbraAppSecret } from '../../types';
|
||||
import './RecipeForm.scss';
|
||||
|
||||
|
||||
function RecipeForm({ recipe, onClose }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [servers, setServers] = useState<AbraServer[]>([]);
|
||||
const [secretData, setSecretData] = useState<AbraAppSecret[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (servers.length === 0) {
|
||||
const [serversData] = await Promise.all([
|
||||
apiService.getServers(),
|
||||
]);
|
||||
|
||||
setServers(serversData);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load servers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
});
|
||||
const [formData, setFormData] = useState({
|
||||
server: "",
|
||||
domain: "",
|
||||
chaos: false,
|
||||
secrets: false,
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const {name, type, value, checked} = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
console.log(formData);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
console.log("Submitting:", formData);
|
||||
setSubmitted(true);
|
||||
const generatedSecrets = await apiService.newApp(recipe.name, formData);
|
||||
setSecretData(generatedSecrets);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal">
|
||||
<button className="close-btn" onClick={onClose}>x</button>
|
||||
{(secretData.length === 0 && submitted) && (
|
||||
<p> Creating new app...</p>
|
||||
)
|
||||
}
|
||||
{(secretData.length > 0 && submitted) && (
|
||||
<div className="secrets">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secretData.map((secret) => (
|
||||
<tr key={secret.name}>
|
||||
<td>{secret.name}</td>
|
||||
<td>
|
||||
<span>{secret.value}</span>
|
||||
<button
|
||||
className="copy-btn"
|
||||
onClick={() => navigator.clipboard.writeText(secret.value)}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(submitted === false) &&
|
||||
<form className="recipe-form" onSubmit={handleSubmit}>
|
||||
<h2>{recipe.name}</h2>
|
||||
<p className="form-subtitle"> Configure and deploy this recipe.</p>
|
||||
{ error && <div className="form-error">{error}</div>}
|
||||
{ loading ? (<p> Loading servers...</p>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label>
|
||||
Choose a server to deploy to:
|
||||
<select
|
||||
className="select-input"
|
||||
name="server"
|
||||
value={formData.server || ""}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.name} value={server.name}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>
|
||||
Domain:
|
||||
<input
|
||||
name="domain"
|
||||
value={formData.domain}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="chaos"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
Chaos Mode
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="secrets"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
Autogenerate Secrets
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecipeForm;
|
||||
@@ -0,0 +1,154 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
|
||||
// Extend global page wrapper
|
||||
.recipes-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.recipes-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// Recipes grid
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: $spacing-xl;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Recipe card
|
||||
.recipe-card {
|
||||
@include card;
|
||||
@include card-dimensions(320px, 180px);
|
||||
row-gap: 1em;
|
||||
grid-template-rows: 1fr 2fr auto 0.9fr;
|
||||
transition: transform $transition-base, box-shadow $transition-base;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
max-width: 30em;
|
||||
|
||||
.recipe-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 2px solid $bg-secondary;
|
||||
|
||||
.recipe-title {
|
||||
h3 {
|
||||
margin: 0 0 $spacing-xs;
|
||||
font-size: $font-size-xl;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.recipe-host {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-muted;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
img {
|
||||
width: clamp(20px, 3vw, 32px);
|
||||
height: clamp(20px, 3vw, 32px);
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: $spacing-sm;
|
||||
height: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag-category {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
color: rgb(255, 255, 255);
|
||||
font-weight: 700;
|
||||
background-color: rgb(111, 128, 255);
|
||||
|
||||
}
|
||||
.tag-feature {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
color: rgb(255, 255, 255);
|
||||
font-weight: 700;
|
||||
background-color: rgb(22, 180, 22);
|
||||
|
||||
}
|
||||
.recipe-stats {
|
||||
flex-grow: 1;
|
||||
flex: 1;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
.stat-row {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm);
|
||||
|
||||
&.primary {
|
||||
background-color: $primary;
|
||||
color: white;
|
||||
border-color: $primary;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary-light;
|
||||
border-color: $primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-alert {
|
||||
background-color: rgba($warning, 0.1);
|
||||
border-left: 4px solid $warning;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
margin: 0 (-$spacing-xl) (-$spacing-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.alert-icon {
|
||||
font-size: $font-size-lg;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraApp, AbraRecipe, AppWithServer } from '../../types';
|
||||
import RecipeForm from './RecipeForm.tsx'
|
||||
import './Recipes.scss';
|
||||
|
||||
export const Recipes: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [recipesData, setRecipesData] = useState<AbraRecipe[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterServer, setFilterServer] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
const data = await mockApiService.getRecipes();
|
||||
console.log(data)
|
||||
setRecipesData(data);
|
||||
} else {
|
||||
const data = await apiService.getRecipes();
|
||||
setRecipesData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isMockMode]);
|
||||
|
||||
// Flatten and enrich apps data
|
||||
const allRecipes: AbraRecipe[] = useMemo(() => {
|
||||
if (!recipesData) return [];
|
||||
|
||||
return recipesData;
|
||||
}, [recipesData]);
|
||||
|
||||
|
||||
// Filter recipes
|
||||
const filteredRecipes = useMemo(() => {
|
||||
return allRecipes.filter(recipe => {
|
||||
const matchesSearch =
|
||||
recipe.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
}, [allRecipes, searchTerm, filterServer, filterStatus]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = allRecipes.length;
|
||||
|
||||
return { total };
|
||||
}, [allRecipes]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="apps-page">
|
||||
<Header />
|
||||
<main className="recipes-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div>Loading recipes</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="recipes-page">
|
||||
<Header />
|
||||
<main className="recipes-content">
|
||||
<div className="error">Error: {error}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipes-page">
|
||||
<Header />
|
||||
<main className="recipes-content">
|
||||
<div className="page-header">
|
||||
<h1>Recipes</h1>
|
||||
<p className="subtitle">{stats.total} recipes</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search recipes by name or description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Server Cards */}
|
||||
<div className="recipes-grid">
|
||||
{filteredRecipes.length === 0 ? (
|
||||
<div className="no-results">No recipes found matching your search</div>
|
||||
) : (
|
||||
filteredRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.name}
|
||||
className="recipe-card"
|
||||
>
|
||||
<div className="recipe-header">
|
||||
<div className="recipe-title">
|
||||
<h3>{recipe.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
{recipe.icon.length > 0 ? <img src={`${recipe.icon}`} /> : null }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-stats">
|
||||
<div className="stat-row">
|
||||
<span className="stat-value">{recipe.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('clicked');
|
||||
setSelectedRecipe(recipe);
|
||||
console.log('selectedRecipe:', selectedRecipe);
|
||||
}}
|
||||
>
|
||||
Add Recipe
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-tags">
|
||||
{recipe.features.backups.toLowerCase().includes("yes") ? <span className="tag-feature"> Backups </span> : null}
|
||||
{recipe.features.healthcheck.toLowerCase().includes("yes") ? <span className="tag-feature"> Healthcheck </span> : null}
|
||||
{recipe.category.length > 0 ? <span className="tag-category"> {recipe.category} </span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{selectedRecipe && (
|
||||
<div className="modal-overlay" onClick={() => setSelectedRecipe(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{}
|
||||
<RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="results-count">
|
||||
Showing {filteredRecipes.length} of {allRecipes.length} apps
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
@use "sass:color";
|
||||
|
||||
.server-detail-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.server-detail-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// Server header section
|
||||
.server-header {
|
||||
@include card;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xl;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.server-title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 $spacing-md 0;
|
||||
font-size: $font-size-3xl;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.server-meta {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons (shared with App view, could be moved to global)
|
||||
.action-btn {
|
||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
|
||||
|
||||
&.primary {
|
||||
background: $primary;
|
||||
color: white;
|
||||
border-color: $primary;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $primary-light;
|
||||
border-color: $primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: $bg-secondary;
|
||||
color: $text-primary;
|
||||
border-color: $border-color;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($bg-secondary, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: $error;
|
||||
color: white;
|
||||
border-color: $error;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color.adjust($error, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@extend .action-btn;
|
||||
@extend .secondary;
|
||||
}
|
||||
|
||||
// Badges - host badge is specific to server view
|
||||
.host-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
background: $bg-secondary;
|
||||
color: $text-secondary;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.upgrade-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
background: rgba($warning, 0.1);
|
||||
background: color.adjust($error, $lightness: -20%);
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
// Content grid layout
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: $spacing-xl;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Info cards
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
font-size: $font-size-xl;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Info grid
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
label {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-secondary;
|
||||
font-weight: $font-weight-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: $font-size-base;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-primary;
|
||||
|
||||
&.warning {
|
||||
background: color.adjust($warning, $lightness: -10%);
|
||||
}
|
||||
|
||||
&.chaos {
|
||||
background: color.adjust($info, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Apps list in server detail
|
||||
.no-apps {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.apps-list {
|
||||
@include card-list-vertical;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-md;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: white;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.app-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
.app-item-name {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.app-item-badges {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.app-item-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
.app-item-version {
|
||||
color: $text-secondary;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.app-item-domain {
|
||||
color: $primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick actions list
|
||||
.action-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.action-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
border: 2px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: white;
|
||||
border-color: $primary;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $error;
|
||||
background: rgba($error, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.action-text {
|
||||
flex: 1;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health status
|
||||
.health-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.health-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.health-value {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
|
||||
&.status-online {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.status-offline {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { Terminal } from '../../components/Terminal/Terminal';
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraServer, ServerAppsResponse } from '../../types';
|
||||
import type { LogEntry } from '../../services/mockApi';
|
||||
import './Server.scss';
|
||||
|
||||
interface ServerWithApps extends AbraServer {
|
||||
apps: any[];
|
||||
appCount: number;
|
||||
upgradeCount: number;
|
||||
versionCount: number;
|
||||
unversionedCount: number;
|
||||
latestCount: number;
|
||||
}
|
||||
|
||||
export const Server: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [server, setServer] = useState<ServerWithApps | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
// Terminal state
|
||||
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
||||
const [terminalActive, setTerminalActive] = useState(false);
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServer = async () => {
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
const appsData = await mockApiService.getAppsGrouped();
|
||||
const serversData = await mockApiService.getServers();
|
||||
|
||||
const foundServer = serversData.find(s => s.name === serverName);
|
||||
const serverApps = appsData[serverName || ''];
|
||||
|
||||
if (foundServer && serverApps) {
|
||||
setServer({
|
||||
...foundServer,
|
||||
apps: serverApps.apps,
|
||||
appCount: serverApps.appCount,
|
||||
upgradeCount: serverApps.upgradeCount,
|
||||
versionCount: serverApps.versionCount,
|
||||
unversionedCount: serverApps.unversionedCount,
|
||||
latestCount: serverApps.latestCount,
|
||||
});
|
||||
} else {
|
||||
setError('Server not found');
|
||||
}
|
||||
} else {
|
||||
const appsData = await apiService.getAppsGrouped();
|
||||
const serversData = await apiService.getServers();
|
||||
|
||||
const foundServer = serversData.find(s => s.name === serverName);
|
||||
const serverApps = appsData[serverName || ''];
|
||||
|
||||
if (foundServer && serverApps) {
|
||||
setServer({
|
||||
...foundServer,
|
||||
apps: serverApps.apps,
|
||||
appCount: serverApps.appCount,
|
||||
upgradeCount: serverApps.upgradeCount,
|
||||
versionCount: serverApps.versionCount,
|
||||
unversionedCount: serverApps.unversionedCount,
|
||||
latestCount: serverApps.latestCount,
|
||||
});
|
||||
} else {
|
||||
setError('Server not found');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load server');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServer();
|
||||
}, [serverName, isMockMode]);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (!server) return;
|
||||
|
||||
setActionLoading(action);
|
||||
setTerminalActive(true);
|
||||
setTerminalLogs([]);
|
||||
|
||||
try {
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
|
||||
const onLog = (log: LogEntry) => {
|
||||
setTerminalLogs(prev => [...prev, log]);
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'refresh':
|
||||
await mockApiService.refreshServer(server.name, onLog);
|
||||
break;
|
||||
case 'deploy-all':
|
||||
await mockApiService.deployAllApps(server.name, server.appCount, onLog);
|
||||
break;
|
||||
case 'upgrade-all':
|
||||
await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Real API calls
|
||||
console.log(`Action: ${action} on server ${server.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err);
|
||||
setTerminalLogs(prev => [...prev, {
|
||||
type: 'error',
|
||||
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="server-detail-page">
|
||||
<Header />
|
||||
<main className="server-detail-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div> Loading server</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !server) {
|
||||
return (
|
||||
<div className="server-detail-page">
|
||||
<Header />
|
||||
<main className="server-detail-content">
|
||||
<div className="error">{error || 'Server not found'}</div>
|
||||
<button onClick={() => navigate('/servers')} className="back-button">
|
||||
Back to Servers
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chaosApps = server.apps.filter(app => app.chaos === 'true');
|
||||
const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running');
|
||||
|
||||
return (
|
||||
<div className="server-detail-page">
|
||||
<Header />
|
||||
<main className="server-detail-content">
|
||||
<div className="back-navigation">
|
||||
<button
|
||||
onClick={() => navigate(`/servers`)}
|
||||
className="server-link"
|
||||
>
|
||||
← Back to Servers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="server-header">
|
||||
<div className="server-title-section">
|
||||
<h1>{server.name}</h1>
|
||||
<div className="server-meta">
|
||||
<span className="host-badge">{server.host}</span>
|
||||
{server.upgradeCount > 0 && (
|
||||
<span className="upgrade-badge">
|
||||
{server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="server-actions">
|
||||
<button
|
||||
className="action-btn secondary"
|
||||
onClick={() => handleAction('refresh')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
{actionLoading === 'refresh' ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => handleAction('deploy-all')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
{actionLoading === 'deploy-all' ? 'Deploying...' : 'Deploy All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Component */}
|
||||
<Terminal
|
||||
logs={terminalLogs}
|
||||
isActive={terminalActive}
|
||||
onClose={() => setTerminalActive(false)}
|
||||
/>
|
||||
|
||||
<div className="content-grid">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="main-column">
|
||||
<section className="info-card">
|
||||
<h2>Server Details</h2>
|
||||
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Server Name</label>
|
||||
<span>{server.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Host</label>
|
||||
<span>{server.host}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Total Apps</label>
|
||||
<span className="stat-value">{server.appCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Running Apps</label>
|
||||
<span className="stat-value">{runningApps.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Upgrades Available</label>
|
||||
<span className={server.upgradeCount > 0 ? 'stat-value warning' : 'stat-value'}>
|
||||
{server.upgradeCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Chaos Mode Apps</label>
|
||||
<span className={chaosApps.length > 0 ? 'stat-value chaos' : 'stat-value'}>
|
||||
{chaosApps.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Latest Versions</label>
|
||||
<span className="stat-value">{server.latestCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<label>Versioned Apps</label>
|
||||
<span className="stat-value">{server.versionCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="info-card">
|
||||
<h2>Applications on this Server</h2>
|
||||
|
||||
{server.apps.length === 0 ? (
|
||||
<div className="no-apps">No applications deployed on this server</div>
|
||||
) : (
|
||||
<div className="apps-list">
|
||||
{server.apps.map((app, idx) => (
|
||||
<div
|
||||
key={`${app.appName}-${idx}`}
|
||||
className="app-item"
|
||||
onClick={() => navigate(`/apps/${server.name}/${app.appName}`)}
|
||||
>
|
||||
<div className="app-item-header">
|
||||
<span className="app-item-name">{app.appName}</span>
|
||||
<div className="app-item-badges">
|
||||
<span className="recipe-badge">{app.recipe}</span>
|
||||
<span className={`status-badge status-${app.status}`}>
|
||||
{app.status}
|
||||
</span>
|
||||
{app.chaos === 'true' && (
|
||||
<span className="chaos-badge">☠️</span>
|
||||
)}
|
||||
{app.upgrade !== 'latest' && (
|
||||
<span className="upgrade-badge">⬆️</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-item-details">
|
||||
<span className="app-item-version">v{app.version}</span>
|
||||
{app.domain && (
|
||||
<a
|
||||
href={`https://${app.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-item-domain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{app.domain}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions & Stats */}
|
||||
<div className="sidebar-column">
|
||||
<section className="info-card">
|
||||
<h2>Quick Actions</h2>
|
||||
|
||||
<div className="action-list">
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('refresh')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Refresh Server Info</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('deploy-all')}
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<span className="action-text">Deploy All Apps</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="action-list-item"
|
||||
onClick={() => handleAction('upgrade-all')}
|
||||
disabled={!!actionLoading || server.upgradeCount === 0}
|
||||
>
|
||||
<span className="action-text">Upgrade All Apps</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="info-card">
|
||||
<h2>Server Health</h2>
|
||||
<div className="health-status">
|
||||
<div className="health-item">
|
||||
<span className="health-label">Status</span>
|
||||
<span className="health-value status-online">🟢 Online</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">CPU Usage</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">Memory</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">Disk Space</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
<div className="health-item">
|
||||
<span className="health-label">Uptime</span>
|
||||
<span className="health-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
@use '../../assets/scss/variables' as *;
|
||||
@use '../../assets/scss/mixins' as *;
|
||||
@use '../../assets/scss/global' as *;
|
||||
|
||||
// Extend global page wrapper
|
||||
.servers-page {
|
||||
@extend .page-wrapper;
|
||||
}
|
||||
|
||||
.servers-content {
|
||||
@extend .page-content;
|
||||
}
|
||||
|
||||
// Servers grid
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: $spacing-xl;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// Server card
|
||||
.server-card {
|
||||
@include card;
|
||||
@include card-hover-lift(-4px, $shadow-xl);
|
||||
@include card-dimensions(320px, 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.server-header {
|
||||
@include card-header-rule;
|
||||
|
||||
.server-title {
|
||||
@include card-title-stack;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
.status-indicator {
|
||||
font-size: $font-size-xl;
|
||||
|
||||
&.connected {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
flex: 1;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
.stat-row {
|
||||
@include card-stat-row;
|
||||
|
||||
// Highlighted rows
|
||||
&.highlight {
|
||||
@include card-row-highlight-bleed;
|
||||
background-color: rgba($warning, 0.05);
|
||||
|
||||
.stat-label {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
&.chaos-row {
|
||||
@include card-row-highlight-bleed(false);
|
||||
background-color: rgba($info, 0.05);
|
||||
|
||||
.stat-label {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
|
||||
.upgrade-icon,
|
||||
.chaos-icon {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm);
|
||||
|
||||
&.primary {
|
||||
// background-color: $primary;
|
||||
// color: white;
|
||||
// border-color: $primary;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary-light;
|
||||
border-color: $primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-alert {
|
||||
background-color: rgba($warning, 0.1);
|
||||
border-left: 4px solid $warning;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
margin: 0 (-$spacing-xl) (-$spacing-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.alert-icon {
|
||||
font-size: $font-size-lg;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Header } from '../../components/Header/Header';
|
||||
import { Loader } from '../../components/Loader/Loader';
|
||||
import { apiService } from '../../services/api';
|
||||
import type { AbraServer, ServerAppsResponse } from '../../types';
|
||||
import './Servers.scss';
|
||||
|
||||
interface ServerWithStats extends AbraServer {
|
||||
appCount: number;
|
||||
versionCount: number;
|
||||
latestCount: number;
|
||||
upgradeCount: number;
|
||||
chaosCount: number;
|
||||
backupEnabled: boolean;
|
||||
}
|
||||
|
||||
export const Servers: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [servers, setServers] = useState<ServerWithStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name');
|
||||
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
|
||||
const [showChaosOnly, setShowChaosOnly] = useState(false);
|
||||
|
||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
let serversData, appsData;
|
||||
|
||||
if (isMockMode) {
|
||||
const { mockApiService } = await import('../../services/mockApi');
|
||||
[serversData, appsData] = await Promise.all([
|
||||
mockApiService.getServers(),
|
||||
mockApiService.getAppsGrouped(),
|
||||
]);
|
||||
} else {
|
||||
[serversData, appsData] = await Promise.all([
|
||||
apiService.getServers(),
|
||||
apiService.getAppsGrouped(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Enrich servers with stats from apps data
|
||||
const enrichedServers = serversData.map(server => {
|
||||
const serverStats = appsData[server.name];
|
||||
const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0;
|
||||
|
||||
return {
|
||||
...server,
|
||||
appCount: serverStats?.appCount || 0,
|
||||
versionCount: serverStats?.versionCount || 0,
|
||||
latestCount: serverStats?.latestCount || 0,
|
||||
upgradeCount: serverStats?.upgradeCount || 0,
|
||||
chaosCount,
|
||||
backupEnabled: serverStats?.backupEnabled
|
||||
};
|
||||
});
|
||||
|
||||
setServers(enrichedServers);
|
||||
} catch (err) {
|
||||
console.error('Error loading servers:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load servers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isMockMode]);
|
||||
|
||||
// Calculate overall stats
|
||||
const stats = useMemo(() => {
|
||||
const totalServers = servers.length;
|
||||
const totalApps = servers.reduce((sum, s) => sum + s.appCount, 0);
|
||||
const totalUpgrades = servers.reduce((sum, s) => sum + s.upgradeCount, 0);
|
||||
const totalChaos = servers.reduce((sum, s) => sum + s.chaosCount, 0);
|
||||
|
||||
return { totalServers, totalApps, totalUpgrades, totalChaos };
|
||||
}, [servers]);
|
||||
|
||||
// Filter and sort servers (additive filters allowed)
|
||||
const filteredServers = useMemo(() => {
|
||||
const filtered = servers.filter(server => {
|
||||
const matchesSearch =
|
||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
server.host.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0;
|
||||
const matchesChaos = !showChaosOnly || server.chaosCount > 0;
|
||||
return matchesSearch && matchesUpgrades && matchesChaos;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'apps':
|
||||
return b.appCount - a.appCount;
|
||||
case 'name':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [servers, searchTerm, sortBy, showUpgradesOnly]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="servers-page">
|
||||
<Header />
|
||||
<main className="servers-content">
|
||||
<div className="loading">
|
||||
<Loader />
|
||||
<div> Loading servers</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="servers-page">
|
||||
<Header />
|
||||
<main className="servers-content">
|
||||
<div className="error">Error: {error}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="servers-page">
|
||||
<Header />
|
||||
<main className="servers-content">
|
||||
<div className="page-header">
|
||||
<h1>Servers</h1>
|
||||
<p className="subtitle">Managing {stats.totalServers} servers with {stats.totalApps} applications</p>
|
||||
</div>
|
||||
|
||||
{/* Compact Stats Row */}
|
||||
<div className="stats-row">
|
||||
<button
|
||||
className="stat-chip"
|
||||
onClick={() => navigate('/apps')}
|
||||
title="View all apps"
|
||||
>
|
||||
<span className="stat-label">Apps</span>
|
||||
<span className="stat-value">{stats.totalApps}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowUpgradesOnly(prev => !prev)}
|
||||
title="Click to filter by servers with upgrades"
|
||||
disabled={stats.totalUpgrades === 0}
|
||||
>
|
||||
<span className="stat-label">Upgrades</span>
|
||||
<span className="stat-value">{stats.totalUpgrades}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowChaosOnly(prev => !prev)}
|
||||
title="Click to filter servers with chaos apps"
|
||||
disabled={stats.totalChaos === 0}
|
||||
>
|
||||
<span className="stat-label">Chaos</span>
|
||||
<span className="stat-value">{stats.totalChaos}</span>
|
||||
</button>
|
||||
|
||||
{/* Clear filters button removed — use stat-chip outlines for active filters */}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search servers by name or host..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Cards */}
|
||||
<div className="servers-grid">
|
||||
{filteredServers.length === 0 ? (
|
||||
<div className="no-results">No servers found matching your search</div>
|
||||
) : (
|
||||
filteredServers.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="server-card"
|
||||
onClick={() => navigate(`/servers/${server.name}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="server-header">
|
||||
<div className="server-title">
|
||||
<h3>{server.name}</h3>
|
||||
<span className="server-host">{server.host}</span>
|
||||
</div>
|
||||
<div className="server-status">
|
||||
<span className="status-indicator connected" title="Connected">●</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="server-stats">
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Applications</span>
|
||||
<span className="stat-value">{server.appCount}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Versioned</span>
|
||||
<span className="stat-value">{server.versionCount}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Latest</span>
|
||||
<span className="stat-value">{server.latestCount}</span>
|
||||
</div>
|
||||
{server.upgradeCount > 0 && (
|
||||
<div className="stat-row highlight">
|
||||
<span className="stat-label">
|
||||
Need Upgrade
|
||||
</span>
|
||||
<span className="stat-value">{server.upgradeCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{server.chaosCount > 0 && (
|
||||
<div className="stat-row chaos-row">
|
||||
<span className="stat-label">
|
||||
Chaos Mode
|
||||
</span>
|
||||
<span className="stat-value">{server.chaosCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!server.backupEnabled && (
|
||||
<div className="server-alert">
|
||||
<span className="alert-text">
|
||||
No Backups Enabled
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="results-count">
|
||||
Showing {filteredServers.length} of {servers.length} servers
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { AbraServer, AbraRecipe, ServerAppsResponse, AbraAppService, DeployEvent, AbraAppSecret, File, AppSnapshot } from '../types';
|
||||
|
||||
// Log entry type
|
||||
export type LogEntry = {
|
||||
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
// Helper to process log JSON from API response
|
||||
const processLogResponse = (logData: any[]): LogEntry[] => {
|
||||
return logData.map(log => ({
|
||||
type: log.type,
|
||||
text: log.text,
|
||||
timestamp: new Date(log.timestamp || Date.now()),
|
||||
}));
|
||||
};
|
||||
|
||||
class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
URLQueryString?: string
|
||||
): Promise<T> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const queryString = URLQueryString ?? ""
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}` + queryString, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text() as unknown as T;
|
||||
}
|
||||
|
||||
private stream<T>(
|
||||
endpoint: string,
|
||||
handlers: {
|
||||
onMessage: (data: T) => void;
|
||||
onError?: (err: any) => void;
|
||||
onOpen?: () => void;
|
||||
parser?: (raw: string) => T;
|
||||
}
|
||||
) {
|
||||
const es = new EventSource(`${API_BASE_URL}${endpoint}`);
|
||||
|
||||
es.onopen = () => {
|
||||
handlers.onOpen?.();
|
||||
};
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = handlers.parser
|
||||
? handlers.parser(event.data)
|
||||
: (event.data as unknown as T);
|
||||
|
||||
handlers.onMessage(data);
|
||||
} catch (err) {
|
||||
handlers.onError?.(err);
|
||||
}
|
||||
};
|
||||
es.onerror = (err) => {
|
||||
handlers.onError?.(err);
|
||||
es.close();
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
private async file(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Blob> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
// Get Logs for service
|
||||
getLogs(appName: string, serviceName: string, msgHandler: (data: String) => void) {
|
||||
return this.stream(`/apps/${appName}/${serviceName}/logs`, {onMessage: msgHandler})
|
||||
}
|
||||
|
||||
async getConfig(appName: string): Promise<string> {
|
||||
const file = await this.request<File>(`/apps/${appName}/config`)
|
||||
return file.content
|
||||
}
|
||||
|
||||
async writeConfig(appName: string, code: string): Promise<void> {
|
||||
const file : File = { content: code };
|
||||
return this.request<void>(`/apps/${appName}/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(file),
|
||||
})
|
||||
}
|
||||
|
||||
async getSecrets(appName: string): Promise<AbraAppSecret[]> {
|
||||
return this.request<AbraAppSecret[]>(`/apps/${appName}/secret`);
|
||||
}
|
||||
|
||||
async writeSecret(appName: string, secretName: string, version: string, value: string): Promise<void> {
|
||||
console.log("writing secret ", appName, secretName, version, value);
|
||||
return this.request<void>(`/apps/${appName}/secret`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify( {
|
||||
'name': secretName,
|
||||
'version': version,
|
||||
'value': value,
|
||||
})
|
||||
});
|
||||
}
|
||||
async downloadSnapshot(appName: string): Promise<Blob> {
|
||||
return this.file(`/apps/${appName}/backup/download`)
|
||||
}
|
||||
|
||||
// Get all apps grouped by server
|
||||
async getAppsGrouped(): Promise<ServerAppsResponse> {
|
||||
return this.request<ServerAppsResponse>('/apps');
|
||||
}
|
||||
|
||||
// Get all servers
|
||||
async getServers(): Promise<AbraServer[]> {
|
||||
return this.request<AbraServer[]>('/servers');
|
||||
}
|
||||
// Get services for app
|
||||
async getServices(appName: string): Promise<AbraAppService[]> {
|
||||
return this.request<AbraAppService[]>(`/apps/${appName}/services`);
|
||||
}
|
||||
|
||||
// Get all backups for an app
|
||||
async getBackupList(appName: string): Promise<AppSnapshot[]> {
|
||||
return this.request<AppSnapshot[]>(`/apps/${appName}/backup/list`);
|
||||
}
|
||||
// Create backup for an app
|
||||
async createBackup (appName: string): Promise<void> {
|
||||
return this.request<void>(`/apps/${appName}/backup/create`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
// restore backup for app
|
||||
async restoreBackup (appName: string, snapshot?: string): Promise<void> {
|
||||
const response = await this.request<void>(`/apps/${appName}/backup/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({snapshot: snapshot}),
|
||||
});
|
||||
return response
|
||||
}
|
||||
// App actions with log streaming (websocket future)
|
||||
async deployApp(appName: string): Promise<void> {
|
||||
return this.request<void>(`/apps/${appName}/deploy`)
|
||||
}
|
||||
deployLogs(appName: string, msgHandler: (data: DeployEvent) => void) {
|
||||
return this.stream(`/apps/${appName}/deploy`, {parser: JSON.parse, onMessage: msgHandler})
|
||||
}
|
||||
|
||||
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeApp(appName: string, version?: string, force?: boolean, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/upgrade`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version: version }),
|
||||
},
|
||||
"?" + new URLSearchParams({force: force ? 'true' : 'false'})
|
||||
);
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
|
||||
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/remove`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
async rollbackApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/rollback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version: version }),
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
async newApp(appName: string, formData: Object): Promise<AbraSecret[]> {
|
||||
const response = await this.request<AbraSecret[]>(`/apps/${appName}/new`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
return response
|
||||
}
|
||||
async getAppVersions(app: string): Promise<string[]> {
|
||||
return this.request<string[]>(`/apps/${app}/versions`);
|
||||
}
|
||||
|
||||
// Server actions with log streaming
|
||||
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
|
||||
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (onLog && response.logs) {
|
||||
const logs = processLogResponse(response.logs);
|
||||
logs.forEach(log => onLog(log));
|
||||
}
|
||||
}
|
||||
// recipe catalog imports
|
||||
async getRecipes(): Promise<AbraRecipe[]> {
|
||||
return this.request<AbraRecipe[]>('/catalogue');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const apiService = new ApiService();
|
||||
@@ -0,0 +1,593 @@
|
||||
{
|
||||
"mydomain.com": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "mydomain.com",
|
||||
"recipe": "authentik",
|
||||
"appName": "accounts.mydomain.com",
|
||||
"domain": "accounts.mydomain.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "7.4.0+2025.6.3",
|
||||
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4"
|
||||
},
|
||||
{
|
||||
"server": "mydomain.com",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.mydomain.com",
|
||||
"domain": "",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "2.3.0+2.3.0-beta",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "mydomain.com",
|
||||
"recipe": "collabora",
|
||||
"appName": "docs.mydomain.com",
|
||||
"domain": "docs.mydomain.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "3.2.0+24.04.12.3.1",
|
||||
"version": "3.2.0+24.04.12.3.1",
|
||||
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1"
|
||||
},
|
||||
{
|
||||
"server": "mydomain.com",
|
||||
"recipe": "nextcloud",
|
||||
"appName": "nc.mydomain.com",
|
||||
"domain": "nc.mydomain.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "12.0.1+31.0.6-fpm",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "mydomain.com",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.mydomain.com",
|
||||
"domain": "traefik.mydomain.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "3.4.2+v3.4.5",
|
||||
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
|
||||
}
|
||||
],
|
||||
"appCount": 5,
|
||||
"versionCount": 5,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 2,
|
||||
"upgradeCount": 3
|
||||
},
|
||||
"coop.test.org": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "cryptpad",
|
||||
"appName": "cryptpad.coop.test.org",
|
||||
"domain": "cryptpad.coop.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "cb2a47fb",
|
||||
"version": "0.4.0+version-2024.3.0",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "garage",
|
||||
"appName": "garage.coop.test.org",
|
||||
"domain": "garage.coop.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "3a729d56+U",
|
||||
"version": "3a729d56+U",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "mobilizon",
|
||||
"appName": "mobilizon.test.org",
|
||||
"domain": "mobilizon.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "f8f874a5 + unstaged changes",
|
||||
"version": "0.2.1+5.1.2",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "owncast",
|
||||
"appName": "tv.test.org",
|
||||
"domain": "tv.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "0.3.0+0.2.1",
|
||||
"version": "0.3.0+0.2.1",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "privatebin",
|
||||
"appName": "paste.test.org",
|
||||
"domain": "paste.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "0.6.1+0.17.4",
|
||||
"version": "0.6.0+1.7.6",
|
||||
"upgrade": "0.6.1+0.17.4"
|
||||
},
|
||||
{
|
||||
"server": "coop.test.org",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.coop.test.org",
|
||||
"domain": "traefik.coop.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "27d5c092",
|
||||
"version": "27d5c092",
|
||||
"upgrade": "latest"
|
||||
}
|
||||
],
|
||||
"appCount": 6,
|
||||
"versionCount": 6,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 5,
|
||||
"upgradeCount": 1
|
||||
},
|
||||
"internal.website.com": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "internal.website.com",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.internal.website.com",
|
||||
"domain": "backup.internal.website.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.website.com",
|
||||
"recipe": "baserow",
|
||||
"appName": "base.website.com",
|
||||
"domain": "base.website.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "1.1.0+1.31.1",
|
||||
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
|
||||
},
|
||||
{
|
||||
"server": "internal.website.com",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.internal.website.com",
|
||||
"domain": "traefik.internal.website.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "3.6.2+v3.4.5",
|
||||
"upgrade": "latest"
|
||||
}
|
||||
],
|
||||
"appCount": 3,
|
||||
"versionCount": 3,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 2,
|
||||
"upgradeCount": 1
|
||||
},
|
||||
"internal.server.net": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "authentik",
|
||||
"appName": "accounts.server.net",
|
||||
"domain": "accounts.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "6.11.0+2024.10.5",
|
||||
"version": "6.11.0+2024.10.5",
|
||||
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "authentik",
|
||||
"appName": "accounts.test.server.net",
|
||||
"domain": "accounts.test.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "7.1.0+2025.2.4",
|
||||
"version": "7.1.0+2025.2.4",
|
||||
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.server.net",
|
||||
"domain": "",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "2.3.0+2.3.0-beta",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "custom-html",
|
||||
"appName": "counterspy.zip",
|
||||
"domain": "counterspy.zip",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "1.11.0+1.29.0",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "garage",
|
||||
"appName": "garage.server.net",
|
||||
"domain": "garage.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "20bfd2c6+U",
|
||||
"version": "20bfd2c6+U",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "gitlab",
|
||||
"appName": "git.server.net",
|
||||
"domain": "git.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "0.2.2+18.3.0-ce.0",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "jitsi",
|
||||
"appName": "jitsi.server.net",
|
||||
"domain": "jitsi.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "d3dd84ab + unstaged changes",
|
||||
"version": "0.0.1+8719",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "liberaforms",
|
||||
"appName": "forms.test.server.net",
|
||||
"domain": "forms.test.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "b3e74fa7+U",
|
||||
"version": "b3e74fa7+U",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "mattermost",
|
||||
"appName": "mattermost-demo.server.net",
|
||||
"domain": "mattermost-demo.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "52580808",
|
||||
"version": "52580808",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "openemr",
|
||||
"appName": "openemr.server.net",
|
||||
"domain": "openemr.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "6b62e331",
|
||||
"version": "6b62e331",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "outline",
|
||||
"appName": "wiki.server.net",
|
||||
"domain": "wiki.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "ab70b3c4+U",
|
||||
"version": "ab70b3c4+U",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "statping",
|
||||
"appName": "status.server.net",
|
||||
"domain": "status.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "bd875e08",
|
||||
"version": "0.1.1+v0.90.78",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.server.net",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.server.net",
|
||||
"domain": "traefik.server.net",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "3.6.2+v3.4.5",
|
||||
"upgrade": "latest"
|
||||
}
|
||||
],
|
||||
"appCount": 13,
|
||||
"versionCount": 13,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 11,
|
||||
"upgradeCount": 2
|
||||
},
|
||||
"internal.intranet.site.com": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "internal.intranet.site.com",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.intranet.site.com",
|
||||
"domain": "",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "2.3.0+2.3.0-beta",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.intranet.site.com",
|
||||
"recipe": "baserow",
|
||||
"appName": "base.intranet.site.com",
|
||||
"domain": "base.intranet.site.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "1.1.0+1.31.1",
|
||||
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
|
||||
},
|
||||
{
|
||||
"server": "internal.intranet.site.com",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.intranet.site.com",
|
||||
"domain": "traefik.intranet.site.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "3.4.2+v3.4.5",
|
||||
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
|
||||
},
|
||||
{
|
||||
"server": "internal.intranet.site.com",
|
||||
"recipe": "vaultwarden",
|
||||
"appName": "pass.intranet.site.com",
|
||||
"domain": "pass.intranet.site.com",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "2.0.0+1.33.2",
|
||||
"upgrade": "2.1.0+1.34.1"
|
||||
}
|
||||
],
|
||||
"appCount": 4,
|
||||
"versionCount": 4,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 1,
|
||||
"upgradeCount": 3
|
||||
},
|
||||
"internal.test.org": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "authentik",
|
||||
"appName": "accounts.test.org",
|
||||
"domain": "accounts.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "9.0.0+2025.8.1",
|
||||
"upgrade": "9.0.1+2025.8.1"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.test.org",
|
||||
"domain": "",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "collabora",
|
||||
"appName": "docs.test.org",
|
||||
"domain": "docs.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "3.1.0+24.04.10.2.1",
|
||||
"version": "3.1.0+24.04.10.2.1",
|
||||
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "custom-html",
|
||||
"appName": "test.org",
|
||||
"domain": "test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "ba1c2269",
|
||||
"version": "1.7.1+1.27.2",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "garage",
|
||||
"appName": "garage.test.org",
|
||||
"domain": "garage.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "9ca66f0f",
|
||||
"version": "9ca66f0f",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "nextcloud",
|
||||
"appName": "nc.test.org",
|
||||
"domain": "nc.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "11.0.0+30.0.4-fpm",
|
||||
"version": "11.0.0+30.0.4-fpm",
|
||||
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm\n11.1.0+30.0.6-fpm\n11.0.1+30.0.4-fpm"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "outline",
|
||||
"appName": "wiki.test.org",
|
||||
"domain": "wiki.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "2.8.1+0.81.1",
|
||||
"version": "2.8.1+0.81.1",
|
||||
"upgrade": "2.12.1+0.87.4\n2.12.0+0.87.3\n2.11.0+0.86.1\n2.10.0+0.84.0\n2.9.1+0.82.0\n2.9.0+0.82.0\n2.8.2+0.81.1"
|
||||
},
|
||||
{
|
||||
"server": "internal.test.org",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.test.org",
|
||||
"domain": "traefik.test.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "993ed9cf",
|
||||
"version": "993ed9cf",
|
||||
"upgrade": "latest"
|
||||
}
|
||||
],
|
||||
"appCount": 8,
|
||||
"versionCount": 8,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 4,
|
||||
"upgradeCount": 4
|
||||
},
|
||||
"orgsite.org": {
|
||||
"apps": [
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "authentik",
|
||||
"appName": "accounts.orgsite.org",
|
||||
"domain": "accounts.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "6.11.0+2024.10.5",
|
||||
"version": "6.11.0+2024.10.5",
|
||||
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "backup-bot-two",
|
||||
"appName": "backup.orgsite.org",
|
||||
"domain": "",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "2.3.0+2.3.0-beta",
|
||||
"version": "2.3.0+2.3.0-beta",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "baserow",
|
||||
"appName": "base.orgsite.org",
|
||||
"domain": "base.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "1.1.0+1.31.1",
|
||||
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "collabora",
|
||||
"appName": "docs.orgsite.org",
|
||||
"domain": "docs.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "3.1.0+24.04.10.2.1",
|
||||
"version": "3.1.0+24.04.10.2.1",
|
||||
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "jitsi",
|
||||
"appName": "meet.orgsite.org",
|
||||
"domain": "meet.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "d3dd84ab",
|
||||
"version": "0.0.1+8719",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "mattermost",
|
||||
"appName": "chat.orgsite.org",
|
||||
"domain": "chat.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "true",
|
||||
"chaosVersion": "5f0295b9",
|
||||
"version": "2.1.0+10.5.0",
|
||||
"upgrade": "latest"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "nextcloud",
|
||||
"appName": "nc.orgsite.org",
|
||||
"domain": "nc.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "11.1.0+30.0.6-fpm",
|
||||
"version": "11.1.0+30.0.6-fpm",
|
||||
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "traefik",
|
||||
"appName": "traefik.orgsite.org",
|
||||
"domain": "traefik.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "3.4.2+v3.4.5",
|
||||
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
|
||||
},
|
||||
{
|
||||
"server": "orgsite.org",
|
||||
"recipe": "vaultwarden",
|
||||
"appName": "pass.orgsite.org",
|
||||
"domain": "pass.orgsite.org",
|
||||
"status": "deployed",
|
||||
"chaos": "false",
|
||||
"chaosVersion": "unknown",
|
||||
"version": "2.0.0+1.33.2",
|
||||
"upgrade": "2.1.0+1.34.1"
|
||||
}
|
||||
],
|
||||
"appCount": 9,
|
||||
"versionCount": 9,
|
||||
"unversionedCount": 0,
|
||||
"latestCount": 3,
|
||||
"upgradeCount": 6
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user