Le problème
sh0 déploie des stacks complexes. Un seul déploiement WordPress crée trois services : le conteneur WordPress, MySQL et phpMyAdmin. Un déploiement Laravel ? Même histoire -- Laravel, MySQL, phpMyAdmin. Chaque service possède jusqu'à quatre URL d'accès : interne (conteneur à conteneur), locale (mappage de port hôte), externe (lorsqu'activée) et domaine (l'URL publique).
La page /domains d'origine n'affichait que les entrées de la table domains en base de données -- les enregistrements de domaines publics avec le statut DNS et SSL. Utile, mais cela passait à côté de la vue d'ensemble. Un utilisateur déployant Redis ne pouvait voir redis:6379 ou localhost:54878 nulle part sur cette page. Il devait naviguer dans chaque application individuellement pour trouver les URL des services.
Le retour du CEO était direct : "toutes ces URL devraient être sur une seule page."
La décision architecturale
Nous avions trois options :
- Frontend N+1 -- Récupérer toutes les applications, puis
GET /api/v1/apps/:id/servicespour chacune. Simple mais lent (Docker inspect par conteneur par application).
- Nouvel endpoint global --
GET /api/v1/services/urlsqui agrège toutes les URL de services en un seul appel. Une seule requête HTTP, mais le backend effectue tous les Docker inspect.
- Hybride -- Utiliser l'existant
GET /api/v1/services(qui renvoie les enregistrements basiques de la BD) puis récupérer les URL en temps réel par lots.
Nous avons opté pour l'option 2. Le raisonnement : c'est un outil auto-hébergé qui tourne sur la même machine que Docker. Les appels via socket Unix au démon Docker sont rapides. L'expérience utilisateur d'un seul indicateur de chargement qui se résout en moins d'une seconde surpasse une cascade de requêtes avec un chargement progressif.
Le backend : extraire, étendre, exposer
Le handler existant list_services pour GET /api/v1/apps/:id/services contenait déjà 180 lignes de logique de construction d'URL : Docker inspect pour les mappages de ports en temps réel, construction d'URL internes, formatage d'URL locales (avec détection de préfixe HTTP), recherche de domaines dans la table domains, et construction d'URL de connexion pour les services de base de données.
Plutôt que de dupliquer tout cela, nous avons extrait le code dans un helper partagé :
rustasync fn build_service_infos(
state: &AppState,
app: &App,
services: &[AppService],
domains: &[Domain],
env_map: &HashMap<String, String>,
) -> Result<Vec<ServiceInfoResponse>> {
// Docker inspect, construction d'URL, correspondance de domaines...
}Le handler par application appelle désormais ce helper. Le nouveau handler global itère sur toutes les applications avec leurs services, appelle le même helper pour chacune et enveloppe chaque résultat avec app_id et app_name :
rust#[derive(Serialize)]
pub struct GlobalServiceInfoResponse {
pub app_id: String,
pub app_name: String,
#[serde(flatten)]
pub service: ServiceInfoResponse,
}Le #[serde(flatten)] est la clé -- cela signifie que la sortie JSON a tous les champs au premier niveau, sans imbrication. Le type frontend s'étend naturellement :
typescriptexport interface GlobalAppServiceInfo extends AppServiceInfo {
app_id: string;
app_name: string;
}Le frontend : une seule table pour les gouverner toutes
La page /domains réécrite est un tableau unique avec six colonnes : App, Service, Interne, Local, Domaine, Statut. L'ordre compte -- App en premier car "à quelle application cela appartient-il ?" est toujours la première question. Statut en dernier car c'est un contexte secondaire.
Chaque cellule d'URL dispose du copier-coller et (le cas échéant) d'une icône de lien externe. Le filtre de recherche fonctionne sur les noms de services, les noms d'applications, les images et tous les types d'URL. Un menu déroulant de statut filtre les services en cours d'exécution par rapport aux arrêtés.
Le résultat : depuis une seule page, vous pouvez voir que votre déploiement WordPress a wordpress:8000 en interne, localhost:61637 en local, et my-wordpress.sh0.app comme domaine public. Son service phpMyAdmin est à phpmyadmin:80 / http://localhost:65240 / my-wordpress-phpmyadmin.sh0.app. Et son MySQL est à mysql:3306 / localhost:62876 sans domaine public (comme prévu).
Le débat sur l'ordre des colonnes
La première version mettait Service en premier, puis App. Après avoir regardé le tableau rendu avec des données réelles, le CEO a demandé d'inverser. Quand vous parcourez une page avec plus de 15 lignes réparties sur 5 applications déployées, le nom de l'application est le point d'ancrage -- il regroupe visuellement les lignes même sans regroupement explicite. Le nom du service est le détail au sein de ce groupe.
C'est un schéma récurrent dans la conception de tableaux de bord : la colonne qui vous aide à trouver ce que vous cherchez doit venir en premier, pas la colonne qui contient le plus de détails.
Ce que nous avons aussi fait dans cette session
Cette session était dense. Au-delà de la page de domaines :
- Correction d'un nom de template incorrect --
codeigniter4.yamln'était pas trouvé car le frontend cherchaitcodeigniter. Une session précédente avait renommé le fichier pour correspondre au champnameinterne mais avait cassé la chaîne de recherche. Un renommage + un changement de champ ont réglé le problème.
- Déplacement des clés API dans leur propre onglet de paramètres -- Elles étaient enfouies dans la section MCP Server. Maintenant, elles ont une entrée dédiée dans la barre latérale avec leur propre icône, ce qui les rend découvrables pour les utilisateurs qui veulent un accès API sans connaître MCP.
- Ajout des annotations OpenAPI manquantes -- Trois endpoints (
GET /domains,GET /services,GET /services/urls) n'avaient pas d'annotations utoipa, donc ils étaient invisibles dans la documentation de l'API. Annotations ajoutées + enregistrement effectué.
- Mise à jour de la documentation API du site marketing -- Le tableau "Autres endpoints" manquait 7 catégories (Services, Backups, Certificats, Projets, Redirections, Environnements de prévisualisation, Paramètres). Toutes ajoutées avec le nombre d'endpoints et les descriptions.
Le cycle construire-auditer-livrer
Tout cela a été fait en une seule session : planifier, implémenter, vérifier les types (svelte-check --threshold error renvoie 0 erreur), vérifier la compilation Rust (cargo check passe), commit, push. La checklist de test comporte 19 éléments de vérification répartis sur 6 catégories pour que le CEO les parcoure.
La méthodologie tient : construire de manière incrémentale, vérifier à chaque étape, commiter de manière atomique. Pas de PR de 500 lignes qui prennent une journée à revoir -- juste des commits ciblés qui font chacun une chose bien.