Update Dockerfile, add 'nightly' build tag, and update readme #5

Open
jjsfunhouse wants to merge 6 commits from dockerfile into main
69 changed files with 14308 additions and 33 deletions
+1
View File
@@ -42,3 +42,4 @@ jobs:
platforms: linux/amd64
push: true
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly-${{ env.COMMIT_SHORT_SHA }}
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
+1 -1
View File
@@ -50,4 +50,4 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
-3
View File
@@ -1,3 +0,0 @@
[submodule "web"]
path = web
url = https://git.coopcloud.tech/toolshed/coop-cloud-front.git
+12 -6
View File
@@ -3,17 +3,23 @@ RUN mkdir /backend
COPY --parents ["cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
RUN go build -C /backend -o gobackend
FROM node:slim
FROM git.coopcloud.tech/toolshed/abra@sha256:38de102e0d4cf40558bd354edb46fa9ae2438221742e74366560c1cfa0b4ced7 AS abra
FROM node:26-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openssh-client \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir /home/node/wizard
COPY --parents web /home/node/wizard
COPY --from=0 /backend/gobackend /home/node/wizard/gobackend
USER node
COPY ./command.sh /
RUN curl https://install.abra.coopcloud.tech | bash
ENV ABRA_BIN=/home/node/.local/bin/abra
# RUN $ABRA_BIN recipe fetch -a
COPY --from=abra /abra /usr/local/bin/abra
COPY ./command.sh /command.sh
COPY --parents web /home/node/wizard
WORKDIR /home/node/wizard/web
USER root
RUN npm install .
+14 -22
View File
@@ -3,16 +3,18 @@ A Go service that exposes RESTful API endpoints to manage Abra programmatically.
Integrates with https://git.coopcloud.tech/toolshed/coop-cloud-front.
## Starting the service with Docker
Build the container:
Pull the container:
```bash
docker build -t coop-cloud-wizard:latest .
docker pull git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
```
Run the container:
```bash
docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh -v $HOME/.abra:/home/node/.abra coop-cloud-wizard
docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh -v $HOME/.abra:/home/node/.abra coop-cloud-webui
```
Upstream container not working? You can build the container manually:
```bash
docker build -t coop-cloud-webui:latest .
```
## Getting started with development
- Clone the front end application `git clone https://git.coopcloud.tech/toolshed/coop-cloud-front.git`
- Checkout the `dev-nomock` branch `cd coop-cloud-front && git checkout dev-nomock`
@@ -20,29 +22,19 @@ docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh
- Start this Go app `go run .`
- Navigate to the React App (http://localhost:5173)
> [!WARNING]
> This is an extremely early prototype, only viewing/deploying apps is supported
> and may fail for your local machine
## Notes:
`api/` is a deprecated path, currently has no function
# Roadmap
## General Roadmap:
Currently refactored the backend to be much more Abra CLI reliant. This is ok but is not ideal from an engineering standpoint (probably).
First step is just to polish this backend & frontend, estimate 1 - 2 months to be in a fairly usable state.
Write test suite for the full stack combination.
Then we want to package this app into a dockerized form, easily to deploy and use on one's own server.
Slowly introduce full Abra CLI functionality into app.
TODO
Repackaging
## Basic Abra Features
| Feature | Status | Follow-up |
| ----------- | ----------- | ----------- |
| More options for app deploy (esp chaos) | TODO | |
| View and pull in recipes from catalogue | In Progress | |
| Deploy should show deployment status similar to Abra CLI | In Progress | |
| App screen should display services associated with app and their status | TODO | |
| Easily show service logs | In Progress | |
| Modify App config | TODO | |
| Manage updates and rollbacks | TODO | |
| View and pull in recipes from catalogue | Done | Refine Frontend |
| Deploy should show deployment status similar to Abra CLI | Done | Make more responsive and have page render more accurately |
| App screen should display services associated with app and their status | Done | |
| Easily show service logs | Done | More ways to process logs? |
| Modify App config | Done | |
| Manage updates and rollbacks | Done | |
| Manage secrets and Abra commands | In Progress | |
Submodule web deleted from e09aa9a1ca
+3
View File
@@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:3000/api
VITE_WS_URL=ws://localhost:3000
VITE_MOCK_AUTH=true
+25
View File
@@ -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?
+2
View File
@@ -0,0 +1,2 @@
pnpm 10.20.0
nodejs 22.12.0
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "97e400e8",
"configHash": "539cfeba",
"lockfileHash": "e3b0c442",
"browserHash": "b743bb36",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+52
View File
@@ -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.
---
+50
View File
@@ -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',
},
},
);
+17
View File
@@ -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>
+5178
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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"
}
}
+4
View File
@@ -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.
+28
View File
@@ -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

+31
View File
@@ -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;
}
+287
View File
@@ -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;
}
+212
View File
@@ -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;
}
}
+76
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
// 1. Configuration & helpers (no CSS output)
@use 'variables';
@use 'mixins';
@use 'fonts';
// 2. Global base styles
@use 'global';
+52
View File
@@ -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>
);
};
+150
View File
@@ -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;
}
+208
View File
@@ -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>
);
};
+32
View File
@@ -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>
)}
+83
View File
@@ -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;
}
}
}
+20
View File
@@ -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;
}
}
}
+35
View File
@@ -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>
);
};
+43
View File
@@ -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);
}
}
+9
View File
@@ -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>
);
};
+83
View File
@@ -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>
);
};
+204
View File
@@ -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;
}
}
}
+93
View File
@@ -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;
}
}
+10
View File
@@ -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>,
)
+591
View File
@@ -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;
}
+760
View File
@@ -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>
);
};
+167
View File
@@ -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;
}
}
}
}
+289
View File
@@ -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>
);
};
+136
View File
@@ -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>
);
};
+59
View File
@@ -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;
}
}
}
View File
View File
+159
View File
@@ -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;
}
+158
View File
@@ -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;
+154
View File
@@ -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;
}
+179
View File
@@ -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>
);
};
+340
View File
@@ -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;
}
}
}
}
+378
View File
@@ -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>
);
};
+157
View File
@@ -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;
}
}
}
+259
View File
@@ -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>
);
};
+287
View File
@@ -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();
+593
View File
@@ -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
+326
View File
@@ -0,0 +1,326 @@
{
"deploy": {
"logs": [
{
"type": "info",
"text": "📦 Deploying {appName}..."
},
{
"type": "command",
"text": "$ abra app deploy {appName}"
},
{
"type": "info",
"text": "Checking server connection..."
},
{
"type": "success",
"text": "✓ Connected to server"
},
{
"type": "info",
"text": "Pulling latest images..."
},
{
"type": "output",
"text": "latest: Pulling from library/traefik"
},
{
"type": "output",
"text": "a1b2c3d4: Pull complete"
},
{
"type": "output",
"text": "e5f6g7h8: Pull complete"
},
{
"type": "success",
"text": "✓ Images pulled successfully"
},
{
"type": "info",
"text": "Creating containers..."
},
{
"type": "output",
"text": "Creating {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Creating {appName}_db_1 ... done"
},
{
"type": "info",
"text": "Starting services..."
},
{
"type": "output",
"text": "Starting {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Starting {appName}_db_1 ... done"
},
{
"type": "success",
"text": "✓ {appName} deployed successfully!"
}
]
},
"stop": {
"logs": [
{
"type": "info",
"text": "🛑 Stopping {appName}..."
},
{
"type": "command",
"text": "$ abra app stop {appName}"
},
{
"type": "output",
"text": "Stopping {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Stopping {appName}_db_1 ... done"
},
{
"type": "success",
"text": "✓ {appName} stopped"
}
]
},
"start": {
"logs": [
{
"type": "info",
"text": "▶️ Starting {appName}..."
},
{
"type": "command",
"text": "$ abra app start {appName}"
},
{
"type": "output",
"text": "Starting {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Starting {appName}_db_1 ... done"
},
{
"type": "success",
"text": "✓ {appName} started"
}
]
},
"upgrade": {
"logs": [
{
"type": "info",
"text": "Upgrading {appName} to {version}..."
},
{
"type": "command",
"text": "$ abra app upgrade {appName}"
},
{
"type": "info",
"text": "Pulling new version..."
},
{
"type": "output",
"text": "{version}: Pulling from library/app"
},
{
"type": "output",
"text": "x9y8z7w6: Pull complete"
},
{
"type": "success",
"text": "✓ New version pulled"
},
{
"type": "info",
"text": "Stopping old containers..."
},
{
"type": "output",
"text": "Stopping {appName}_app_1 ... done"
},
{
"type": "info",
"text": "Starting new containers..."
},
{
"type": "output",
"text": "Starting {appName}_app_1 ... done"
},
{
"type": "success",
"text": "✓ {appName} upgraded to {version}"
}
]
},
"remove": {
"logs": [
{
"type": "warning",
"text": "⚠️ Removing {appName}..."
},
{
"type": "command",
"text": "$ abra app remove {appName}"
},
{
"type": "output",
"text": "Stopping {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Stopping {appName}_db_1 ... done"
},
{
"type": "output",
"text": "Removing {appName}_app_1 ... done"
},
{
"type": "output",
"text": "Removing {appName}_db_1 ... done"
},
{
"type": "output",
"text": "Removing network {appName}_default"
},
{
"type": "success",
"text": "✓ {appName} removed"
}
]
},
"serverRefresh": {
"logs": [
{
"type": "info",
"text": "🔄 Refreshing server {serverName}..."
},
{
"type": "command",
"text": "$ abra server ls"
},
{
"type": "info",
"text": "Fetching server status..."
},
{
"type": "output",
"text": "Checking connection..."
},
{
"type": "success",
"text": "✓ Connected"
},
{
"type": "output",
"text": "Fetching app list..."
},
{
"type": "success",
"text": "✓ Found apps on {serverName}"
},
{
"type": "success",
"text": "✓ Server refreshed"
}
]
},
"deployAll": {
"logs": [
{
"type": "info",
"text": "📦 Deploying all apps on {serverName}..."
},
{
"type": "command",
"text": "$ abra app deploy --all --server {serverName}"
},
{
"type": "info",
"text": "Found {appCount} apps to deploy"
},
{
"type": "output",
"text": "Deploying apps in parallel..."
},
{
"type": "info",
"text": "[1/{appCount}] Deploying app_1..."
},
{
"type": "success",
"text": "✓ app_1 deployed"
},
{
"type": "info",
"text": "[2/{appCount}] Deploying app_2..."
},
{
"type": "success",
"text": "✓ app_2 deployed"
},
{
"type": "info",
"text": "[3/{appCount}] Deploying app_3..."
},
{
"type": "success",
"text": "✓ app_3 deployed"
},
{
"type": "success",
"text": "✓ All apps deployed on {serverName}"
}
]
},
"upgradeAll": {
"logs": [
{
"type": "info",
"text": "Upgrading all apps on {serverName}..."
},
{
"type": "command",
"text": "$ abra app upgrade --all --server {serverName}"
},
{
"type": "info",
"text": "Found {upgradeCount} apps with available upgrades"
},
{
"type": "output",
"text": "Upgrading apps sequentially..."
},
{
"type": "info",
"text": "[1/{upgradeCount}] Upgrading app_1..."
},
{
"type": "success",
"text": "✓ app_1 upgraded"
},
{
"type": "info",
"text": "[2/{upgradeCount}] Upgrading app_2..."
},
{
"type": "success",
"text": "✓ app_2 upgraded"
},
{
"type": "success",
"text": "✓ All apps upgraded on {serverName}"
}
]
}
}
+30
View File
@@ -0,0 +1,30 @@
[
{
"host": "mydomain.com",
"name": "mydomain.com"
},
{
"host": "coop.test.org",
"name": "coop.test.org"
},
{
"host": "internal.website.com",
"name": "internal.website.com"
},
{
"host": "internal.server.net",
"name": "internal.server.net"
},
{
"host": "internal.intranet.site.com",
"name": "internal.intranet.site.com"
},
{
"host": "internal.test.org",
"name": "internal.test.org"
},
{
"host": "orgsite.org",
"name": "orgsite.org"
}
]
+145
View File
@@ -0,0 +1,145 @@
import type { AbraRecipe, AbraServer, ServerAppsResponse } from '../types';
import appsData from './mock-apps.json';
import serversData from './mock-servers.json';
import logsData from './mock-logs.json';
import catalogue from './mock-catalogue.json';
// Log entry type
export type LogEntry = {
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
timestamp: Date;
};
// Type for the imported JSON structure
type LogTemplate = {
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
};
type LogsDataStructure = {
[key: string]: {
logs: LogTemplate[];
};
};
// Simulate API delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to replace template variables like {appName}, {version}
const replaceVars = (text: string, vars: Record<string, string | number>): string => {
let result = text;
Object.entries(vars).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
});
return result;
};
// Helper to process log templates into LogEntry objects
const processLogs = (
action: string,
vars: Record<string, string | number>
): LogEntry[] => {
console.log('Processing logs for action:', action);
console.log('Loaded logsData:', logsData);
const typedLogsData = logsData as LogsDataStructure;
const actionData = typedLogsData[action];
if (!actionData || !actionData.logs) {
console.error(`No logs found for action: ${action}`);
return [];
}
console.log('Found logs:', actionData.logs);
const templates = actionData.logs;
return templates
.filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries
.map(template => ({
type: template.type,
text: replaceVars(template.text, vars),
timestamp: new Date(),
}));
};
export const mockApiService = {
async getAppsGrouped(): Promise<ServerAppsResponse> {
await delay(500);
return appsData as ServerAppsResponse;
},
async getServers(): Promise<AbraServer[]> {
await delay(300);
return serversData as AbraServer[];
},
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('deploy', { appName });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('stop', { appName });
for (const log of logs) {
await delay(150);
onLog?.(log);
}
},
async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('upgrade', { appName, version });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('remove', { appName });
for (const log of logs) {
await delay(180);
onLog?.(log);
}
},
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('serverRefresh', { serverName });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('deployAll', { serverName, appCount });
for (const log of logs) {
await delay(250);
onLog?.(log);
}
},
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('upgradeAll', { serverName, upgradeCount });
for (const log of logs) {
await delay(250);
onLog?.(log);
}
},
// recipe catalog imports
async getRecipes(): Promise<AbraRecipe[]> {
await delay(300);
return catalogue as AbraRecipe[];
}
};
+126
View File
@@ -0,0 +1,126 @@
export interface ApiError {
message: string;
status: number;
}
export interface AbraApp {
server: string;
recipe: string;
appName: string;
domain: string;
status: 'deployed' | 'stopped' | 'unknown';
chaos: string;
chaosVersion: string;
version: string;
upgrade: string;
}
export interface AbraServer {
name: string;
host: string;
}
export interface ServerAppsResponse {
[serverName: string]: {
apps: AbraApp[];
appCount: number;
versionCount: number;
unversionedCount: number;
latestCount: number;
upgradeCount: number;
backupEnabled: boolean;
};
}
export interface AppWithServer extends AbraApp {
serverStats: {
appCount: number;
upgradeCount: number;
};
}
export interface AbraAppService {
service: string;
chaos: boolean;
created: string;
image: string;
ports: string;
state: string;
status: string;
version: string;
}
export interface AbraServiceState {
name: string;
err: string;
id: string;
status: string;
retries: number;
health: string;
rollback: boolean;
failed: boolean;
}
export interface DeployState {
count: number;
total: number;
failed: boolean;
quit: boolean;
}
export type DeployEvent =
| {
type: "service";
data: AbraServiceState;
}
| {
type: "done";
data: DeployState;
}
export interface Image {
image: string;
rating: string
source: string
url: string
}
type RecipeVersions = Record<string, Record<string, ServiceMeta>>[];
export interface ServiceMeta {
image: string;
tag: string
}
export interface Features {
backups: string;
email: string;
healthcheck: string;
image: Image;
status: number;
tests: string;
sso: string;
}
export interface AbraRecipe {
category: string;
default_branch: string;
description: string;
features: Features;
icon: string;
name: string;
repository: string;
ssh_url: string;
versions: RecipeVersions;
website: string;
}
export interface AbraAppSecret {
name: string;
version: string;
value: string;
created: boolean;
}
export interface File {
content: string;
}
export interface AppSnapshot {
createdAt: string;
snapshot: string;
version: string;
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})