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*
node_modules
pnpm-lock.yaml
dist
dist-ssr
*.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
managing VPSs, containers and application deployments.
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.
This repository contains a Vite + React + TypeScript app styled
with SCSS.
## Still a work in progess!
## 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.
---
## This is built with react, typescript, scss, and vite

View File

@ -5,10 +5,6 @@
<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>

5083
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@
"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",
@ -17,9 +16,11 @@
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'"
},
"dependencies": {
"prismjs": "^1.30.0",
"react": "^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": {
"@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-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-weight: 400;
font-display: swap;
@ -8,7 +8,7 @@
@font-face {
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-style: normal;
font-display: swap;
@ -16,7 +16,7 @@
@font-face {
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-style: normal;
font-display: swap;
@ -24,7 +24,7 @@
@font-face {
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-style: normal;
font-display: swap;

View File

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

View File

@ -23,98 +23,11 @@
// Card style
@mixin card {
background: $bg-primary;
border-radius: $radius-lg;
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;
@ -149,64 +62,4 @@
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;
}
}

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}/>
</h1>
<nav className="nav">
<button onClick={() => navigate('/dashboard')} className="nav-link">
Dashboard
</button>
<button onClick={() => navigate('/apps')} className="nav-link">
Apps
</button>

View File

@ -27,14 +27,12 @@
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%;

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 }) => {
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;
}
setDisplayedLogs([]);
let currentIndex = 0;
const streamLogs = () => {
if (currentIndex < logs.length) {
setDisplayedLogs(prev => [...prev, logs[currentIndex]]);
currentIndex++;
if (indexRef.current < logs.length) {
setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
indexRef.current++;
// Variable delay based on log type
const delay = logs[currentIndex - 1]?.type === 'command' ? 200 :
logs[currentIndex - 1]?.type === 'output' ? 100 : 300;
const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
setTimeout(streamLogs, delay);
}
@ -37,7 +36,6 @@ export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) =
streamLogs();
}, [logs, isActive]);
// Auto-scroll to bottom
useEffect(() => {
if (terminalRef.current) {
@ -73,7 +71,6 @@ export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) =
<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-timestamp">[{formatTime(log.timestamp)}]</span>
<span className="terminal-text">{log.text}</span>
</div>
))}

View File

@ -49,19 +49,42 @@
// Action buttons
.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 {
background: $primary;
color: $text-primary;
border-color: $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;
@ -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 {
display: grid;
@ -149,13 +215,12 @@
}
.domain-link {
color: $primary-dark;
color: $primary-light;
text-decoration: none;
font-size: $font-size-base;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
@ -163,7 +228,7 @@
.server-link {
background: none;
border: none;
color: $primary-dark;
color: $primary-light;
cursor: pointer;
padding: 0;
font-size: $font-size-base;
@ -171,6 +236,7 @@
transition: color $transition-base;
&:hover {
background: none;
color: $primary-light;
text-decoration: underline;
}
@ -327,7 +393,6 @@
background: rgba($error, 0.05);
}
}
.action-text {
flex: 1;
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 { Link, useNavigate, useParams } from 'react-router-dom';
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 { apiService } from '../../services/api';
import type { AbraApp } from '../../types';
import type { AbraApp, AbraAppService, AbraServiceState } from '../../types';
import type { LogEntry } from '../../services/mockApi';
import { createPortal } from "react-dom";
import './App.scss';
export const AppDetail: React.FC = () => {
@ -12,16 +16,38 @@ export const AppDetail: React.FC = () => {
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);
// Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
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(() => {
const fetchApp = async () => {
try {
@ -38,12 +64,17 @@ export const AppDetail: React.FC = () => {
setError('App not found');
}
} else {
console.log('fetching app...');
const appsData = await apiService.getAppsGrouped();
const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (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 {
setError('App not found');
}
@ -56,15 +87,55 @@ export const AppDetail: React.FC = () => {
};
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) => {
if (!app) return;
setActionLoading(action);
setTerminalActive(true);
setTerminalLogs([]);
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
@ -94,9 +165,57 @@ export const AppDetail: React.FC = () => {
switch (action) {
case 'stop':
await apiService.stopApp(app.appName);
setRefreshKey(prev => prev + 1);
break;
case 'deploy':
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;
}
}
@ -117,7 +236,10 @@ export const AppDetail: React.FC = () => {
<div className="app-detail-page">
<Header />
<main className="app-detail-content">
<div className="loading">Loading application...</div>
<div className="loading">
<Loader />
<div>Loading application</div>
</div>
</main>
</div>
);
@ -136,19 +258,23 @@ export const AppDetail: React.FC = () => {
</div>
);
}
const upgradeVersions = app.upgrade !== 'latest' ? app.upgrade.split('\n') : [];
// 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)} />
)}
<Header />
<main className="app-detail-content">
<div className="breadcrumb">
<button onClick={() => navigate('/apps')} className="breadcrumb-link">
Apps
<div className="back-navigation">
<button
onClick={() => navigate(`/apps`)}
className="server-link"
>
Back to Apps
</button>
<span className="breadcrumb-separator">/</span>
<span className="breadcrumb-current">{app.appName}</span>
</div>
<div className="app-header">
@ -167,14 +293,14 @@ export const AppDetail: React.FC = () => {
<button
className="action-btn danger"
onClick={() => handleAction('stop')}
disabled={!!actionLoading}
disabled={!!actionLoading || deployState === "undeployed"}
>
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
</button>
<button
className="action-btn primary"
className="action-btn"
onClick={() => handleAction('deploy')}
disabled={!!actionLoading}
disabled={!!actionLoading || deployState === "deployed"}
>
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
</button>
@ -185,7 +311,11 @@ export const AppDetail: React.FC = () => {
<Terminal
logs={terminalLogs}
isActive={terminalActive}
onClose={() => setTerminalActive(false)}
onClose={() => {
stopRef.current?.();
stopRef.current = null;
setTerminalActive(false)
}}
/>
<div className="content-grid">
@ -242,7 +372,70 @@ export const AppDetail: React.FC = () => {
</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>
@ -282,6 +475,18 @@ export const AppDetail: React.FC = () => {
</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
@ -317,7 +522,7 @@ export const AppDetail: React.FC = () => {
<button
className="action-list-item danger"
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');
}
}}
@ -325,6 +530,13 @@ export const AppDetail: React.FC = () => {
>
<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>
</div>
</section>

View File

@ -21,12 +21,31 @@
}
.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 {
border-color: $primary-dark;
background: $bg-primary;
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
border-color: $primary;
background: rgba($primary, 0.05);
}
.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-container {
@include scrollable-card;
@include card;
overflow-x: auto;
margin-bottom: $spacing-lg;
}
@ -105,18 +141,18 @@
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;
}
}
}
.domain-link {
color: $primary-dark;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
.no-domain {
color: $text-muted;
@ -146,7 +182,15 @@
gap: $spacing-sm;
.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 {
background-color: $primary;

View File

@ -1,11 +1,7 @@
// 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';
@ -17,10 +13,11 @@ export const Apps: React.FC = () => {
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
@ -64,7 +61,7 @@ export const Apps: React.FC = () => {
return Object.keys(appsData);
}, [appsData]);
// Filter apps (additive filters: upgrades AND chaos can be applied together)
// Filter apps
const filteredApps = useMemo(() => {
return allApps.filter(app => {
const matchesSearch =
@ -73,12 +70,14 @@ export const Apps: React.FC = () => {
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
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';
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
});
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]);
}, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]);
const stats = useMemo(() => {
const total = allApps.length;
@ -89,15 +88,37 @@ export const Apps: React.FC = () => {
return { total, needsUpgrade, chaosApps, totalServers };
}, [allApps, servers]);
const resetFilters = () => {
setSearchTerm('');
setFilterServer('all');
setShowChaosOnly(false);
setShowUpgradesOnly(false);
};
const toggleUpgrades = () => setShowUpgradesOnly(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) {
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="loading">Loading applications...</div>
<div className="loading">
<Loader />
<div>Loading apps</div>
</div>
</main>
</div>
);
@ -125,6 +146,15 @@ export const Apps: React.FC = () => {
{/* Compact Stats Overview */}
<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
className="stat-chip"
onClick={() => navigate('/servers')}
@ -135,7 +165,7 @@ export const Apps: React.FC = () => {
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
className={`stat-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={toggleUpgrades}
title="Click to toggle apps with upgrades available"
disabled={stats.needsUpgrade === 0}
@ -145,7 +175,7 @@ export const Apps: React.FC = () => {
</button>
<button
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`}
className={`stat-chip ${showChaosOnly ? 'active' : ''}`}
onClick={toggleChaos}
title="Click to toggle chaos mode apps"
disabled={stats.chaosApps === 0}

View File

@ -1,5 +1,6 @@
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';
@ -12,7 +13,7 @@ export const Dashboard: React.FC = () => {
const [error, setError] = useState('');
const navigate = useNavigate();
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
@ -60,7 +61,10 @@ export const Dashboard: React.FC = () => {
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="loading">Loading dashboard...</div>
<div className="loading">
<Loader />
<div>Loading dashboard</div>
</div>
</main>
</div>
);

View File

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

View File

@ -1,100 +1,59 @@
@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);
}
}
.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);
}
}
.modal {
background: white;
position: relative;
padding: 24px;
border-radius: 8px;
width: min(100%, 800px);
max-height: 90vh;
}
.form-subtitle {
margin: 0;
color: $text-secondary;
font-size: $font-size-sm;
.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);
}
.form-error {
background: rgba($error, 0.08);
color: $error;
padding: $spacing-sm $spacing-md;
border-radius: $radius-sm;
.modal .close-btn:hover {
background: rgba(255, 0, 0, 0.797);
}
.select-input {
width: 100%;
.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;
}

View File

@ -1,26 +1,26 @@
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import { apiService } from '../../services/api';
import type { AbraServer } from '../../types';
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 [selectedServer, setSelectedServer] = useState(""); // ❌ only one value
const [chaos, setChaos] = useState(false);
const [secrets, setSecrets] = useState(false);
const [secretData, setSecretData] = useState<AbraAppSecret[]>([]);
const [error, setError] = useState('');
useEffect(() => {
const fetchData = async () => {
try {
const [serversData] = await Promise.all([
apiService.getServers(),
]);
setServers(serversData);
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');
@ -32,91 +32,123 @@ function RecipeForm({ recipe, onClose }) {
fetchData();
});
const [formData, setFormData] = useState({
server: "",
domain: "",
chaos: false,
secrets: true,
secrets: false,
});
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
const {name, type, value, checked} = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
console.log(formData);
};
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault();
console.log("Submitting:", formData);
onClose();
setSubmitted(true);
const generatedSecrets = await apiService.newApp(recipe.name, formData);
setSecretData(generatedSecrets);
};
return (
<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 className="loading">Loading servers...</p>
) : (
<div className="field">
<label>
Choose a server to deploy to:
<select
className="select-input"
value={selectedServer}
onChange={(e) => setSelectedServer(e.target.value)}
>
<option value="">None</option>
{servers.map((server) => (
<option key={server.name} value={server.name}>
{server.name}
</option>
<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>
))}
</select>
</label>
</tbody>
</table>
</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">
<label>
Domain:
<input
name="domain"
placeholder="example.com"
value={formData.domain}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
Autogenerate Secrets:
<input
type="checkbox"
name="secrets"
onChange={handleChange}
/>
</label>
</div>
<div className="field field-inline">
<label className="checkbox-label">
<input
type="checkbox"
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>
<button type="submit">Submit</button>
</form>
}
</div>
);
}

View File

@ -11,11 +11,10 @@
@extend .page-content;
}
// Recipes grid
// Servers grid
.recipes-grid {
display: grid;
justify-content: stretch;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
@ -24,16 +23,19 @@
}
}
// Recipe card
// Server card
.recipe-card {
@include card;
@include card-dimensions(320px, 180px);
row-gap: 1em;
display: grid;
grid-template-rows: 1fr 2fr auto 0.9fr;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
max-width: 30em;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.recipe-header {
display: flex;
@ -108,7 +110,21 @@
gap: $spacing-sm;
.action-btn {
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 {
background-color: $primary;
@ -152,10 +168,3 @@ display: flex;
justify-content: 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 { 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 type { AbraApp, AppWithServer, AbraRecipe } from '../../types';
import RecipeForm from './RecipeForm.tsx'
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(() => {
const fetchData = async () => {
@ -51,7 +52,7 @@ export const Recipes: React.FC = () => {
}, [recipesData]);
// Filter recipes
// Filter apps
const filteredRecipes = useMemo(() => {
return allRecipes.filter(recipe => {
const matchesSearch =
@ -72,7 +73,10 @@ export const Recipes: React.FC = () => {
<div className="apps-page">
<Header />
<main className="recipes-content">
<div className="loading">Loading applications...</div>
<div className="loading">
<Loader />
<div>Loading recipes</div>
</div>
</main>
</div>
);
@ -94,20 +98,36 @@ export const Recipes: React.FC = () => {
<Header />
<main className="recipes-content">
<div className="page-header">
<h1>Recipes</h1>
<h1>Applications</h1>
<p className="subtitle">{stats.total} recipes</p>
</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 */}
<div className="filters">
<input
type="text"
placeholder="Search recipes by name or description..."
placeholder="Search apps by name, recipe, or domain..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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>
{/* Server Cards */}
@ -119,6 +139,7 @@ export const Recipes: React.FC = () => {
<div
key={recipe.name}
className="recipe-card"
style={{ cursor: 'pointer' }}
>
<div className="recipe-header">
<div className="recipe-title">
@ -159,7 +180,7 @@ export const Recipes: React.FC = () => {
</div>
{selectedRecipe && (
<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)} />
</div>

View File

@ -48,7 +48,25 @@
// 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);
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 {
background: $primary;
@ -195,7 +213,9 @@
}
.apps-list {
@include card-list-vertical;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.app-item {

View File

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

View File

@ -14,12 +14,10 @@
// Servers grid
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
align-items: stretch;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
@ -28,18 +26,38 @@
// Server card
.server-card {
@include card;
@include card-hover-lift(-4px, $shadow-xl);
@include card-dimensions(320px, 180px);
display: flex;
flex-direction: column;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.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 {
@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 {
@ -62,12 +80,24 @@
margin-bottom: $spacing-lg;
.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
&.highlight {
@include card-row-highlight-bleed;
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 {
font-weight: $font-weight-semibold;
@ -79,8 +109,11 @@
}
&.chaos-row {
@include card-row-highlight-bleed(false);
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 {
font-weight: $font-weight-semibold;
@ -119,7 +152,21 @@
.action-btn {
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 {
// background-color: $primary;

View File

@ -1,6 +1,7 @@
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';
@ -21,9 +22,8 @@ export const Servers: React.FC = () => {
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';
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
@ -80,15 +80,14 @@ export const Servers: React.FC = () => {
return { totalServers, totalApps, totalUpgrades, totalChaos };
}, [servers]);
// Filter and sort servers (additive filters allowed)
// Filter and sort servers
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;
return matchesSearch && matchesUpgrades;
});
filtered.sort((a, b) => {
@ -104,12 +103,21 @@ export const Servers: React.FC = () => {
return filtered;
}, [servers, searchTerm, sortBy, showUpgradesOnly]);
const resetFilters = () => {
setSearchTerm('');
setSortBy('name');
setShowUpgradesOnly(false);
};
if (loading) {
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="loading">Loading servers...</div>
<div className="loading">
<Loader />
<div> Loading servers</div>
</div>
</main>
</div>
);
@ -137,6 +145,15 @@ export const Servers: React.FC = () => {
{/* Compact 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
className="stat-chip"
onClick={() => navigate('/apps')}
@ -147,7 +164,7 @@ export const Servers: React.FC = () => {
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
className={`stat-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={() => setShowUpgradesOnly(prev => !prev)}
title="Click to filter by servers with upgrades"
disabled={stats.totalUpgrades === 0}
@ -157,16 +174,22 @@ export const Servers: React.FC = () => {
</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}
className={`stat-chip ${sortBy === 'apps' ? 'active' : ''}`}
onClick={() => setSortBy(sortBy === 'apps' ? 'name' : 'apps')}
title="Click to sort by app count"
>
<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 */}
{(searchTerm || sortBy !== 'name' || showUpgradesOnly) && (
<button
className="reset-filters-btn"
onClick={resetFilters}
>
Clear filters
</button>
)}
</div>
{/* 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
export type LogEntry = {
@ -37,10 +37,71 @@ class ApiService {
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;
}
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
async getAppsGrouped(): Promise<ServerAppsResponse> {
return this.request<ServerAppsResponse>('/apps');
@ -50,21 +111,24 @@ class ApiService {
async getServers(): Promise<AbraServer[]> {
return this.request<AbraServer[]>('/servers');
}
// App actions with log streaming (websocket future)
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
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));
}
// Get services for app
async getServices(appName: string): Promise<AbraAppService[]> {
return this.request<AbraAppService[]>(`/apps/${appName}/services`);
}
async undeployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/undeploy`, {
// App actions with log streaming (websocket future)
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',
});
@ -96,6 +160,16 @@ class ApiService {
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
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
@ -132,7 +206,7 @@ class ApiService {
}
// recipe catalog imports
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 serversData from './mock-servers.json';
import logsData from './mock-logs.json';

View File

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