forked from toolshed/coop-cloud-backend
Compare commits
53
Commits
dockerize
...
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 | ||
|
|
fc4535aafb | ||
|
|
916c3036de | ||
|
|
3d658103b6 | ||
|
|
619b107a49 | ||
|
|
4a8d4b281c | ||
|
|
e93281f4eb | ||
|
|
79ad1c450a | ||
|
|
cad24f4001 | ||
|
|
052e327182 | ||
|
|
aa1bd46039 | ||
|
|
491d9ffc10 | ||
|
|
d46144c4f2 | ||
|
|
22178dbc7d | ||
|
|
941ee2955c | ||
|
|
82269195ed | ||
|
|
39b4e323dd | ||
|
|
da0863fda7 | ||
|
|
fbf5b90cf8 | ||
|
|
fb72298f71 | ||
|
|
d753bf873d | ||
|
|
3fbb283624 | ||
|
|
8e55df779e | ||
|
|
23775a9221 | ||
|
|
d088a4be7b | ||
|
|
131161b262 | ||
|
|
cf55b7908f | ||
|
|
4f2880d0df |
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: coopcloud.tech/abra-wizard
|
||||
steps:
|
||||
|
||||
- name: publish image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
auto_tag: true
|
||||
username: abra-wizard-bot
|
||||
password:
|
||||
from_secret: git_coopcloud_tech_token_abra_wizard
|
||||
repo: git.coopcloud.tech/toolshed/coop-cloud-backend
|
||||
registry: git.coopcloud.tech
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,5 @@
|
||||
dot_abra/
|
||||
ssh_config
|
||||
id_*
|
||||
app
|
||||
coop-cloud-backend
|
||||
+28
-11
@@ -1,15 +1,32 @@
|
||||
FROM golang:1.25
|
||||
RUN mkdir /backend
|
||||
COPY . /backend/
|
||||
RUN go build -C /backend -o app
|
||||
EXPOSE 3000
|
||||
COPY --parents ["cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
|
||||
RUN go build -C /backend -o gobackend
|
||||
|
||||
FROM node
|
||||
RUN mkdir /frontend
|
||||
WORKDIR /frontend
|
||||
RUN git clone https://git.coopcloud.tech/BornDeleuze/coop-cloud-front
|
||||
EXPOSE 5173
|
||||
FROM git.coopcloud.tech/toolshed/abra:latest AS abra
|
||||
|
||||
COPY ./start.sh /
|
||||
COPY --from=0 /backend/app /backend/app
|
||||
ENTRYPOINT [ "/start.sh" ]
|
||||
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 --from=abra /abra /usr/local/bin/abra
|
||||
COPY ./command.sh /command.sh
|
||||
|
||||
WORKDIR /home/node/wizard/web
|
||||
USER root
|
||||
RUN npm install .
|
||||
RUN chown -R node /home/node/
|
||||
USER node
|
||||
|
||||
EXPOSE 5173 3000
|
||||
# ENV VITE_API_URL=http://localhost:3000/api
|
||||
ENV VITE_MOCK_AUTH=false
|
||||
CMD [ "/command.sh" ]
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
# coop-cloud-backend
|
||||
A Go service that exposes RESTful API endpoints to manage Abra programmatically.
|
||||
Integrates with https://git.coopcloud.tech/BornDeleuze/coop-cloud-front.
|
||||
Integrates with https://git.coopcloud.tech/toolshed/coop-cloud-front.
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
- Edit the front-end application to turn off mock mode in `src/routes/Authenticated/App.tsx` and `src/routes/Authenticated/Apps.tsx`.
|
||||
## Starting the service with Docker
|
||||
Pull the container:
|
||||
```bash
|
||||
docker pull git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
|
||||
```
|
||||
Run the container:
|
||||
```bash
|
||||
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`
|
||||
- Launch the front-end application `npm run dev`
|
||||
- Start this Go app `go run .`
|
||||
- Navigate to the React App (http://localhost:5173)
|
||||
|
||||
> [!WARNING]
|
||||
> This is an extremely early prototype, only viewing apps is supported
|
||||
> and may fail for your local machine
|
||||
|
||||
# Roadmap
|
||||
## General Roadmap:
|
||||
TODO
|
||||
Repackaging
|
||||
## Basic Abra Features
|
||||
| Feature | Status | Follow-up |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| More options for app deploy (esp chaos) | 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 | |
|
||||
|
||||
-433
@@ -1,433 +0,0 @@
|
||||
package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
configPkg "coopcloud.tech/abra/pkg/config"
|
||||
deployPkg "coopcloud.tech/abra/pkg/deploy"
|
||||
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"net/http"
|
||||
|
||||
"coop-cloud-backend/internal"
|
||||
)
|
||||
type abraHandler struct{
|
||||
mux *http.ServeMux
|
||||
}
|
||||
func newAbraHandler() *abraHandler {
|
||||
h := &abraHandler{
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
h.mux.HandleFunc("/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
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("/apps/{serverId}/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method{
|
||||
case http.MethodPost:
|
||||
h.handleDeployApp(w, r, r.PathValue("appId"), r.PathValue("serverId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
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)
|
||||
}
|
||||
})
|
||||
return h
|
||||
}
|
||||
func StartAPI() {
|
||||
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)
|
||||
|
||||
// Delegate to internal mux
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
// func (h *appHandler) handleStartApp(w http.ResponseWriter, r *http.Request) {
|
||||
// appName := r.PathValue("appName")
|
||||
// w.Write([]byte("starting app: " + appName))
|
||||
|
||||
// }
|
||||
|
||||
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string, serverId string) {
|
||||
log.Printf("App Id: %s | Server Id: %s", appName, serverId)
|
||||
app, err := GetApp(appName)
|
||||
if err != nil {
|
||||
log.Printf("Error getting app %s: %s\n", appName, err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
if deployMeta.IsDeployed {
|
||||
log.Fatal("%s is already deployed", app.Name)
|
||||
InternalServerErrorHandler(w, r)
|
||||
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.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSecrets(cl, app); err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deployPkg.MergeAbraShEnv(app.Recipe, app.Env); err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
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.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
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.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
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 := configPkg.MISSING_DEFAULT
|
||||
if deployMeta.IsDeployed {
|
||||
deployedVersion = deployMeta.Version
|
||||
if deployMeta.IsChaos {
|
||||
deployedVersion = deployMeta.ChaosVersion
|
||||
}
|
||||
}
|
||||
ShowUnchanged := false
|
||||
secretInfo, err := deployPkg.GatherSecretsForDeploy(cl, app, ShowUnchanged)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Gather configs
|
||||
configInfo, err := deployPkg.GatherConfigsForDeploy(cl, app, compose, app.Env, ShowUnchanged)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Gather images
|
||||
imageInfo, err := deployPkg.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.Fatal(err)
|
||||
}
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 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.Fatal(err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
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("GetAppStatuses Falied\n")
|
||||
fmt.Println("Error: ", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
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)
|
||||
serverApps.Apps = append(serverApps.Apps, appTransposeUndeployed(app))
|
||||
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("Error converting to json: %s\n", err)
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
func appTransposeUndeployed(app appPkg.App) AbraApp {
|
||||
config, err := app.Recipe.GetComposeConfig(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(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",
|
||||
}
|
||||
}
|
||||
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 {
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
serverNames, err := ReadServerNames()
|
||||
if err != nil {
|
||||
InternalServerErrorHandler(w, r)
|
||||
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 {
|
||||
InternalServerErrorHandler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
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 configPkg.GetServers()
|
||||
}
|
||||
|
||||
func ReadServerNames() ([]string, error) {
|
||||
return configPkg.ReadServerNames()
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package api
|
||||
|
||||
// Represents an App, follows the format of
|
||||
// https://git.coopcloud.tech/BornDeleuze/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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// "github.com/spf13/cobra"
|
||||
|
||||
// "coopcloud.tech/abra/pkg/log"
|
||||
"coop-cloud-backend/api"
|
||||
"coop-cloud-backend/cli"
|
||||
)
|
||||
|
||||
// getEnv reads env variables from docker services.
|
||||
@@ -82,5 +82,5 @@ func main() {
|
||||
fmt.Println(val)
|
||||
}
|
||||
|
||||
api.StartAPI()
|
||||
cli.StartAPI()
|
||||
}
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"coop-cloud-backend/cli/external"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
type MockCommandRunner struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
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(),
|
||||
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")
|
||||
http.Error(w, "API Path not found", http.StatusMethodNotAllowed)
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
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) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
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 {
|
||||
case http.MethodPost:
|
||||
h.handleUndeployApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/remove", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleRemoveApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleListServers(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/catalogue", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
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 {
|
||||
case http.MethodPost:
|
||||
h.handleNewApp(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handlePostConfig(w, r, r.PathValue("appId"))
|
||||
case http.MethodGet:
|
||||
h.handleGetConfig(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
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 {
|
||||
case http.MethodPost:
|
||||
h.handleInsertAppSecret(w, r, r.PathValue("appId"))
|
||||
case http.MethodGet:
|
||||
h.handleGetAppSecrets(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/services", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleGetAppServices(w, r, r.PathValue("appId"))
|
||||
default:
|
||||
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
h.mux.HandleFunc("/api/apps/{appId}/{serviceId}/logs", func(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}*/
|
||||
|
||||
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, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// Delegate to internal mux
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"log"
|
||||
"maps"
|
||||
"encoding/json"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
offline := false
|
||||
catl, err := recipe.ReadRecipeCatalogue(offline)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
vals := slices.Collect(maps.Values(catl))
|
||||
|
||||
jsonBytes, err := json.Marshal(vals)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
import (
|
||||
"log"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
)
|
||||
type FileResponse struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleGetConfig(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
files, err := appPkg.LoadAppFiles("")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
appFile, exists := files[appName]
|
||||
if !exists {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("path: %s | server: %s", appFile.Path, appFile.Server)
|
||||
log.Printf("Ending...")
|
||||
// TODO: sanitize
|
||||
file, err := ioutil.ReadFile(appFile.Path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp := FileResponse{Content: string(file)}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handlePostConfig(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
files, err := appPkg.LoadAppFiles("")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
appFile, exists := files[appName]
|
||||
if !exists {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields() // catch unwanted fields
|
||||
|
||||
var config FileResponse
|
||||
err = d.Decode(&config)
|
||||
if err != nil {
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(appFile.Path, []byte(config.Content), 0644)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to write file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"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/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"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
//"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
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 chaos {
|
||||
args = append(args, "-C")
|
||||
}
|
||||
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 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
|
||||
// STEP 1: collect information about app being deployed
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var serviceIDs []ui.ServiceMeta
|
||||
stackName := app.StackName()
|
||||
namespace := convert.NewNamespace(stackName)
|
||||
log.Printf("stack name: %s | namespace: %s", stackName, namespace.Name())
|
||||
existingServices, err := GetStackServices(context.Background(), cl, namespace.Name())
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, service := range existingServices {
|
||||
log.Printf("existingServices contains: %s", service.Spec.Name)
|
||||
serviceIDs = append(serviceIDs, ui.ServiceMeta{
|
||||
Name: service.Spec.Name,
|
||||
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")
|
||||
log.Printf("Waiting on service...")
|
||||
|
||||
stream := make(chan status.StreamEvent, 50)
|
||||
go status.WaitOnServices(ctx, cl, serviceIDs, f, stream)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("deploy cancelled or done")
|
||||
return
|
||||
case msg, ok := <-stream:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
test, err := json.MarshalIndent(msg, "", " ")
|
||||
fmt.Printf(string(test), err)
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
OnError(err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func getStackFilter(namespace string) filters.Args {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", convert.LabelNamespace+"="+namespace)
|
||||
return filter
|
||||
}
|
||||
func getStackServiceFilter(namespace string) filters.Args {
|
||||
return getStackFilter(namespace)
|
||||
}
|
||||
|
||||
func GetStackServices(ctx context.Context, dockerclient dclient.APIClient, namespace string) ([]swarm.Service, error) {
|
||||
return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: getStackServiceFilter(namespace)})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cli
|
||||
import (
|
||||
"net/http"
|
||||
"log"
|
||||
)
|
||||
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 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)
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"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) {
|
||||
output, err := h.cmd.Run("abra", "app", "ls", "-m")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
var resp ServerAppsResponse
|
||||
// no filtering
|
||||
listAppServer := ""
|
||||
recipeFilter := ""
|
||||
|
||||
if err := json.Unmarshal(output, &resp); err != nil {
|
||||
InternalServerError(w, fmt.Errorf("invalid JSON from CLI"))
|
||||
return
|
||||
}
|
||||
// Get abra apps from remote
|
||||
apps, err := h.ap.GetApps(recipeFilter, listAppServer)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
statuses, err = h.ap.GetAppStatuses(rapps, true)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
// TODO: if a server is unreachable, skip its apps, maybe flag something?
|
||||
for _, app := range apps {
|
||||
var stats ServerApps
|
||||
|
||||
stats = resp[app.Server]
|
||||
var appStats *AbraApp
|
||||
for _, sapp := range stats.Apps {
|
||||
if sapp.AppName == app.Name {
|
||||
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[h.ap.StackName(app)]; ok {
|
||||
if currentVersion, exists := statusMeta["version"]; exists {
|
||||
if currentVersion != "" {
|
||||
version = currentVersion
|
||||
}
|
||||
}
|
||||
if chaosDeploy, exists := statusMeta["chaos"]; exists {
|
||||
chaos = chaosDeploy
|
||||
}
|
||||
if chaosDeployVersion, exists := statusMeta["chaosVersion"]; exists {
|
||||
chaosVersion = chaosDeployVersion
|
||||
}
|
||||
if statusMeta["status"] != "" {
|
||||
status = statusMeta["status"]
|
||||
}
|
||||
stats.VersionCount++
|
||||
} else {
|
||||
stats.UnversionedCount++
|
||||
}
|
||||
stats.AppCount++
|
||||
|
||||
appStats.Status = status
|
||||
appStats.Chaos = chaos
|
||||
appStats.ChaosVersion = chaosVersion
|
||||
appStats.Version = version
|
||||
localApp := true
|
||||
|
||||
var newUpdates []string
|
||||
if version != "unknown" && chaos == "false" {
|
||||
if err := h.rp.EnsureExists(app); err != nil {
|
||||
InternalServerError(w, fmt.Errorf("unable to clone %s: %s", app.Name, err))
|
||||
return
|
||||
}
|
||||
|
||||
updates, err := h.rp.Tags(app)
|
||||
if err != nil {
|
||||
localApp = false
|
||||
} else {
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
if ok := tagcmp.IsParsable(update); !ok {
|
||||
log.Printf("unable to parse %s, skipping as upgrade option", update)
|
||||
continue
|
||||
}
|
||||
|
||||
parsedUpdate, err := tagcmp.Parse(update)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
||||
newUpdates = append(newUpdates, update)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(newUpdates) == 0 {
|
||||
if version == "unknown" {
|
||||
appStats.Upgrade = "unknown"
|
||||
} else if localApp {
|
||||
appStats.Upgrade = "latest"
|
||||
stats.LatestCount++
|
||||
} else {
|
||||
appStats.Upgrade = "unknown"
|
||||
}
|
||||
} else {
|
||||
newUpdates = SortVersionsDesc(newUpdates)
|
||||
appStats.Upgrade = strings.Join(newUpdates, "\n")
|
||||
stats.UpgradeCount++
|
||||
}
|
||||
|
||||
for i, sapp := range stats.Apps {
|
||||
if sapp.AppName == app.Name {
|
||||
stats.Apps[i] = *appStats
|
||||
}
|
||||
}
|
||||
resp[app.Server] = stats
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
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) {
|
||||
abraServers, err := h.cmd.Run("abra", "server", "ls", "-m")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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
|
||||
|
||||
for _, v := range versions {
|
||||
parsed, _ := tagcmp.Parse(v) // skips unsupported tags
|
||||
tags = append(tags, parsed)
|
||||
}
|
||||
|
||||
sort.Sort(tagcmp.ByTagDesc(tags))
|
||||
|
||||
var desc []string
|
||||
for _, t := range tags {
|
||||
desc = append(desc, t.String())
|
||||
}
|
||||
|
||||
return desc
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package cli
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coop-cloud-backend/cli/status"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
|
||||
)
|
||||
func (h *abraHandler) handleGetLogs(w http.ResponseWriter, r *http.Request, appName string, serviceName string) {
|
||||
app, err := appPkg.Get(appName)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Printf("%s is not deployed?", app.Name)
|
||||
return
|
||||
}
|
||||
serviceNames := []string{serviceName}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
services, err := cl.ServiceList(
|
||||
context.Background(),
|
||||
types.ServiceListOptions{Filters: f},
|
||||
)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
logCh := make (chan string, 50)
|
||||
go status.StreamLogs(ctx, cl, services, logCh)
|
||||
for {
|
||||
select{
|
||||
case <- ctx.Done():
|
||||
log.Printf("log streaming done")
|
||||
return
|
||||
case line, ok := <- logCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", line)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
// 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"`
|
||||
BackupEnabled bool `json:"backupEnabled"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type AppSecret struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type AppSnapshot struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Snapshot string `json:"snapshot"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// type RecipeMeta struct {
|
||||
// Category string `json:"category"`
|
||||
// DefaultBranch string `json:"default_branch"`
|
||||
// Description string `json:"description"`
|
||||
// Features Features `json:"features"`
|
||||
// Icon string `json:"icon"`
|
||||
// Name string `json:"name"`
|
||||
// Repository string `json:"repository"`
|
||||
// SSHURL string `json:"ssh_url"`
|
||||
// Versions RecipeVersions `json:"versions"`
|
||||
// Website string `json:"website"`
|
||||
// }
|
||||
|
||||
// type RecipeCatalogue map[Name]RecipeMeta
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"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) {
|
||||
args := []string{"app", "new", appName, "-n"}
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields() // catch unwanted fields
|
||||
|
||||
// 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"`
|
||||
}{}
|
||||
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
// bad JSON or unrecognized json field
|
||||
log.Printf("%v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
if body.Domain == nil {
|
||||
http.Error(w, "missing field 'domain' from JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Server == nil {
|
||||
http.Error(w, "missing field 'server' from JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args = append(args, fmt.Sprintf("--domain=%s", *body.Domain))
|
||||
args = append(args, fmt.Sprintf("--server=%s", *body.Server))
|
||||
if body.Chaos != nil && *body.Chaos == true {
|
||||
args = append(args, "-C")
|
||||
}
|
||||
log.Printf("%v", args)
|
||||
output, err := h.cmd.Run("abra", args...)
|
||||
if err != nil {
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
var fs []AppSecret
|
||||
if body.Secrets != nil && *body.Secrets == true {
|
||||
log.Printf("testing: %v %v", *body.Domain, *body.Server)
|
||||
appSecrets, err := h.secret.createSecrets(appName, *body.Domain, *body.Server)
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
for k, v := range appSecrets {
|
||||
fs = append(fs, AppSecret{
|
||||
Name: k,
|
||||
Value: v,
|
||||
Version: "v1",
|
||||
Created: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
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(jsonBytes)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
composeFiles, err := recipe.GetComposeFiles(sampleEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//sanitisedAppName := appPkg.SanitiseAppName(domain)
|
||||
secretsConfig, err := secret.ReadSecretsConfig(
|
||||
recipe.SampleEnvPath,
|
||||
composeFiles,
|
||||
appPkg.StackName(domain),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secrets, err := secret.GenerateSecrets(cl, secretsConfig, server)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleGetAppServices(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
var tmpMsg map[string]map[string]string
|
||||
output, err := h.cmd.Run("abra", "app", "ps", appName, "-m")
|
||||
if err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(output, &tmpMsg); err != nil {
|
||||
InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
var services []AbraAppService
|
||||
for _, v := range tmpMsg {
|
||||
services = append(services, AbraAppService{
|
||||
Service: v["service"],
|
||||
Chaos: v["chaos"] == "true",
|
||||
Created: v["created"],
|
||||
Image: v["image"],
|
||||
Ports: v["ports"],
|
||||
State: v["state"],
|
||||
Status: v["status"],
|
||||
Version: v["version"],
|
||||
})
|
||||
}
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].Service < services[j].Service
|
||||
})
|
||||
jsonBytes, err := json.Marshal(services)
|
||||
if err != nil {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleRemoveApp(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
output, err := h.cmd.Run("abra", "app", "remove", appName, "-n")
|
||||
if err != nil {
|
||||
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *abraHandler) handleGetAppSecrets(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
log.Printf("handling secrets!")
|
||||
secrets, err := h.cmd.Run("abra", "app", "secret", "list", appName, "-m")
|
||||
if err != nil {
|
||||
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(jsonBytes)
|
||||
}
|
||||
|
||||
func (h *abraHandler) handleInsertAppSecret(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields() // catch unwanted fields
|
||||
|
||||
// anonymous struct type: handy for one-time use
|
||||
body := struct {
|
||||
Name *string `json:"name"`
|
||||
Version *string `json:"version"`
|
||||
Value *string `json:"value"`
|
||||
}{}
|
||||
|
||||
err := d.Decode(&body)
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name == nil {
|
||||
BadRequest(w, errors.New("missing field 'name' from JSON object"))
|
||||
return
|
||||
}
|
||||
if body.Version == nil {
|
||||
BadRequest(w, errors.New("missing field 'version' from JSON object"))
|
||||
return
|
||||
}
|
||||
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)
|
||||
output, err := h.cmd.Run("abra", "app", "secret", "insert", appName, *body.Name, *body.Version, *body.Value)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package status
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"io"
|
||||
"log"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/json"
|
||||
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
containerTypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/cli/cli/command/service/progress"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
|
||||
"coopcloud.tech/abra/pkg/ui"
|
||||
)
|
||||
type Event interface {
|
||||
ServiceID() string
|
||||
}
|
||||
|
||||
type ProgressEvent struct {
|
||||
Service ServiceState
|
||||
Err error
|
||||
Failed bool
|
||||
}
|
||||
func (e ProgressEvent) ServiceID() string { return e.Service.Id }
|
||||
|
||||
type HealthEvent struct {
|
||||
Service ServiceState
|
||||
Err error
|
||||
Health string
|
||||
}
|
||||
func (e HealthEvent) ServiceID() string { return e.Service.Id }
|
||||
|
||||
type StatusEvent struct {
|
||||
Service ServiceState
|
||||
Err error
|
||||
JsonMsg jsonmessage.JSONMessage
|
||||
}
|
||||
func (e StatusEvent) ServiceID() string { return e.Service.Id }
|
||||
|
||||
type StreamEvent struct {
|
||||
Type string `json:"type"` // "service" | "done"
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ServiceState struct {
|
||||
Name string `json:"name"`
|
||||
Err error `json:"err"`
|
||||
Id string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Retries int `json:"retries"`
|
||||
Health string `json:"health"`
|
||||
Rollback bool `json:"rollback"`
|
||||
Failed bool `json:"failed"`
|
||||
}
|
||||
|
||||
type DeployState struct {
|
||||
Count int `json:"count"`
|
||||
Total int `json:"total"`
|
||||
Failed bool `json:"failed"`
|
||||
Quit bool `json:"quit"`
|
||||
}
|
||||
|
||||
type DeployMsg int
|
||||
const (
|
||||
FailMsg DeployMsg = iota
|
||||
CompleteMsg
|
||||
QuitMsg
|
||||
)
|
||||
|
||||
func (ds DeployState) complete() bool {
|
||||
return ds.Count == ds.Total
|
||||
}
|
||||
|
||||
func progressProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, w *io.PipeWriter, ch chan<- Event) {
|
||||
go func() {
|
||||
log.Printf("producing progress...")
|
||||
err := progress.ServiceProgress(ctx, cl, s.Id, w)
|
||||
log.Printf("got some service progress here")
|
||||
if err != nil {
|
||||
log.Printf("Error in service progress")
|
||||
ch <- ProgressEvent{
|
||||
Service: s,
|
||||
Failed: true,
|
||||
Err: err,
|
||||
}
|
||||
} else {
|
||||
ch <- ProgressEvent{
|
||||
Service: s,
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func statusProducer(ctx context.Context, decoder *json.Decoder, s ServiceState, ch chan<- Event) {
|
||||
go func() {
|
||||
log.Printf("producing status...")
|
||||
for {
|
||||
var msg jsonmessage.JSONMessage
|
||||
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
|
||||
ch <- StatusEvent{
|
||||
Service: s,
|
||||
Err: err,
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("Maybe writing JSON message")
|
||||
msg.Display(os.Stdout, false);
|
||||
ch <- StatusEvent{
|
||||
Service: s,
|
||||
JsonMsg: msg,
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func healthProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, ch chan<- Event) {
|
||||
go func() {
|
||||
log.Printf("producing health...")
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
health := ""
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("^%s", s.Name))
|
||||
|
||||
containers, err := cl.ContainerList(ctx, containerTypes.ListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
ch <- HealthEvent{Service: s, Err: err}
|
||||
continue
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
ch <- HealthEvent{Service: s}
|
||||
continue
|
||||
}
|
||||
|
||||
containerState, err := cl.ContainerInspect(ctx, containers[0].ID)
|
||||
if err != nil {
|
||||
ch <- HealthEvent{Service: s, Err: err}
|
||||
continue
|
||||
}
|
||||
|
||||
if containerState.State.Health != nil {
|
||||
health = containerState.State.Health.Status
|
||||
}
|
||||
ch <- HealthEvent{
|
||||
Service: s,
|
||||
Health: health,
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func processEvent(ctx context.Context, events <- chan Event, info chan <- DeployMsg, s ServiceState, stream chan <- StreamEvent) error {
|
||||
for {
|
||||
select {
|
||||
case event := <- events:
|
||||
if event.ServiceID() != s.Id {
|
||||
continue
|
||||
}
|
||||
switch event := event.(type) {
|
||||
case ProgressEvent:
|
||||
if event.Err != nil {
|
||||
s.Err = event.Err
|
||||
log.Printf("Error in progress event: %s", s.Err)
|
||||
}
|
||||
if event.Failed {
|
||||
info <- FailMsg
|
||||
}
|
||||
// print for debugging purposes
|
||||
log.Printf("Service: %s is complete", event.Service.Id)
|
||||
// end print
|
||||
info <- CompleteMsg
|
||||
|
||||
case StatusEvent:
|
||||
if event.Err != nil {
|
||||
s.Err = event.Err
|
||||
log.Printf("Error in status event: %s", s.Err)
|
||||
}
|
||||
|
||||
// print for debugging purposes
|
||||
b, err := json.MarshalIndent(event.JsonMsg, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Problem with json message: %s", err)
|
||||
}
|
||||
log.Printf(string(b))
|
||||
// end print
|
||||
|
||||
if event.JsonMsg.ID == "rollback" {
|
||||
log.Printf("Failed in rollback")
|
||||
s.Failed = true
|
||||
s.Rollback = true
|
||||
}
|
||||
|
||||
if event.JsonMsg.ID != "overall progress" {
|
||||
newStatus := strings.ToLower(event.JsonMsg.Status)
|
||||
currentStatus := s.Status
|
||||
|
||||
if !strings.Contains(currentStatus, "starting") &&
|
||||
strings.Contains(newStatus, "starting") {
|
||||
s.Retries += 1
|
||||
}
|
||||
|
||||
if s.Rollback {
|
||||
if event.JsonMsg.ID == "rollback" {
|
||||
s.Status = newStatus
|
||||
}
|
||||
} else {
|
||||
s.Status = newStatus
|
||||
}
|
||||
}
|
||||
|
||||
case HealthEvent:
|
||||
if event.Err != nil {
|
||||
s.Err = event.Err
|
||||
log.Printf("Error in health event: %s", s.Err)
|
||||
}
|
||||
h := "?"
|
||||
if s.Health != "" {
|
||||
h = s.Health
|
||||
}
|
||||
if event.Health != "" {
|
||||
h = event.Health
|
||||
}
|
||||
s.Health = h
|
||||
}
|
||||
stream <- StreamEvent{Type: "service", Data: s}
|
||||
case <- ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
func processService(ctx context.Context, info chan <- DeployMsg, cl *dockerClient.Client, s ServiceState, decoder *json.Decoder, writer *io.PipeWriter, stream chan <- StreamEvent) {
|
||||
events := make (chan Event, 50)
|
||||
progressProducer(ctx, cl, s, writer, events)
|
||||
statusProducer(ctx, decoder, s, events)
|
||||
healthProducer(ctx, cl, s, events)
|
||||
|
||||
go processEvent(ctx, events, info, s, stream)
|
||||
}
|
||||
|
||||
func WaitOnServices(pctx context.Context, cl *dockerClient.Client, services []ui.ServiceMeta, filters filters.Args, stream chan <- StreamEvent) {
|
||||
ctx, cancel := context.WithCancel(pctx)
|
||||
defer cancel()
|
||||
log.Printf("What???")
|
||||
ds := DeployState{
|
||||
Count: 0,
|
||||
Total: len(services),
|
||||
Failed: false,
|
||||
Quit: false,
|
||||
}
|
||||
info := make (chan DeployMsg, 50)
|
||||
for _, service := range services {
|
||||
r, w := io.Pipe()
|
||||
d := json.NewDecoder(r)
|
||||
s := ServiceState{
|
||||
Name: service.Name,
|
||||
Id: service.ID,
|
||||
Retries: -1,
|
||||
Health: "?",
|
||||
}
|
||||
log.Printf("Processing Service: %s", s.Id)
|
||||
processService(ctx, info, cl, s, d, w, stream)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case msg := <-info:
|
||||
switch msg {
|
||||
case FailMsg:
|
||||
ds.Failed = true
|
||||
case CompleteMsg:
|
||||
ds.Count += 1
|
||||
if ds.complete() {
|
||||
log.Printf("deploy completed")
|
||||
cancel()
|
||||
}
|
||||
case QuitMsg:
|
||||
ds.Quit = true
|
||||
cancel()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
log.Printf("%v", ds)
|
||||
stream <- StreamEvent{Type: "done", Data: ds}
|
||||
log.Printf("Context finished because: %s", ctx.Err())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package status
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
containerTypes "github.com/docker/docker/api/types/container"
|
||||
)
|
||||
func StreamLogs(
|
||||
ctx context.Context,
|
||||
cl *dockerClient.Client,
|
||||
services []swarm.Service,
|
||||
logCh chan <- string,
|
||||
) error {
|
||||
waitCh := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for _, service := range services {
|
||||
wg.Add(1)
|
||||
go func(serviceID string) {
|
||||
defer wg.Done()
|
||||
tail := "50"
|
||||
|
||||
logs, err := cl.ServiceLogs(ctx, serviceID, containerTypes.LogsOptions{
|
||||
ShowStderr: true,
|
||||
ShowStdout: true,
|
||||
Until: "",
|
||||
Timestamps: true,
|
||||
Follow: true,
|
||||
Tail: tail,
|
||||
Details: false,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
buf := bufio.NewScanner(logs)
|
||||
for buf.Scan() {
|
||||
select {
|
||||
case <- ctx.Done():
|
||||
return
|
||||
default:
|
||||
line := fmt.Sprintf("%s: %s", service.Spec.Name, buf.Text())
|
||||
logCh <- line
|
||||
}
|
||||
}
|
||||
logs.Close()
|
||||
return
|
||||
}
|
||||
}(service.ID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
close(logCh)
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
export PATH=/home/node/.local/bin/:$PATH
|
||||
|
||||
/home/node/wizard/gobackend &
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ go 1.25.6
|
||||
require (
|
||||
coopcloud.tech/abra v0.0.0-20260305102834-9d401202b4fb
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/docker/cli v28.4.0+incompatible
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gorilla/websocket v1.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,10 +38,11 @@ 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/cli v28.4.0+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||
@@ -47,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
|
||||
@@ -58,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
|
||||
@@ -71,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
|
||||
@@ -82,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
|
||||
@@ -96,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=
|
||||
@@ -253,6 +260,7 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
@@ -300,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=
|
||||
@@ -319,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=
|
||||
@@ -354,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=
|
||||
@@ -417,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=
|
||||
@@ -461,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=
|
||||
@@ -469,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=
|
||||
@@ -476,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=
|
||||
@@ -508,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=
|
||||
@@ -517,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=
|
||||
@@ -539,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=
|
||||
@@ -550,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=
|
||||
@@ -568,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=
|
||||
@@ -580,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=
|
||||
@@ -606,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=
|
||||
@@ -637,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=
|
||||
@@ -655,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=
|
||||
@@ -677,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=
|
||||
@@ -688,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=
|
||||
@@ -699,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
-1
@@ -14,7 +14,7 @@ var (
|
||||
DontWaitConverge bool
|
||||
Dry bool
|
||||
Force bool
|
||||
MachineReadable bool
|
||||
Secrets bool
|
||||
Major bool
|
||||
Minor bool
|
||||
NoDomainChecks bool
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDatasources": [
|
||||
"docker"
|
||||
],
|
||||
"pinDigests": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user