5 Commits

Author SHA1 Message Date
hey
e09aa9a1ca merge changes from dev branch 2026-05-08 12:25:29 -04:00
hey
642be74d70 add config information 2026-05-08 11:59:17 -04:00
hey
6ecc158268 add creating new app and fix terminal bug 2026-04-18 17:35:50 -04:00
hey
864640a15e merged recipes with log streaming 2026-04-18 13:32:10 -04:00
hey
9b1eaf168f added service information for each app 2026-04-18 13:28:19 -04:00
39 changed files with 9265 additions and 538 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# API base URL for the backend that wraps the abra CLI
VITE_API_URL=http://localhost:3000/api
# Set to 'true' to use mock data
VITE_MOCK_AUTH=true

1
.gitignore vendored
View File

@ -8,7 +8,6 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
pnpm-lock.yaml
dist dist
dist-ssr dist-ssr
*.local *.local

View File

@ -1,52 +1,7 @@
# Coop Cloud Front # Coop Cloud Front
Frontend for Coop Cloud — a web UI wrapper around the `abra` CLI for This is the frontend of a web wrapper for Coop Clouds abra CLI, letting users set up and manage VPSs and docker images through a web UI.
managing VPSs, containers and application deployments.
This repository contains a Vite + React + TypeScript app styled ## Still a work in progess!
with SCSS.
## Quick start ## This is built with react, typescript, scss, and vite
- 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.
---

View File

@ -5,10 +5,6 @@
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>coopcloud-frontend</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

5083
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"dev": "vite", "dev": "vite",
"start:prod": "VITE_MOCK_AUTH=false pnpm start",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
@ -17,9 +16,11 @@
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'" "format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'"
}, },
"dependencies": { "dependencies": {
"prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.5" "react-router-dom": "^7.9.5",
"react-simple-code-editor": "^0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

3042
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: 'Lora'; font-family: 'Lora';
src: url('/fonts/Lora-Italic.woff2') format('woff2'); src: url('https://coopcloud.tech/font/Lora-Italic.woff2') format('woff2');
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
@ -8,7 +8,7 @@
@font-face { @font-face {
font-family: 'Manrope'; font-family: 'Manrope';
src: url('/fonts/manrope.light.woff2') format('woff2'); src: url('https://coopcloud.tech/font/manrope.light.woff2') format('woff2');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -16,7 +16,7 @@
@font-face { @font-face {
font-family: 'Manrope'; font-family: 'Manrope';
src: url('/fonts/manrope.medium.woff2') format('woff2'); src: url('https://coopcloud.tech/font/manrope.medium.woff2') format('woff2');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -24,7 +24,7 @@
@font-face { @font-face {
font-family: 'Manrope'; font-family: 'Manrope';
src: url('/fonts/manrope.bold.woff2') format('woff2'); src: url('https://coopcloud.tech/font/manrope.bold.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;

View File

@ -86,7 +86,6 @@ body {
// Modifier classes for colored borders // Modifier classes for colored borders
&.upgrade { &.upgrade {
border-left: 4px solid $primary-light; border-left: 4px solid $primary-light;
cursor: none;
} }
&.chaos { &.chaos {
@ -236,18 +235,6 @@ body {
color: $text-secondary; 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
.results-count { .results-count {
text-align: center; text-align: center;

View File

@ -23,98 +23,11 @@
// Card style // Card style
@mixin card { @mixin card {
background: $bg-primary; background: $bg-primary;
border-radius: $radius-lg;
box-shadow: $shadow-md; box-shadow: $shadow-md;
padding: $spacing-xl; 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 // Button base
@mixin button-base { @mixin button-base {
padding: $spacing-sm $spacing-lg; padding: $spacing-sm $spacing-lg;
@ -149,64 +62,4 @@
font-size: $font-size-sm; font-size: $font-size-sm;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
background-color: rgba($color, 0.1); 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;
}
} }

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

View File

@ -17,6 +17,9 @@ return(
<img className="logo" src={logo}/> <img className="logo" src={logo}/>
</h1> </h1>
<nav className="nav"> <nav className="nav">
<button onClick={() => navigate('/dashboard')} className="nav-link">
Dashboard
</button>
<button onClick={() => navigate('/apps')} className="nav-link"> <button onClick={() => navigate('/apps')} className="nav-link">
Apps Apps
</button> </button>

View File

@ -27,14 +27,12 @@
margin: 0; margin: 0;
display: inline-block; display: inline-block;
max-width: 100%; max-width: 100%;
cursor: pointer;
} }
.nav { .nav {
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
flex: 1; flex: 1;
text-align: center;
@media (max-width: 768px) { @media (max-width: 768px) {
width: 100%; width: 100%;

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

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

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

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

View File

@ -10,26 +10,25 @@ interface TerminalProps {
export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => { export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const indexRef = useRef(0);
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]); const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
// Stream logs in with delays for realistic effect // Stream logs in with delays for realistic effect
useEffect(() => { useEffect(() => {
if (!isActive || logs.length === 0) { if (!isActive || logs.length === 0) {
indexRef.current = 0;
setDisplayedLogs([]); setDisplayedLogs([]);
return; return;
} }
setDisplayedLogs([]);
let currentIndex = 0;
const streamLogs = () => { const streamLogs = () => {
if (currentIndex < logs.length) { if (indexRef.current < logs.length) {
setDisplayedLogs(prev => [...prev, logs[currentIndex]]); setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
currentIndex++; indexRef.current++;
// Variable delay based on log type // Variable delay based on log type
const delay = logs[currentIndex - 1]?.type === 'command' ? 200 : const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
logs[currentIndex - 1]?.type === 'output' ? 100 : 300; logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
setTimeout(streamLogs, delay); setTimeout(streamLogs, delay);
} }
@ -37,7 +36,6 @@ export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) =
streamLogs(); streamLogs();
}, [logs, isActive]); }, [logs, isActive]);
// Auto-scroll to bottom // Auto-scroll to bottom
useEffect(() => { useEffect(() => {
if (terminalRef.current) { if (terminalRef.current) {
@ -73,7 +71,6 @@ export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) =
<div className="terminal-content" ref={terminalRef}> <div className="terminal-content" ref={terminalRef}>
{displayedLogs.filter(log => log && log.type).map((log, index) => ( {displayedLogs.filter(log => log && log.type).map((log, index) => (
<div key={index} className={`terminal-line terminal-${log.type}`}> <div key={index} className={`terminal-line terminal-${log.type}`}>
<span className="terminal-timestamp">[{formatTime(log.timestamp)}]</span>
<span className="terminal-text">{log.text}</span> <span className="terminal-text">{log.text}</span>
</div> </div>
))} ))}

View File

@ -49,19 +49,42 @@
// Action buttons // Action buttons
.action-btn { .action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm); padding: $spacing-sm $spacing-lg;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
background: white;
color: $text-primary;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: $shadow-md;
}
&.primary { &.primary {
background: $primary; background: $primary;
color: $text-primary; color: white;
border-color: $primary; border-color: $primary-light;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: $primary-light; background: $primary-light;
border-color: $primary-light; border-color: $primary-light;
} }
} }
&.service {
&:hover:not(:disabled) {
background: #3fff5c9d;
}
}
&.secondary { &.secondary {
background: $bg-secondary; background: $bg-secondary;
color: $text-primary; color: $text-primary;
@ -112,6 +135,49 @@
} }
} }
// 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
.info-grid { .info-grid {
display: grid; display: grid;
@ -149,13 +215,12 @@
} }
.domain-link { .domain-link {
color: $primary-dark; color: $primary-light;
text-decoration: none; text-decoration: none;
font-size: $font-size-base; font-size: $font-size-base;
transition: color $transition-base; transition: color $transition-base;
&:hover { &:hover {
color: $primary-light;
text-decoration: underline; text-decoration: underline;
} }
} }
@ -163,7 +228,7 @@
.server-link { .server-link {
background: none; background: none;
border: none; border: none;
color: $primary-dark; color: $primary-light;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
font-size: $font-size-base; font-size: $font-size-base;
@ -171,6 +236,7 @@
transition: color $transition-base; transition: color $transition-base;
&:hover { &:hover {
background: none;
color: $primary-light; color: $primary-light;
text-decoration: underline; text-decoration: underline;
} }
@ -327,7 +393,6 @@
background: rgba($error, 0.05); background: rgba($error, 0.05);
} }
} }
.action-text { .action-text {
flex: 1; flex: 1;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
@ -365,3 +430,52 @@
} }
} }
} }
// 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;
}
.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;
}
}
}
// navigation back to apps page
.back-navigation {
margin-bottom: 10px;
}

View File

@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { Terminal } from '../../components/Terminal/Terminal'; import { Terminal } from '../../components/Terminal/Terminal';
import { EditorPage } from '../../components/Editor/Editor';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraApp } from '../../types'; import type { AbraApp, AbraAppService, AbraServiceState } from '../../types';
import type { LogEntry } from '../../services/mockApi'; import type { LogEntry } from '../../services/mockApi';
import { createPortal } from "react-dom";
import './App.scss'; import './App.scss';
export const AppDetail: React.FC = () => { export const AppDetail: React.FC = () => {
@ -12,16 +16,38 @@ export const AppDetail: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [app, setApp] = useState<AbraApp | null>(null); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [openEditor, setOpenEditor] = useState(false);
// Terminal state // Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]); const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false); const [terminalActive, setTerminalActive] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; // Stream state
const stopRef = useRef<null | (() => void)>(null);
const deployRef = useRef<null | (() => void)>(null);
// Use to refresh page
const [refreshKey, setRefreshKey] = useState(0);
const isMockMode = false;
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(() => { useEffect(() => {
const fetchApp = async () => { const fetchApp = async () => {
try { try {
@ -38,12 +64,17 @@ export const AppDetail: React.FC = () => {
setError('App not found'); setError('App not found');
} }
} else { } else {
console.log('fetching app...');
const appsData = await apiService.getAppsGrouped(); const appsData = await apiService.getAppsGrouped();
const serverApps = appsData[server || '']; const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName); const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (foundApp) { if (foundApp) {
setApp(foundApp); setApp(foundApp);
// 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 { } else {
setError('App not found'); setError('App not found');
} }
@ -56,15 +87,55 @@ export const AppDetail: React.FC = () => {
}; };
fetchApp(); fetchApp();
}, [server, appName, isMockMode]); }, [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]);
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
);
}
const handleAction = async (action: string, version?: string) => { const handleAction = async (action: string, version?: string) => {
if (!app) return; if (!app) return;
setActionLoading(action); setActionLoading(action);
setTerminalActive(true);
setTerminalLogs([]);
try { try {
if (isMockMode) { if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi'); const { mockApiService } = await import('../../services/mockApi');
@ -94,9 +165,57 @@ export const AppDetail: React.FC = () => {
switch (action) { switch (action) {
case 'stop': case 'stop':
await apiService.stopApp(app.appName); await apiService.stopApp(app.appName);
setRefreshKey(prev => prev + 1);
break; break;
case 'deploy': case 'deploy':
await apiService.deployApp(app.appName); await apiService.deployApp(app.appName);
setDeployState("deploying");
console.log("deploying");
deployRef.current = apiService.deployLogs(app.appName,
(update) => {
if (update.type === "service") {
console.log(update.data.name)
const serviceName = update.data.name.slice(app.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);
}
}
)
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; break;
} }
} }
@ -117,7 +236,10 @@ export const AppDetail: React.FC = () => {
<div className="app-detail-page"> <div className="app-detail-page">
<Header /> <Header />
<main className="app-detail-content"> <main className="app-detail-content">
<div className="loading">Loading application...</div> <div className="loading">
<Loader />
<div>Loading application</div>
</div>
</main> </main>
</div> </div>
); );
@ -136,19 +258,23 @@ export const AppDetail: React.FC = () => {
</div> </div>
); );
} }
// TODO: make sure this makes sense when app.upgrade is unknown
const upgradeVersions = app.upgrade !== 'latest' ? app.upgrade.split('\n') : []; const upgradeVersions = (app.upgrade !== 'latest' && app.upgrade !== 'unknown') ? app.upgrade.split('\n') : [];
return ( return (
<div className="app-detail-page"> <div className="app-detail-page">
{openEditor && (
<EditorModal onClose={() => setOpenEditor(false)} />
)}
<Header /> <Header />
<main className="app-detail-content"> <main className="app-detail-content">
<div className="breadcrumb"> <div className="back-navigation">
<button onClick={() => navigate('/apps')} className="breadcrumb-link"> <button
Apps onClick={() => navigate(`/apps`)}
className="server-link"
>
Back to Apps
</button> </button>
<span className="breadcrumb-separator">/</span>
<span className="breadcrumb-current">{app.appName}</span>
</div> </div>
<div className="app-header"> <div className="app-header">
@ -167,14 +293,14 @@ export const AppDetail: React.FC = () => {
<button <button
className="action-btn danger" className="action-btn danger"
onClick={() => handleAction('stop')} onClick={() => handleAction('stop')}
disabled={!!actionLoading} disabled={!!actionLoading || deployState === "undeployed"}
> >
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'} {actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
</button> </button>
<button <button
className="action-btn primary" className="action-btn"
onClick={() => handleAction('deploy')} onClick={() => handleAction('deploy')}
disabled={!!actionLoading} disabled={!!actionLoading || deployState === "deployed"}
> >
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'} {actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
</button> </button>
@ -185,7 +311,11 @@ export const AppDetail: React.FC = () => {
<Terminal <Terminal
logs={terminalLogs} logs={terminalLogs}
isActive={terminalActive} isActive={terminalActive}
onClose={() => setTerminalActive(false)} onClose={() => {
stopRef.current?.();
stopRef.current = null;
setTerminalActive(false)
}}
/> />
<div className="content-grid"> <div className="content-grid">
@ -242,7 +372,70 @@ export const AppDetail: React.FC = () => {
</div> </div>
</div> </div>
</section> </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"> <section className="info-card">
<h2>Version Information</h2> <h2>Version Information</h2>
@ -282,6 +475,18 @@ export const AppDetail: React.FC = () => {
</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' && ( {app.upgrade === 'latest' && (
<div className="version-latest"> <div className="version-latest">
Running latest version Running latest version
@ -317,7 +522,7 @@ export const AppDetail: React.FC = () => {
<button <button
className="action-list-item danger" className="action-list-item danger"
onClick={() => { onClick={() => {
if (confirm(`Are you sure you want to remove ${app.appName}?`)) { if (confirm(`Are you sure you want to remove ${app.appName}?\nThis will delete all data and config`)) {
handleAction('remove'); handleAction('remove');
} }
}} }}
@ -325,6 +530,13 @@ export const AppDetail: React.FC = () => {
> >
<span className="action-text">Remove Application</span> <span className="action-text">Remove Application</span>
</button> </button>
<button
className="action-list-item"
onClick={() => handleAction('config')}
disabled={!!actionLoading}
>
<span className="action-text">Manage Config</span>
</button>
</div> </div>
</section> </section>

View File

@ -21,12 +21,31 @@
} }
.stat-chip { .stat-chip {
@include chip(); display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
background: white;
border: 2px solid $border-color;
border-radius: $radius-md;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size-base;
&:hover:not(:disabled) {
border-color: $primary;
transform: translateY(-2px);
box-shadow: $shadow-sm;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.active { &.active {
border-color: $primary-dark; border-color: $primary;
background: $bg-primary; background: rgba($primary, 0.05);
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
} }
.stat-label { .stat-label {
@ -44,11 +63,28 @@
} }
} }
/* reset-filters-btn removed — outline on stat-chip indicates active filters */ .reset-filters-btn {
padding: $spacing-sm $spacing-md;
background: $bg-secondary;
border: 2px solid $border-color;
border-radius: $radius-md;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-secondary;
&:hover {
background: white;
color: $text-primary;
border-color: $primary;
}
}
// Apps table specific styles // Apps table specific styles
.apps-table-container { .apps-table-container {
@include scrollable-card; @include card;
overflow-x: auto;
margin-bottom: $spacing-lg; margin-bottom: $spacing-lg;
} }
@ -105,18 +141,18 @@
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
color: $text-primary; color: $text-primary;
} }
.domain-link {
color: $primary-dark;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
} }
.domain-link {
color: $primary-dark;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
.no-domain { .no-domain {
color: $text-muted; color: $text-muted;
@ -146,7 +182,15 @@
gap: $spacing-sm; gap: $spacing-sm;
.action-btn { .action-btn {
@include action-btn(1px, $radius-sm, $spacing-xs $spacing-md, $font-size-sm); background: none;
border: 1px solid $border-color;
padding: $spacing-xs $spacing-md;
border-radius: $radius-sm;
cursor: pointer;
font-size: $font-size-sm;
color: $text-primary;
font-weight: $font-weight-medium;
transition: all $transition-base;
&:hover { &:hover {
background-color: $primary; background-color: $primary;

View File

@ -1,11 +1,7 @@
// TODOS:
// make the two filters non-exlusive
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types'; import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types';
import './Apps.scss'; import './Apps.scss';
@ -17,10 +13,11 @@ export const Apps: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all'); const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false); const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; const isMockMode = false;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -64,7 +61,7 @@ export const Apps: React.FC = () => {
return Object.keys(appsData); return Object.keys(appsData);
}, [appsData]); }, [appsData]);
// Filter apps (additive filters: upgrades AND chaos can be applied together) // Filter apps
const filteredApps = useMemo(() => { const filteredApps = useMemo(() => {
return allApps.filter(app => { return allApps.filter(app => {
const matchesSearch = const matchesSearch =
@ -73,12 +70,14 @@ export const Apps: React.FC = () => {
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase()); (app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesServer = filterServer === 'all' || app.server === filterServer; const matchesServer = filterServer === 'all' || app.server === filterServer;
const matchesChaos = !showChaosOnly || app.chaos === 'true'; const matchesChaos = filterStatus === 'all' ||
(filterStatus === 'chaos' && app.chaos === 'true') ||
(filterStatus === 'stable' && app.chaos === 'false');
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest'; const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest';
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade; return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
}); });
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]); }, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = allApps.length; const total = allApps.length;
@ -89,15 +88,37 @@ export const Apps: React.FC = () => {
return { total, needsUpgrade, chaosApps, totalServers }; return { total, needsUpgrade, chaosApps, totalServers };
}, [allApps, servers]); }, [allApps, servers]);
const resetFilters = () => {
setSearchTerm('');
setFilterServer('all');
setShowChaosOnly(false);
setShowUpgradesOnly(false);
};
const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev); const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev);
const toggleChaos = () => setShowChaosOnly(prev => !prev); const toggleChaos = () => setShowChaosOnly(prev => !prev);
// Show only apps that need upgrades
const filterByUpgrades = () => {
setShowUpgradesOnly(true);
setFilterStatus('all');
};
// Show only chaos apps
const filterByChaos = () => {
setFilterStatus('chaos');
setShowUpgradesOnly(false);
};
if (loading) { if (loading) {
return ( return (
<div className="apps-page"> <div className="apps-page">
<Header /> <Header />
<main className="apps-content"> <main className="apps-content">
<div className="loading">Loading applications...</div> <div className="loading">
<Loader />
<div>Loading apps</div>
</div>
</main> </main>
</div> </div>
); );
@ -125,6 +146,15 @@ export const Apps: React.FC = () => {
{/* Compact Stats Overview */} {/* Compact Stats Overview */}
<div className="stats-row"> <div className="stats-row">
<button
className="stat-chip"
onClick={resetFilters}
title="Click to show all apps"
>
<span className="stat-label">Apps</span>
<span className="stat-value">{stats.total}</span>
</button>
<button <button
className="stat-chip" className="stat-chip"
onClick={() => navigate('/servers')} onClick={() => navigate('/servers')}
@ -135,7 +165,7 @@ export const Apps: React.FC = () => {
</button> </button>
<button <button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`} className={`stat-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={toggleUpgrades} onClick={toggleUpgrades}
title="Click to toggle apps with upgrades available" title="Click to toggle apps with upgrades available"
disabled={stats.needsUpgrade === 0} disabled={stats.needsUpgrade === 0}
@ -145,7 +175,7 @@ export const Apps: React.FC = () => {
</button> </button>
<button <button
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`} className={`stat-chip ${showChaosOnly ? 'active' : ''}`}
onClick={toggleChaos} onClick={toggleChaos}
title="Click to toggle chaos mode apps" title="Click to toggle chaos mode apps"
disabled={stats.chaosApps === 0} disabled={stats.chaosApps === 0}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraApp, AbraServer } from '../../types'; import type { AbraApp, AbraServer } from '../../types';
@ -12,7 +13,7 @@ export const Dashboard: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; const isMockMode = false;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -60,7 +61,10 @@ export const Dashboard: React.FC = () => {
<div className="dashboard-page"> <div className="dashboard-page">
<Header /> <Header />
<main className="dashboard-content"> <main className="dashboard-content">
<div className="loading">Loading dashboard...</div> <div className="loading">
<Loader />
<div>Loading dashboard</div>
</div>
</main> </main>
</div> </div>
); );

View File

@ -23,21 +23,29 @@
} }
.apps-list { .apps-list {
@include card-list-vertical; display: flex;
flex-direction: column;
gap: $spacing-md;
} }
.app-item { .app-item {
@include card; @include card;
@include card-hover-lift(-2px, $shadow-lg);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: transform $transition-base, box-shadow $transition-base;
cursor: pointer; cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
.app-info { .app-info {
flex: 1; flex: 1;
h4 { h4 {
margin: 0 0 $spacing-xs;
font-size: $font-size-lg; font-size: $font-size-lg;
color: $text-primary; color: $text-primary;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;

View File

@ -1,100 +1,59 @@
@use '../../assets/scss/variables' as *; .modal {
@use '../../assets/scss/mixins' as *; background: white;
@use '../../assets/scss/global' as *; position: relative;
padding: 24px;
.recipe-form { border-radius: 8px;
@include card; width: min(100%, 800px);
max-width: 680px; max-height: 90vh;
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);
}
}
.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-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);
}
}
} }
.form-subtitle { .modal .close-btn {
margin: 0; position: absolute;
color: $text-secondary; top: 10px;
font-size: $font-size-sm; right: 10px;
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 0, 0, 0.308);
} }
.form-error { .modal .close-btn:hover {
background: rgba($error, 0.08); background: rgba(255, 0, 0, 0.797);
color: $error;
padding: $spacing-sm $spacing-md;
border-radius: $radius-sm;
} }
.select-input { .modal .close-btn:focus {
width: 100%; 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;
}

View File

@ -1,26 +1,26 @@
import { useEffect, useState } from "react"; import { useState, useEffect } from "react";
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraServer } from '../../types'; import type { AbraServer, AbraAppSecret } from '../../types';
import './RecipeForm.scss'; import './RecipeForm.scss';
function RecipeForm({ recipe, onClose }) { function RecipeForm({ recipe, onClose }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitted, setSubmitted] = useState(false);
const [servers, setServers] = useState<AbraServer[]>([]); const [servers, setServers] = useState<AbraServer[]>([]);
const [selectedServer, setSelectedServer] = useState(""); // ❌ only one value const [secretData, setSecretData] = useState<AbraAppSecret[]>([]);
const [chaos, setChaos] = useState(false);
const [secrets, setSecrets] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [serversData] = await Promise.all([ if (servers.length === 0) {
apiService.getServers(), const [serversData] = await Promise.all([
]); apiService.getServers(),
]);
setServers(serversData);
setServers(serversData);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load servers'); setError(err instanceof Error ? err.message : 'Failed to load servers');
@ -32,91 +32,123 @@ function RecipeForm({ recipe, onClose }) {
fetchData(); fetchData();
}); });
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
server: "",
domain: "", domain: "",
chaos: false, chaos: false,
secrets: true, secrets: false,
}); });
const handleChange = (e) => { const handleChange = (e) => {
setFormData({ const {name, type, value, checked} = e.target;
...formData, setFormData((prev) => ({
[e.target.name]: e.target.value, ...prev,
}); [name]: type === "checkbox" ? checked : value,
}));
console.log(formData);
}; };
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
console.log("Submitting:", formData); console.log("Submitting:", formData);
onClose(); setSubmitted(true);
const generatedSecrets = await apiService.newApp(recipe.name, formData);
setSecretData(generatedSecrets);
}; };
return ( return (
<form className="recipe-form" onSubmit={handleSubmit}> <div className="modal">
<h2>{recipe.name}</h2> <button className="close-btn" onClick={onClose}>x</button>
<p className="form-subtitle">Configure and deploy this recipe.</p> {(secretData.length === 0 && submitted) && (
{error && <div className="form-error">{error}</div>} <p> Creating new app...</p>
)
{ loading ? ( }
<p className="loading">Loading servers...</p> {(secretData.length > 0 && submitted) && (
) : ( <div className="secrets">
<div className="field"> <table>
<label> <thead>
Choose a server to deploy to: <tr>
<select <th>Name</th>
className="select-input" <th>Value</th>
value={selectedServer} </tr>
onChange={(e) => setSelectedServer(e.target.value)} </thead>
> <tbody>
<option value="">None</option> {secretData.map((secret) => (
{servers.map((server) => ( <tr key={secret.name}>
<option key={server.name} value={server.name}> <td>{secret.name}</td>
{server.name} <td>
</option> <span>{secret.value}</span>
<button
className="copy-btn"
onClick={() => navigator.clipboard.writeText(secret.value)}
>
Copy
</button>
</td>
</tr>
))} ))}
</select> </tbody>
</label> </table>
</div> </div>
)} )}
{(submitted === false) &&
<form onSubmit={handleSubmit}>
<h2>{recipe.name}</h2>
{ loading ? (<p> Loading servers...</p>
) : (
<div>
<label>
Choose a server to deploy to:
<select
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>
<label>
Domain:
<input
name="domain"
value={formData.domain}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
Chaos Mode Enabled:
<input
type="checkbox"
name="chaos"
onChange={handleChange}
/>
</label>
</div>
<div className="field"> <div>
<label> <label>
Domain: Autogenerate Secrets:
<input <input
name="domain" type="checkbox"
placeholder="example.com" name="secrets"
value={formData.domain} onChange={handleChange}
onChange={handleChange} />
/> </label>
</label> </div>
</div>
<div className="field field-inline"> <button type="submit">Submit</button>
<label className="checkbox-label"> </form>
<input }
type="checkbox" </div>
name="chaos"
checked={formData.chaos}
onChange={(e) => setFormData({ ...formData, chaos: e.target.checked })}
/>
Chaos Mode
</label>
<label className="checkbox-label">
<input
type="checkbox"
name="secrets"
checked={formData.secrets}
onChange={(e) => setFormData({ ...formData, secrets: e.target.checked })}
/>
Autogenerate Secrets
</label>
</div>
<div className="form-actions">
<button className="action-btn primary" type="submit">Submit</button>
<button className="action-btn" type="button" onClick={onClose}>Cancel</button>
</div>
</form>
); );
} }

View File

@ -11,11 +11,10 @@
@extend .page-content; @extend .page-content;
} }
// Recipes grid // Servers grid
.recipes-grid { .recipes-grid {
display: grid; display: grid;
justify-content: stretch; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: $spacing-xl; gap: $spacing-xl;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
@ -24,16 +23,19 @@
} }
} }
// Recipe card // Server card
.recipe-card { .recipe-card {
@include card; @include card;
@include card-dimensions(320px, 180px); display: grid;
row-gap: 1em;
grid-template-rows: 1fr 2fr auto 0.9fr; grid-template-rows: 1fr 2fr auto 0.9fr;
transition: transform $transition-base, box-shadow $transition-base; transition: transform $transition-base, box-shadow $transition-base;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
max-width: 30em;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.recipe-header { .recipe-header {
display: flex; display: flex;
@ -108,7 +110,21 @@
gap: $spacing-sm; gap: $spacing-sm;
.action-btn { .action-btn {
flex: 1; flex: 1;
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm); padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
background: none;
color: $text-primary;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
&:hover {
background-color: rgba($primary, 0.1);
border-color: $primary;
transform: translateY(-1px);
}
&.primary { &.primary {
background-color: $primary; background-color: $primary;
@ -152,10 +168,3 @@ display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.modal {
background: white;
padding: 24px;
border-radius: 8px;
width: min(90%, 500px);
}

View File

@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraApp, AbraRecipe, AppWithServer } from '../../types'; import type { AbraApp, AppWithServer, AbraRecipe } from '../../types';
import RecipeForm from './RecipeForm.tsx' import RecipeForm from './RecipeForm.tsx'
import './Recipes.scss'; import './Recipes.scss';
@ -19,7 +20,7 @@ export const Recipes: React.FC = () => {
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; const isMockMode = false;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -51,7 +52,7 @@ export const Recipes: React.FC = () => {
}, [recipesData]); }, [recipesData]);
// Filter recipes // Filter apps
const filteredRecipes = useMemo(() => { const filteredRecipes = useMemo(() => {
return allRecipes.filter(recipe => { return allRecipes.filter(recipe => {
const matchesSearch = const matchesSearch =
@ -72,7 +73,10 @@ export const Recipes: React.FC = () => {
<div className="apps-page"> <div className="apps-page">
<Header /> <Header />
<main className="recipes-content"> <main className="recipes-content">
<div className="loading">Loading applications...</div> <div className="loading">
<Loader />
<div>Loading recipes</div>
</div>
</main> </main>
</div> </div>
); );
@ -94,20 +98,36 @@ export const Recipes: React.FC = () => {
<Header /> <Header />
<main className="recipes-content"> <main className="recipes-content">
<div className="page-header"> <div className="page-header">
<h1>Recipes</h1> <h1>Applications</h1>
<p className="subtitle">{stats.total} recipes</p> <p className="subtitle">{stats.total} recipes</p>
</div> </div>
{/* Stats Overview */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-info">
<p className="stat-number">{stats.total}</p>
<p className="stat-label">Total Recipes</p>
</div>
</div>
</div>
{/* Filters */} {/* Filters */}
<div className="filters"> <div className="filters">
<input <input
type="text" type="text"
placeholder="Search recipes by name or description..." placeholder="Search apps by name, recipe, or domain..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="search-input" className="search-input"
/> />
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="all">All Status</option>
<option value="stable">Stable</option>
<option value="chaos">Chaos Mode</option>
</select>
</div> </div>
{/* Server Cards */} {/* Server Cards */}
@ -119,6 +139,7 @@ export const Recipes: React.FC = () => {
<div <div
key={recipe.name} key={recipe.name}
className="recipe-card" className="recipe-card"
style={{ cursor: 'pointer' }}
> >
<div className="recipe-header"> <div className="recipe-header">
<div className="recipe-title"> <div className="recipe-title">
@ -159,7 +180,7 @@ export const Recipes: React.FC = () => {
</div> </div>
{selectedRecipe && ( {selectedRecipe && (
<div className="modal-overlay" onClick={() => setSelectedRecipe(null)}> <div className="modal-overlay" onClick={() => setSelectedRecipe(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
{} {}
<RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} /> <RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
</div> </div>

View File

@ -48,7 +48,25 @@
// Action buttons (shared with App view, could be moved to global) // Action buttons (shared with App view, could be moved to global)
.action-btn { .action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm); padding: $spacing-sm $spacing-lg;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
background: white;
color: $text-primary;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: $shadow-md;
}
&.primary { &.primary {
background: $primary; background: $primary;
@ -195,7 +213,9 @@
} }
.apps-list { .apps-list {
@include card-list-vertical; display: flex;
flex-direction: column;
gap: $spacing-md;
} }
.app-item { .app-item {

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { Terminal } from '../../components/Terminal/Terminal'; import { Terminal } from '../../components/Terminal/Terminal';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types'; import type { AbraServer, ServerAppsResponse } from '../../types';
@ -29,7 +30,7 @@ export const Server: React.FC = () => {
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]); const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false); const [terminalActive, setTerminalActive] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; const isMockMode = false;
useEffect(() => { useEffect(() => {
const fetchServer = async () => { const fetchServer = async () => {
@ -133,7 +134,10 @@ export const Server: React.FC = () => {
<div className="server-detail-page"> <div className="server-detail-page">
<Header /> <Header />
<main className="server-detail-content"> <main className="server-detail-content">
<div className="loading">Loading server...</div> <div className="loading">
<Loader />
<div> Loading server</div>
</div>
</main> </main>
</div> </div>
); );
@ -160,12 +164,13 @@ export const Server: React.FC = () => {
<div className="server-detail-page"> <div className="server-detail-page">
<Header /> <Header />
<main className="server-detail-content"> <main className="server-detail-content">
<div className="breadcrumb"> <div className="back-navigation">
<button onClick={() => navigate('/servers')} className="breadcrumb-link"> <button
Servers onClick={() => navigate(`/servers`)}
className="server-link"
>
Back to Servers
</button> </button>
<span className="breadcrumb-separator">/</span>
<span className="breadcrumb-current">{server.name}</span>
</div> </div>
<div className="server-header"> <div className="server-header">

View File

@ -14,12 +14,10 @@
// Servers grid // Servers grid
.servers-grid { .servers-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: $spacing-xl; gap: $spacing-xl;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
align-items: stretch;
@media (max-width: 768px) { @media (max-width: 768px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -28,18 +26,38 @@
// Server card // Server card
.server-card { .server-card {
@include card; @include card;
@include card-hover-lift(-4px, $shadow-xl);
@include card-dimensions(320px, 180px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: transform $transition-base, box-shadow $transition-base;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.server-header { .server-header {
@include 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;
.server-title { .server-title {
@include card-title-stack; h3 {
margin: 0 0 $spacing-xs;
font-size: $font-size-xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
.server-host {
font-size: $font-size-sm;
color: $text-muted;
font-family: monospace;
}
} }
.server-status { .server-status {
@ -62,12 +80,24 @@
margin-bottom: $spacing-lg; margin-bottom: $spacing-lg;
.stat-row { .stat-row {
@include 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;
}
// Highlighted rows // Highlighted rows
&.highlight { &.highlight {
@include card-row-highlight-bleed;
background-color: rgba($warning, 0.05); background-color: rgba($warning, 0.05);
padding: $spacing-sm $spacing-md;
margin: $spacing-sm (-$spacing-xl);
padding-left: calc($spacing-xl + $spacing-md);
padding-right: calc($spacing-xl + $spacing-md);
border-bottom: none;
.stat-label { .stat-label {
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
@ -79,8 +109,11 @@
} }
&.chaos-row { &.chaos-row {
@include card-row-highlight-bleed(false);
background-color: rgba($info, 0.05); background-color: rgba($info, 0.05);
padding: $spacing-sm $spacing-md;
margin: $spacing-sm (-$spacing-xl);
padding-left: calc($spacing-xl + $spacing-md);
padding-right: calc($spacing-xl + $spacing-md);
.stat-label { .stat-label {
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
@ -119,7 +152,21 @@
.action-btn { .action-btn {
flex: 1; flex: 1;
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm); padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
background: none;
color: $text-primary;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
&:hover {
background-color: rgba($primary, 0.1);
border-color: $primary;
transform: translateY(-1px);
}
&.primary { &.primary {
// background-color: $primary; // background-color: $primary;

View File

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header'; import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api'; import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types'; import type { AbraServer, ServerAppsResponse } from '../../types';
import './Servers.scss'; import './Servers.scss';
@ -21,9 +22,8 @@ export const Servers: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name'); const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; const isMockMode = false;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -80,15 +80,14 @@ export const Servers: React.FC = () => {
return { totalServers, totalApps, totalUpgrades, totalChaos }; return { totalServers, totalApps, totalUpgrades, totalChaos };
}, [servers]); }, [servers]);
// Filter and sort servers (additive filters allowed) // Filter and sort servers
const filteredServers = useMemo(() => { const filteredServers = useMemo(() => {
const filtered = servers.filter(server => { const filtered = servers.filter(server => {
const matchesSearch = const matchesSearch =
server.name.toLowerCase().includes(searchTerm.toLowerCase()) || server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.host.toLowerCase().includes(searchTerm.toLowerCase()); server.host.toLowerCase().includes(searchTerm.toLowerCase());
const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0; const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0;
const matchesChaos = !showChaosOnly || server.chaosCount > 0; return matchesSearch && matchesUpgrades;
return matchesSearch && matchesUpgrades && matchesChaos;
}); });
filtered.sort((a, b) => { filtered.sort((a, b) => {
@ -104,12 +103,21 @@ export const Servers: React.FC = () => {
return filtered; return filtered;
}, [servers, searchTerm, sortBy, showUpgradesOnly]); }, [servers, searchTerm, sortBy, showUpgradesOnly]);
const resetFilters = () => {
setSearchTerm('');
setSortBy('name');
setShowUpgradesOnly(false);
};
if (loading) { if (loading) {
return ( return (
<div className="servers-page"> <div className="servers-page">
<Header /> <Header />
<main className="servers-content"> <main className="servers-content">
<div className="loading">Loading servers...</div> <div className="loading">
<Loader />
<div> Loading servers</div>
</div>
</main> </main>
</div> </div>
); );
@ -137,6 +145,15 @@ export const Servers: React.FC = () => {
{/* Compact Stats Row */} {/* Compact Stats Row */}
<div className="stats-row"> <div className="stats-row">
<button
className="stat-chip"
onClick={resetFilters}
title="Click to reset filters"
>
<span className="stat-label">Servers</span>
<span className="stat-value">{stats.totalServers}</span>
</button>
<button <button
className="stat-chip" className="stat-chip"
onClick={() => navigate('/apps')} onClick={() => navigate('/apps')}
@ -147,7 +164,7 @@ export const Servers: React.FC = () => {
</button> </button>
<button <button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`} className={`stat-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={() => setShowUpgradesOnly(prev => !prev)} onClick={() => setShowUpgradesOnly(prev => !prev)}
title="Click to filter by servers with upgrades" title="Click to filter by servers with upgrades"
disabled={stats.totalUpgrades === 0} disabled={stats.totalUpgrades === 0}
@ -157,16 +174,22 @@ export const Servers: React.FC = () => {
</button> </button>
<button <button
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`} className={`stat-chip ${sortBy === 'apps' ? 'active' : ''}`}
onClick={() => setShowChaosOnly(prev => !prev)} onClick={() => setSortBy(sortBy === 'apps' ? 'name' : 'apps')}
title="Click to filter servers with chaos apps" title="Click to sort by app count"
disabled={stats.totalChaos === 0}
> >
<span className="stat-label">Chaos</span> <span className="stat-label">Chaos</span>
<span className="stat-value">{stats.totalChaos}</span> <span className="stat-value">{stats.totalChaos}</span>
</button> </button>
{/* Clear filters button removed — use stat-chip outlines for active filters */} {(searchTerm || sortBy !== 'name' || showUpgradesOnly) && (
<button
className="reset-filters-btn"
onClick={resetFilters}
>
Clear filters
</button>
)}
</div> </div>
{/* Filters */} {/* Filters */}

View File

@ -1,4 +1,4 @@
import type { AbraRecipe, AbraServer, ServerAppsResponse } from '../types'; import type { AbraServer, AbraRecipe, ServerAppsResponse, AbraAppService, DeployEvent, AbraAppSecret, File } from '../types';
// Log entry type // Log entry type
export type LogEntry = { export type LogEntry = {
@ -37,10 +37,71 @@ class ApiService {
const error = await response.json().catch(() => ({ message: 'An error occurred' })); const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP ${response.status}`); throw new Error(error.message || `HTTP ${response.status}`);
} }
if (response.status === 204) {
return undefined as T;
}
return response.json(); 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();
}
// 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),
})
}
// Get all apps grouped by server // Get all apps grouped by server
async getAppsGrouped(): Promise<ServerAppsResponse> { async getAppsGrouped(): Promise<ServerAppsResponse> {
return this.request<ServerAppsResponse>('/apps'); return this.request<ServerAppsResponse>('/apps');
@ -50,21 +111,24 @@ class ApiService {
async getServers(): Promise<AbraServer[]> { async getServers(): Promise<AbraServer[]> {
return this.request<AbraServer[]>('/servers'); return this.request<AbraServer[]>('/servers');
} }
// Get services for app
// App actions with log streaming (websocket future) async getServices(appName: string): Promise<AbraAppService[]> {
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> { return this.request<AbraAppService[]>(`/apps/${appName}/services`);
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
} }
async undeployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> { // App actions with log streaming (websocket future)
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/undeploy`, { async deployApp(appName: string): Promise<void> {
return this.request<void>(`/apps/${appName}/deploy`, {
method: 'POST',
});
}
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', method: 'POST',
}); });
@ -96,6 +160,16 @@ class ApiService {
logs.forEach(log => onLog(log)); 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
}
// Server actions with log streaming // Server actions with log streaming
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> { async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
@ -132,7 +206,7 @@ class ApiService {
} }
// recipe catalog imports // recipe catalog imports
async getRecipes(): Promise<AbraRecipe[]> { async getRecipes(): Promise<AbraRecipe[]> {
return this.request<AbraRecipe[]>('/abra/catalogue'); return this.request<AbraRecipe[]>('/catalogue');
} }
} }

View File

@ -1,4 +1,4 @@
import type { AbraRecipe, AbraServer, ServerAppsResponse } from '../types'; import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types';
import appsData from './mock-apps.json'; import appsData from './mock-apps.json';
import serversData from './mock-servers.json'; import serversData from './mock-servers.json';
import logsData from './mock-logs.json'; import logsData from './mock-logs.json';

View File

@ -37,6 +37,43 @@ export interface AppWithServer extends AbraApp {
upgradeCount: 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 { export interface Image {
image: string; image: string;
rating: string rating: string
@ -68,4 +105,14 @@ export interface AbraRecipe {
ssh_url: string; ssh_url: string;
versions: RecipeVersions; versions: RecipeVersions;
website: string; website: string;
}
export interface AbraAppSecret {
name: string;
version: string;
value: string;
}
export interface File {
content: string;
} }