app route

This commit is contained in:
Matt Beaudoin
2025-11-14 12:39:16 -08:00
parent 371f4fdebc
commit a84bd39a6d
9 changed files with 1347 additions and 82 deletions

View File

@ -3,6 +3,7 @@ import { AuthProvider } from './context/AuthContext';
import { LoginForm } from './routes/Login/LoginForm';
import { Authenticated } from './routes/Login/Authenticated';
import { Dashboard } from './routes/Authenticated/Dashboard/Dashboard';
import { Apps } from './routes/Authenticated/Apps/Apps';
function App() {
return (
@ -27,7 +28,7 @@ function App() {
path="/apps"
element={
<Authenticated>
<div style={{ padding: '2rem' }}>Apps page - Coming soon</div>
<Apps />
</Authenticated>
}
/>

View File

@ -0,0 +1,325 @@
@use '../../../assets/scss/variables' as *;
@use '../../../assets/scss/mixins' as *;
.apps-page {
min-height: 100vh;
background-color: $bg-secondary;
}
.apps-content {
max-width: 1600px;
margin: 0 auto;
padding: $spacing-2xl $spacing-xl;
@media (max-width: 768px) {
padding: $spacing-xl $spacing-md;
}
}
.page-header {
margin-bottom: $spacing-2xl;
h1 {
font-size: $font-size-3xl;
margin: 0 0 $spacing-sm;
color: $text-primary;
}
.subtitle {
font-size: $font-size-lg;
color: $text-secondary;
margin: 0;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-2xl;
}
.stat-card {
@include card;
display: flex;
align-items: center;
gap: $spacing-lg;
padding: $spacing-xl;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
&.upgrade {
border-left: 4px solid $warning;
}
&.chaos {
border-left: 4px solid $info;
}
.stat-icon {
font-size: 2rem;
}
.stat-info {
.stat-number {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $text-primary;
margin: 0;
line-height: 1;
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
margin: $spacing-xs 0 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.filters {
@include card;
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-xl;
@media (max-width: 768px) {
flex-direction: column;
}
.search-input {
flex: 1;
min-width: 250px;
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
transition: border-color $transition-base;
&:focus {
outline: none;
border-color: $primary;
}
}
select {
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
background-color: white;
cursor: pointer;
transition: border-color $transition-base;
&:focus {
outline: none;
border-color: $primary;
}
}
.checkbox-filter {
display: flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
}
span {
font-size: $font-size-sm;
color: $text-primary;
}
}
}
.apps-table-container {
@include card;
overflow-x: auto;
margin-bottom: $spacing-lg;
}
.apps-table {
width: 100%;
border-collapse: collapse;
thead {
background-color: $bg-tertiary;
th {
padding: $spacing-md $spacing-lg;
text-align: left;
font-weight: $font-weight-semibold;
color: $text-primary;
font-size: $font-size-sm;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid $border-color;
}
}
tbody {
tr {
border-bottom: 1px solid $border-color;
transition: background-color $transition-base;
&:hover {
background-color: rgba($primary, 0.05);
}
&:last-child {
border-bottom: none;
}
}
td {
padding: $spacing-md $spacing-lg;
font-size: $font-size-base;
color: $text-primary;
&.no-results {
text-align: center;
padding: $spacing-3xl;
color: $text-secondary;
}
}
}
}
.app-name-cell {
.app-name {
font-weight: $font-weight-medium;
color: $text-primary;
}
}
.recipe-badge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
background-color: rgba($info, 0.1);
color: darken($info, 20%);
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
.domain-link {
color: $primary;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-dark;
text-decoration: underline;
}
}
.no-domain {
color: $text-muted;
}
.server-badge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
background-color: rgba($text-secondary, 0.1);
color: $text-secondary;
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
.version-cell {
display: flex;
align-items: center;
gap: $spacing-sm;
.version {
font-family: monospace;
font-size: $font-size-sm;
}
.chaos-badge,
.upgrade-available {
font-size: $font-size-base;
}
}
.status-badge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: capitalize;
&.status-deployed {
background-color: rgba($success, 0.1);
color: darken($success, 20%);
}
&.status-stopped {
background-color: rgba($text-muted, 0.1);
color: $text-muted;
}
&.status-error {
background-color: rgba($error, 0.1);
color: darken($error, 10%);
}
}
.actions {
display: flex;
gap: $spacing-sm;
.action-btn {
border: 1px solid $border-color;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
font-size: $font-size-base;
transition: all $transition-base;
&:hover {
background-color: $bg-tertiary;
transform: scale(1.1);
}
&.upgrade {
border-color: $warning;
}
}
}
.results-count {
text-align: center;
color: $text-secondary;
font-size: $font-size-sm;
padding: $spacing-md;
}
.loading,
.error {
text-align: center;
padding: $spacing-3xl;
font-size: $font-size-lg;
}
.loading {
color: $text-secondary;
}
.error {
color: $error;
}

View File

@ -0,0 +1,259 @@
// routes/Apps/Apps.tsx
import React, { useEffect, useState, useMemo } from 'react';
import { Header } from '../../../components/Header/Header';
import { apiService } from '../../../services/api';
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../../types';
import './Apps.scss';
export const Apps: React.FC = () => {
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockAppsData } = await import('../../../services/mockApi');
setAppsData(mockAppsData);
} else {
const data = await apiService.getAppsGrouped();
setAppsData(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load apps');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Flatten and enrich apps data
const allApps: AppWithServer[] = useMemo(() => {
if (!appsData) return [];
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
serverData.apps.map(app => ({
...app,
serverStats: {
appCount: serverData.appCount,
upgradeCount: serverData.upgradeCount,
},
}))
);
}, [appsData]);
// Get unique servers
const servers = useMemo(() => {
if (!appsData) return [];
return Object.keys(appsData);
}, [appsData]);
// Filter apps
const filteredApps = useMemo(() => {
return allApps.filter(app => {
const matchesSearch =
app.appName.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.recipe.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.domain.toLowerCase().includes(searchTerm.toLowerCase());
const matchesServer = filterServer === 'all' || app.server === filterServer;
const matchesChaos = 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, filterStatus, showUpgradesOnly]);
const stats = useMemo(() => {
const total = allApps.length;
const needsUpgrade = allApps.filter(app => app.upgrade !== 'latest').length;
const chaosApps = allApps.filter(app => app.chaos === 'true').length;
const totalServers = servers.length;
return { total, needsUpgrade, chaosApps, totalServers };
}, [allApps, servers]);
if (loading) {
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="loading">Loading applications...</div>
</main>
</div>
);
}
if (error) {
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="page-header">
<h1>Applications</h1>
<p className="subtitle">{stats.total} apps across {stats.totalServers} servers</p>
</div>
{/* 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 Apps</p>
</div>
</div>
<div className="stat-card upgrade">
<div className="stat-info">
<p className="stat-number">{stats.needsUpgrade}</p>
<p className="stat-label">Upgrades Available</p>
</div>
</div>
<div className="stat-card chaos">
<div className="stat-info">
<p className="stat-number">{stats.chaosApps}</p>
<p className="stat-label">Chaos Mode</p>
</div>
</div>
<div className="stat-card">
<div className="stat-info">
<p className="stat-number">{stats.totalServers}</p>
<p className="stat-label">Servers</p>
</div>
</div>
</div>
{/* Filters */}
<div className="filters">
<input
type="text"
placeholder="Search apps by name, recipe, or domain..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<select value={filterServer} onChange={(e) => setFilterServer(e.target.value)}>
<option value="all">All Servers</option>
{servers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
<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>
<label className="checkbox-filter">
<input
type="checkbox"
checked={showUpgradesOnly}
onChange={(e) => setShowUpgradesOnly(e.target.checked)}
/>
<span>Show only apps with upgrades</span>
</label>
</div>
{/* Apps Table */}
<div className="apps-table-container">
<table className="apps-table">
<thead>
<tr>
<th>App Name</th>
<th>Recipe</th>
<th>Domain</th>
<th>Server</th>
<th>Version</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredApps.length === 0 ? (
<tr>
<td colSpan={7} className="no-results">
No apps found matching your filters
</td>
</tr>
) : (
filteredApps.map((app, index) => (
<tr key={`${app.server}-${app.appName}-${index}`}>
<td className="app-name-cell">
<span className="app-name">{app.appName}</span>
</td>
<td>
<span className="recipe-badge">{app.recipe}</span>
</td>
<td>
{app.domain ? (
<a href={`https://${app.domain}`} target="_blank" rel="noopener noreferrer" className="domain-link">
{app.domain}
</a>
) : (
<span className="no-domain"></span>
)}
</td>
<td>
<span className="server-badge">{app.server}</span>
</td>
<td>
<div className="version-cell">
<span className="version">{app.version}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled"></span>
)}
{app.upgrade !== 'latest' && (
<span className="upgrade-available" title="Upgrade available">
</span>
)}
</div>
</td>
<td>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</td>
<td>
<div className="actions">
<button className="action-btn" title="View details">details</button>
{app.upgrade !== 'latest' && (
<button className="action-btn upgrade" title="Upgrade">upgrade</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="results-count">
Showing {filteredApps.length} of {allApps.length} apps
</div>
</main>
</div>
);
};

View File

@ -19,18 +19,24 @@ export const Dashboard: React.FC = () => {
// Use mock API in development
const { mockApiService } = await import('../../../services/mockApi');
const [appsData, serversData] = await Promise.all([
mockApiService.getApps(),
mockApiService.getAppsGrouped(),
mockApiService.getServers(),
]);
setApps(appsData);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
} else {
// Use real API in production
const [appsData, serversData] = await Promise.all([
apiService.getApps(),
apiService.getAppsGrouped(),
apiService.getServers(),
]);
setApps(appsData);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
}
} catch (err) {
@ -76,7 +82,7 @@ export const Dashboard: React.FC = () => {
<h3>Apps</h3>
<p className="stat-number">{apps.length}</p>
<p className="stat-label">
{apps.filter(a => a.status === 'running').length} running
{apps.filter(a => a.status === 'deployed').length} deployed
</p>
</div>
@ -84,7 +90,7 @@ export const Dashboard: React.FC = () => {
<h3>Servers</h3>
<p className="stat-number">{servers.length}</p>
<p className="stat-label">
{servers.filter(s => s.connected).length} connected
{servers.length} connected
</p>
</div>
</div>
@ -92,11 +98,12 @@ export const Dashboard: React.FC = () => {
<section className="recent-apps">
<h3>Recent Applications</h3>
<div className="apps-list">
{apps.slice(0, 5).map((app) => (
<div key={app.id} className="app-item">
{apps.slice(0, 5).map((app, index) => (
<div key={`${app.server}-${app.appName}-${index}`} className="app-item">
<div className="app-info">
<h4>{app.name}</h4>
<p className="app-domain">{app.domain}</p>
<h4>{app.appName}</h4>
<p className="app-domain">{app.domain || 'No domain'}</p>
<p className="app-server">{app.server}</p>
</div>
<span className={`status-badge status-${app.status}`}>
{app.status}

View File

@ -1,3 +1,5 @@
// services/api.ts
import type { AbraApp, AbraServer, AuthResponse, LoginCredentials, User } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
@ -58,8 +60,8 @@ class ApiService {
}
// Abra CLI wrapper methods
async getApps(): Promise<AbraApp[]> {
return this.request<AbraApp[]>('/abra/apps');
async getAppsGrouped(): Promise<ServerAppsResponse> {
return this.request<ServerAppsResponse>('/abra/apps');
}
async getServers(): Promise<AbraServer[]> {

View File

@ -0,0 +1,593 @@
{
"mydomain.com": {
"apps": [
{
"server": "mydomain.com",
"recipe": "authentik",
"appName": "accounts.mydomain.com",
"domain": "accounts.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "7.4.0+2025.6.3",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4"
},
{
"server": "mydomain.com",
"recipe": "backup-bot-two",
"appName": "backup.mydomain.com",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "mydomain.com",
"recipe": "collabora",
"appName": "docs.mydomain.com",
"domain": "docs.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.2.0+24.04.12.3.1",
"version": "3.2.0+24.04.12.3.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1"
},
{
"server": "mydomain.com",
"recipe": "nextcloud",
"appName": "nc.mydomain.com",
"domain": "nc.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "12.0.1+31.0.6-fpm",
"upgrade": "latest"
},
{
"server": "mydomain.com",
"recipe": "traefik",
"appName": "traefik.mydomain.com",
"domain": "traefik.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
}
],
"appCount": 5,
"versionCount": 5,
"unversionedCount": 0,
"latestCount": 2,
"upgradeCount": 3
},
"coop.test.org": {
"apps": [
{
"server": "coop.test.org",
"recipe": "cryptpad",
"appName": "cryptpad.coop.test.org",
"domain": "cryptpad.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "cb2a47fb",
"version": "0.4.0+version-2024.3.0",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "garage",
"appName": "garage.coop.test.org",
"domain": "garage.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "3a729d56+U",
"version": "3a729d56+U",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "mobilizon",
"appName": "mobilizon.test.org",
"domain": "mobilizon.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "f8f874a5 + unstaged changes",
"version": "0.2.1+5.1.2",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "owncast",
"appName": "tv.test.org",
"domain": "tv.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "0.3.0+0.2.1",
"version": "0.3.0+0.2.1",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "privatebin",
"appName": "paste.test.org",
"domain": "paste.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "0.6.1+0.17.4",
"version": "0.6.0+1.7.6",
"upgrade": "0.6.1+0.17.4"
},
{
"server": "coop.test.org",
"recipe": "traefik",
"appName": "traefik.coop.test.org",
"domain": "traefik.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "27d5c092",
"version": "27d5c092",
"upgrade": "latest"
}
],
"appCount": 6,
"versionCount": 6,
"unversionedCount": 0,
"latestCount": 5,
"upgradeCount": 1
},
"internal.website.com": {
"apps": [
{
"server": "internal.website.com",
"recipe": "backup-bot-two",
"appName": "backup.internal.website.com",
"domain": "backup.internal.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.website.com",
"recipe": "baserow",
"appName": "base.website.com",
"domain": "base.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "internal.website.com",
"recipe": "traefik",
"appName": "traefik.internal.website.com",
"domain": "traefik.internal.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.6.2+v3.4.5",
"upgrade": "latest"
}
],
"appCount": 3,
"versionCount": 3,
"unversionedCount": 0,
"latestCount": 2,
"upgradeCount": 1
},
"internal.server.net": {
"apps": [
{
"server": "internal.server.net",
"recipe": "authentik",
"appName": "accounts.server.net",
"domain": "accounts.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "6.11.0+2024.10.5",
"version": "6.11.0+2024.10.5",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
},
{
"server": "internal.server.net",
"recipe": "authentik",
"appName": "accounts.test.server.net",
"domain": "accounts.test.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "7.1.0+2025.2.4",
"version": "7.1.0+2025.2.4",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1"
},
{
"server": "internal.server.net",
"recipe": "backup-bot-two",
"appName": "backup.server.net",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "custom-html",
"appName": "counterspy.zip",
"domain": "counterspy.zip",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.11.0+1.29.0",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "garage",
"appName": "garage.server.net",
"domain": "garage.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "20bfd2c6+U",
"version": "20bfd2c6+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "gitlab",
"appName": "git.server.net",
"domain": "git.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "0.2.2+18.3.0-ce.0",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "jitsi",
"appName": "jitsi.server.net",
"domain": "jitsi.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "d3dd84ab + unstaged changes",
"version": "0.0.1+8719",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "liberaforms",
"appName": "forms.test.server.net",
"domain": "forms.test.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "b3e74fa7+U",
"version": "b3e74fa7+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "mattermost",
"appName": "mattermost-demo.server.net",
"domain": "mattermost-demo.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "52580808",
"version": "52580808",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "openemr",
"appName": "openemr.server.net",
"domain": "openemr.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "6b62e331",
"version": "6b62e331",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "outline",
"appName": "wiki.server.net",
"domain": "wiki.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "ab70b3c4+U",
"version": "ab70b3c4+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "statping",
"appName": "status.server.net",
"domain": "status.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "bd875e08",
"version": "0.1.1+v0.90.78",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "traefik",
"appName": "traefik.server.net",
"domain": "traefik.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.6.2+v3.4.5",
"upgrade": "latest"
}
],
"appCount": 13,
"versionCount": 13,
"unversionedCount": 0,
"latestCount": 11,
"upgradeCount": 2
},
"internal.intranet.site.com": {
"apps": [
{
"server": "internal.intranet.site.com",
"recipe": "backup-bot-two",
"appName": "backup.intranet.site.com",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.intranet.site.com",
"recipe": "baserow",
"appName": "base.intranet.site.com",
"domain": "base.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "internal.intranet.site.com",
"recipe": "traefik",
"appName": "traefik.intranet.site.com",
"domain": "traefik.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
},
{
"server": "internal.intranet.site.com",
"recipe": "vaultwarden",
"appName": "pass.intranet.site.com",
"domain": "pass.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.0.0+1.33.2",
"upgrade": "2.1.0+1.34.1"
}
],
"appCount": 4,
"versionCount": 4,
"unversionedCount": 0,
"latestCount": 1,
"upgradeCount": 3
},
"internal.test.org": {
"apps": [
{
"server": "internal.test.org",
"recipe": "authentik",
"appName": "accounts.test.org",
"domain": "accounts.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "9.0.0+2025.8.1",
"upgrade": "9.0.1+2025.8.1"
},
{
"server": "internal.test.org",
"recipe": "backup-bot-two",
"appName": "backup.test.org",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "collabora",
"appName": "docs.test.org",
"domain": "docs.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.1.0+24.04.10.2.1",
"version": "3.1.0+24.04.10.2.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
},
{
"server": "internal.test.org",
"recipe": "custom-html",
"appName": "test.org",
"domain": "test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "ba1c2269",
"version": "1.7.1+1.27.2",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "garage",
"appName": "garage.test.org",
"domain": "garage.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "9ca66f0f",
"version": "9ca66f0f",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "nextcloud",
"appName": "nc.test.org",
"domain": "nc.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "11.0.0+30.0.4-fpm",
"version": "11.0.0+30.0.4-fpm",
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm\n11.1.0+30.0.6-fpm\n11.0.1+30.0.4-fpm"
},
{
"server": "internal.test.org",
"recipe": "outline",
"appName": "wiki.test.org",
"domain": "wiki.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.8.1+0.81.1",
"version": "2.8.1+0.81.1",
"upgrade": "2.12.1+0.87.4\n2.12.0+0.87.3\n2.11.0+0.86.1\n2.10.0+0.84.0\n2.9.1+0.82.0\n2.9.0+0.82.0\n2.8.2+0.81.1"
},
{
"server": "internal.test.org",
"recipe": "traefik",
"appName": "traefik.test.org",
"domain": "traefik.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "993ed9cf",
"version": "993ed9cf",
"upgrade": "latest"
}
],
"appCount": 8,
"versionCount": 8,
"unversionedCount": 0,
"latestCount": 4,
"upgradeCount": 4
},
"orgsite.org": {
"apps": [
{
"server": "orgsite.org",
"recipe": "authentik",
"appName": "accounts.orgsite.org",
"domain": "accounts.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "6.11.0+2024.10.5",
"version": "6.11.0+2024.10.5",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
},
{
"server": "orgsite.org",
"recipe": "backup-bot-two",
"appName": "backup.orgsite.org",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "baserow",
"appName": "base.orgsite.org",
"domain": "base.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "orgsite.org",
"recipe": "collabora",
"appName": "docs.orgsite.org",
"domain": "docs.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.1.0+24.04.10.2.1",
"version": "3.1.0+24.04.10.2.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
},
{
"server": "orgsite.org",
"recipe": "jitsi",
"appName": "meet.orgsite.org",
"domain": "meet.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "d3dd84ab",
"version": "0.0.1+8719",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "mattermost",
"appName": "chat.orgsite.org",
"domain": "chat.orgsite.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "5f0295b9",
"version": "2.1.0+10.5.0",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "nextcloud",
"appName": "nc.orgsite.org",
"domain": "nc.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "11.1.0+30.0.6-fpm",
"version": "11.1.0+30.0.6-fpm",
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm"
},
{
"server": "orgsite.org",
"recipe": "traefik",
"appName": "traefik.orgsite.org",
"domain": "traefik.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
},
{
"server": "orgsite.org",
"recipe": "vaultwarden",
"appName": "pass.orgsite.org",
"domain": "pass.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.0.0+1.33.2",
"upgrade": "2.1.0+1.34.1"
}
],
"appCount": 9,
"versionCount": 9,
"unversionedCount": 0,
"latestCount": 3,
"upgradeCount": 6
}
}

View File

@ -0,0 +1,30 @@
[
{
"host": "mydomain.com",
"name": "mydomain.com"
},
{
"host": "coop.test.org",
"name": "coop.test.org"
},
{
"host": "internal.website.com",
"name": "internal.website.com"
},
{
"host": "internal.server.net",
"name": "internal.server.net"
},
{
"host": "internal.intranet.site.com",
"name": "internal.intranet.site.com"
},
{
"host": "internal.test.org",
"name": "internal.test.org"
},
{
"host": "orgsite.org",
"name": "orgsite.org"
}
]

View File

@ -1,62 +1,91 @@
import type { AbraApp, AbraServer } from '../types';
// Mock dev data
export const mockApps: AbraApp[] = [
{
id: '1',
name: 'nextcloud',
domain: 'cloud.example.coop',
status: 'running',
recipe: 'nextcloud',
import type { AbraServer, ServerAppsResponse } from '../types';
// Mock data matching real API structure
export const mockAppsData: ServerAppsResponse = {
"mydomain.com": {
apps: [
{
server: "mydomain.com",
recipe: "nextcloud",
appName: "nc.mydomain.com",
domain: "nc.mydomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
version: "12.0.1+31.0.6-fpm",
upgrade: "latest"
},
{
server: "mydomain.com",
recipe: "traefik",
appName: "traefik.mydomain.com",
domain: "traefik.mydomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
version: "3.4.2+v3.4.5",
upgrade: "3.6.2+v3.4.5\n3.6.1+v3.4.5"
},
{
server: "mydomain.com",
recipe: "authentik",
appName: "accounts.mydomain.com",
domain: "accounts.mydomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
version: "7.4.0+2025.6.3",
upgrade: "9.0.1+2025.8.1\n9.0.0+2025.8.1"
}
],
appCount: 3,
versionCount: 3,
unversionedCount: 0,
latestCount: 1,
upgradeCount: 2
},
{
id: '2',
name: 'wordpress',
domain: 'blog.example.coop',
status: 'running',
recipe: 'wordpress',
},
{
id: '3',
name: 'gitea',
domain: 'git.example.coop',
status: 'stopped',
recipe: 'gitea',
},
{
id: '4',
name: 'discourse',
domain: 'forum.example.coop',
status: 'running',
recipe: 'discourse',
},
{
id: '5',
name: 'peertube',
domain: 'video.example.coop',
status: 'error',
recipe: 'peertube',
},
];
"test.coop": {
apps: [
{
server: "test.coop",
recipe: "cryptpad",
appName: "cryptpad.test.coop",
domain: "cryptpad.test.coop",
status: "deployed",
chaos: "true",
chaosVersion: "cb2a47fb",
version: "0.4.0+version-2024.3.0",
upgrade: "latest"
},
{
server: "test.coop",
recipe: "mobilizon",
appName: "events.test.coop",
domain: "events.test.coop",
status: "deployed",
chaos: "true",
chaosVersion: "f8f874a5",
version: "0.2.1+5.1.2",
upgrade: "latest"
}
],
appCount: 2,
versionCount: 2,
unversionedCount: 0,
latestCount: 2,
upgradeCount: 0
}
};
export const mockServers: AbraServer[] = [
{
name: 'prod-server-1',
host: '192.168.1.10',
user: 'root',
connected: true,
name: 'mydomain.com',
host: 'mydomain.com',
},
{
name: 'prod-server-2',
host: '192.168.1.11',
user: 'root',
connected: true,
},
{
name: 'staging-server',
host: '192.168.1.20',
user: 'root',
connected: false,
name: 'test.coop',
host: 'test.coop',
},
];
@ -64,9 +93,9 @@ export const mockServers: AbraServer[] = [
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const mockApiService = {
async getApps(): Promise<AbraApp[]> {
await delay(300);
return mockApps;
async getAppsGrouped(): Promise<ServerAppsResponse> {
await delay(500);
return mockAppsData;
},
async getServers(): Promise<AbraServer[]> {

View File

@ -19,18 +19,37 @@ export interface ApiError {
status: number;
}
// // Abra CLI related types
// export interface AbraApp {
// id: string;
// name: string;
// domain: string;
// status: 'running' | 'stopped' | 'error';
// recipe: string;
// }
export interface AbraApp {
server: string;
recipe: string;
appName: string;
domain: string;
status: string;
chaos: string;
chaosVersion: string;
version: string;
upgrade: string;
}
// export interface AbraServer {
// name: string;
// host: string;
// user: string;
// connected: boolean;
// }
export interface AbraServer {
name: string;
host: string;
}
export interface ServerAppsResponse {
[serverName: string]: {
apps: AbraApp[];
appCount: number;
versionCount: number;
unversionedCount: number;
latestCount: number;
upgradeCount: number;
};
}
export interface AppWithServer extends AbraApp {
serverStats: {
appCount: number;
upgradeCount: number;
};
}