first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"web": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "600kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kB",
|
||||
"maximumError": "16kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
},
|
||||
"fonts": true
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "web:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "web:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+5962
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@12.0.2",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.2.0",
|
||||
"@angular/compiler": "^22.2.0",
|
||||
"@angular/core": "^22.2.0",
|
||||
"@angular/forms": "^22.2.0",
|
||||
"@angular/platform-browser": "^22.2.0",
|
||||
"@angular/router": "^22.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.2.2",
|
||||
"@angular/cli": "^22.2.2",
|
||||
"@angular/compiler-cli": "^22.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3333",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#2a78d6"/><g transform="translate(4 4)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,17 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { unauthorizedInterceptor } from './core/auth';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
/**
|
||||
* HttpClient sends the XSRF-TOKEN cookie back in the X-XSRF-TOKEN
|
||||
* header (CSRF protection of the API).
|
||||
*/
|
||||
provideHttpClient(withInterceptors([unauthorizedInterceptor])),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard } from './core/auth';
|
||||
import { Shell } from './layout/shell';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Connexion · Big Brother',
|
||||
loadComponent: () => import('./pages/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: Shell,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
title: 'Vue d’ensemble · Big Brother',
|
||||
loadComponent: () => import('./pages/overview/overview').then((m) => m.OverviewPage),
|
||||
},
|
||||
{
|
||||
path: 'containers',
|
||||
title: 'Conteneurs · Big Brother',
|
||||
loadComponent: () => import('./pages/containers/containers').then((m) => m.ContainersPage),
|
||||
},
|
||||
{
|
||||
path: 'containers/:id',
|
||||
title: 'Conteneur · Big Brother',
|
||||
loadComponent: () =>
|
||||
import('./pages/containers/container-detail').then((m) => m.ContainerDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
title: 'Sites · Big Brother',
|
||||
loadComponent: () => import('./pages/sites/sites').then((m) => m.SitesPage),
|
||||
},
|
||||
{
|
||||
path: 'sites/:id',
|
||||
title: 'Site · Big Brother',
|
||||
loadComponent: () => import('./pages/sites/site-detail').then((m) => m.SiteDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
title: 'Projets · Big Brother',
|
||||
loadComponent: () => import('./pages/projects/projects').then((m) => m.ProjectsPage),
|
||||
},
|
||||
{
|
||||
path: 'projects/new',
|
||||
title: 'Nouveau projet · Big Brother',
|
||||
loadComponent: () => import('./pages/projects/project-new').then((m) => m.ProjectNewPage),
|
||||
},
|
||||
{
|
||||
path: 'projects/:category/:name',
|
||||
title: 'Projet · Big Brother',
|
||||
loadComponent: () =>
|
||||
import('./pages/projects/project-detail').then((m) => m.ProjectDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'caddy',
|
||||
title: 'Caddy · Big Brother',
|
||||
loadComponent: () => import('./pages/caddy/caddy').then((m) => m.CaddyPage),
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
title: 'Sauvegardes · Big Brother',
|
||||
loadComponent: () => import('./pages/backups/backups').then((m) => m.BackupsPage),
|
||||
},
|
||||
{
|
||||
path: 'journal',
|
||||
title: 'Journal · Big Brother',
|
||||
loadComponent: () => import('./pages/journal/journal').then((m) => m.JournalPage),
|
||||
},
|
||||
{
|
||||
path: 'jobs/:id',
|
||||
title: 'Action · Big Brother',
|
||||
loadComponent: () => import('./pages/journal/job').then((m) => m.JobPage),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom, type Observable } from 'rxjs';
|
||||
import type {
|
||||
AuditEntry,
|
||||
AuditStatus,
|
||||
BackupFile,
|
||||
BackupSchedule,
|
||||
CaddyInfo,
|
||||
CaddyVersion,
|
||||
ContainerAction,
|
||||
ContainerDetails,
|
||||
ContainerSummary,
|
||||
CreateProjectInput,
|
||||
GitInfo,
|
||||
HistoryPoint,
|
||||
HistoryRange,
|
||||
Overview,
|
||||
ProjectAction,
|
||||
ProjectDetails,
|
||||
Project,
|
||||
ProjectOptions,
|
||||
SiteDetails,
|
||||
SiteRange,
|
||||
SiteSummary,
|
||||
} from './models';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
/**
|
||||
* Typed access to the API. Every call returns a promise, the pages keep
|
||||
* the results in signals.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Api {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
private get<T>(path: string, params?: Record<string, string | number>) {
|
||||
return firstValueFrom(
|
||||
this.http.get<T>(`${BASE}${path}`, {
|
||||
params: params ? new HttpParams({ fromObject: params }) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private send<T>(method: 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown) {
|
||||
const request: Observable<T> = this.http.request<T>(method, `${BASE}${path}`, {
|
||||
body: body ?? {},
|
||||
});
|
||||
return firstValueFrom(request);
|
||||
}
|
||||
|
||||
// Auth
|
||||
me() {
|
||||
return this.get<{ username: string }>('/auth/me');
|
||||
}
|
||||
login(username: string, password: string, code: string) {
|
||||
return this.send<{ username: string }>('POST', '/auth/login', { username, password, code });
|
||||
}
|
||||
logout() {
|
||||
return this.send<{ ok: true }>('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
// Overview & metrics
|
||||
overview() {
|
||||
return this.get<Overview>('/overview');
|
||||
}
|
||||
history(range: HistoryRange) {
|
||||
return this.get<{ range: HistoryRange; bucket: number; points: HistoryPoint[] }>(
|
||||
'/metrics/history',
|
||||
{ range },
|
||||
);
|
||||
}
|
||||
|
||||
// Containers
|
||||
containers() {
|
||||
return this.get<ContainerSummary[]>('/containers');
|
||||
}
|
||||
container(id: string) {
|
||||
return this.get<ContainerDetails>(`/containers/${encodeURIComponent(id)}`);
|
||||
}
|
||||
containerAction(id: string, action: ContainerAction) {
|
||||
return this.send<ContainerDetails>('POST', `/containers/${encodeURIComponent(id)}/${action}`);
|
||||
}
|
||||
|
||||
// Sites
|
||||
sites() {
|
||||
return this.get<SiteSummary[]>('/sites');
|
||||
}
|
||||
site(id: number, range: SiteRange) {
|
||||
return this.get<SiteDetails>(`/sites/${id}`, { range });
|
||||
}
|
||||
checkSites() {
|
||||
return this.send<SiteSummary[]>('POST', '/sites/check');
|
||||
}
|
||||
|
||||
// Projects
|
||||
projects() {
|
||||
return this.get<Project[]>('/projects');
|
||||
}
|
||||
projectOptions() {
|
||||
return this.get<ProjectOptions>('/projects/options');
|
||||
}
|
||||
project(category: string, name: string) {
|
||||
return this.get<ProjectDetails>(`/projects/${enc(category)}/${enc(name)}`);
|
||||
}
|
||||
fetchProject(category: string, name: string) {
|
||||
return this.send<GitInfo>('POST', `/projects/${enc(category)}/${enc(name)}/fetch`);
|
||||
}
|
||||
projectAction(category: string, name: string, action: ProjectAction, commit?: string) {
|
||||
return this.send<{ jobId: number }>(
|
||||
'POST',
|
||||
`/projects/${enc(category)}/${enc(name)}/actions/${action}`,
|
||||
commit ? { commit } : {},
|
||||
);
|
||||
}
|
||||
createProject(input: CreateProjectInput) {
|
||||
return this.send<{ jobId: number }>('POST', '/projects', input);
|
||||
}
|
||||
|
||||
// Caddy
|
||||
caddy() {
|
||||
return this.get<CaddyInfo>('/caddy');
|
||||
}
|
||||
saveCaddy(content: string, reason: string) {
|
||||
return this.send<{ ok: true; output: string }>('PUT', '/caddy', { content, reason });
|
||||
}
|
||||
reloadCaddy() {
|
||||
return this.send<{ ok: true; output: string }>('POST', '/caddy/reload');
|
||||
}
|
||||
caddyVersions() {
|
||||
return this.get<CaddyVersion[]>('/caddy/versions');
|
||||
}
|
||||
caddyVersion(id: number) {
|
||||
return this.get<CaddyVersion>(`/caddy/versions/${id}`);
|
||||
}
|
||||
restoreCaddy(id: number) {
|
||||
return this.send<{ ok: true; output: string }>('POST', `/caddy/versions/${id}/restore`);
|
||||
}
|
||||
|
||||
// Backups
|
||||
backups() {
|
||||
return this.get<{ dir: string; schedule: BackupSchedule; files: BackupFile[] }>('/backups');
|
||||
}
|
||||
runBackup() {
|
||||
return this.send<{ jobId: number }>('POST', '/backups');
|
||||
}
|
||||
deleteBackup(name: string) {
|
||||
return this.send<{ ok: true }>('DELETE', `/backups/${enc(name)}`);
|
||||
}
|
||||
backupUrl(name: string) {
|
||||
return `${BASE}/backups/${enc(name)}`;
|
||||
}
|
||||
|
||||
// Jobs & action log
|
||||
jobStreamUrl(id: number) {
|
||||
return `${BASE}/jobs/${id}/stream`;
|
||||
}
|
||||
audit(page: number, status?: AuditStatus) {
|
||||
return this.get<{
|
||||
meta: { total: number; page: number; lastPage: number };
|
||||
data: AuditEntry[];
|
||||
}>('/audit', status ? { page, status } : { page });
|
||||
}
|
||||
auditEntry(id: number) {
|
||||
return this.get<{
|
||||
id: number;
|
||||
action: string;
|
||||
target: string | null;
|
||||
status: AuditStatus;
|
||||
output: string | null;
|
||||
}>(`/audit/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function enc(value: string) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { Api } from './api';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Auth {
|
||||
private api = inject(Api);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly user = signal<string | null>(null);
|
||||
|
||||
/**
|
||||
* True when a session exists (asks the API once, then trusts the signal).
|
||||
*/
|
||||
async ensure() {
|
||||
if (this.user()) return true;
|
||||
try {
|
||||
const me = await this.api.me();
|
||||
this.user.set(me.username);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string, code: string) {
|
||||
const me = await this.api.login(username, password, code);
|
||||
this.user.set(me.username);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this.api.logout();
|
||||
} finally {
|
||||
this.expired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the API answers 401: back to the login page.
|
||||
*/
|
||||
expired() {
|
||||
this.user.set(null);
|
||||
const current = this.router.url;
|
||||
if (!current.startsWith('/login')) {
|
||||
this.router.navigate(['/login'], { queryParams: current !== '/' ? { next: current } : {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authGuard: CanActivateFn = async (_route, state) => {
|
||||
const auth = inject(Auth);
|
||||
const router = inject(Router);
|
||||
if (await auth.ensure()) return true;
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: state.url !== '/' ? { next: state.url } : {},
|
||||
});
|
||||
};
|
||||
|
||||
export const guestGuard: CanActivateFn = async () => {
|
||||
const auth = inject(Auth);
|
||||
const router = inject(Router);
|
||||
return (await auth.ensure()) ? router.createUrlTree(['/']) : true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends back to the login page when the session has expired.
|
||||
*/
|
||||
export const unauthorizedInterceptor: HttpInterceptorFn = (request, next) => {
|
||||
const auth = inject(Auth);
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const isAuthCall = request.url.endsWith('/auth/me') || request.url.endsWith('/auth/login');
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && !isAuthCall) {
|
||||
auth.expired();
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface ConfirmRequest {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog and toasts, rendered once by the shell.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Feedback {
|
||||
readonly confirmation = signal<(ConfirmRequest & { resolve: (ok: boolean) => void }) | null>(
|
||||
null,
|
||||
);
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
private nextId = 1;
|
||||
|
||||
confirm(request: ConfirmRequest): Promise<boolean> {
|
||||
return new Promise((resolve) => this.confirmation.set({ ...request, resolve }));
|
||||
}
|
||||
|
||||
answer(ok: boolean) {
|
||||
const current = this.confirmation();
|
||||
this.confirmation.set(null);
|
||||
current?.resolve(ok);
|
||||
}
|
||||
|
||||
success(text: string) {
|
||||
this.push('success', text);
|
||||
}
|
||||
|
||||
error(text: string) {
|
||||
this.push('error', text, 8000);
|
||||
}
|
||||
|
||||
info(text: string) {
|
||||
this.push('info', text);
|
||||
}
|
||||
|
||||
dismiss(id: number) {
|
||||
this.toasts.update((toasts) => toasts.filter((toast) => toast.id !== id));
|
||||
}
|
||||
|
||||
private push(kind: Toast['kind'], text: string, duration = 4000) {
|
||||
const id = this.nextId++;
|
||||
this.toasts.update((toasts) => [...toasts, { id, kind, text }]);
|
||||
setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
export function formatNumber(value: number | null | undefined, decimals = 1) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '–';
|
||||
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: decimals }).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1536 -> "1,5 Ko"
|
||||
*/
|
||||
export function formatBytes(bytes: number | null | undefined) {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '–';
|
||||
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
||||
let value = Math.abs(bytes);
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
const decimals = value >= 100 || unit === 0 ? 0 : 1;
|
||||
return `${new Intl.NumberFormat('fr-FR', { maximumFractionDigits: decimals }).format(value)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatRate(bytesPerSecond: number | null | undefined) {
|
||||
if (bytesPerSecond === null || bytesPerSecond === undefined) return '–';
|
||||
return `${formatBytes(bytesPerSecond)}/s`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined, decimals = 0) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '–';
|
||||
return `${new Intl.NumberFormat('fr-FR', { maximumFractionDigits: decimals }).format(value)} %`;
|
||||
}
|
||||
|
||||
export function percent(part: number, total: number) {
|
||||
return total > 0 ? (part / total) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 93784 -> "1 j 2 h"
|
||||
*/
|
||||
export function formatDuration(seconds: number | null | undefined) {
|
||||
if (seconds === null || seconds === undefined) return '–';
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
if (days > 0) return `${days} j ${hours} h`;
|
||||
if (hours > 0) return `${hours} h ${minutes} min`;
|
||||
if (minutes > 0) return `${minutes} min`;
|
||||
return `${s} s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts milliseconds, seconds (unix) or an ISO string.
|
||||
*/
|
||||
export function toDate(value: number | string | null | undefined): Date | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'string') return new Date(value);
|
||||
return new Date(value < 1e12 ? value * 1000 : value);
|
||||
}
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const timeFormat = new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const timeSecondsFormat = new Intl.DateTimeFormat('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
const dayFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit' });
|
||||
|
||||
export function formatDateTime(value: number | string | null | undefined) {
|
||||
const date = toDate(value);
|
||||
return date ? dateTimeFormat.format(date) : '–';
|
||||
}
|
||||
|
||||
export function formatTime(value: number | string | null | undefined, seconds = false) {
|
||||
const date = toDate(value);
|
||||
return date ? (seconds ? timeSecondsFormat : timeFormat).format(date) : '–';
|
||||
}
|
||||
|
||||
/**
|
||||
* Axis label: hour for short ranges, day + hour for longer ones.
|
||||
*/
|
||||
export function formatAxisTime(ms: number, spanMs: number) {
|
||||
const date = new Date(ms);
|
||||
if (spanMs <= 30 * 60_000) return timeSecondsFormat.format(date);
|
||||
if (spanMs <= 2 * 86400_000) return timeFormat.format(date);
|
||||
return `${dayFormat.format(date)} ${timeFormat.format(date)}`;
|
||||
}
|
||||
|
||||
const relative = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' });
|
||||
|
||||
/**
|
||||
* "il y a 5 minutes"
|
||||
*/
|
||||
export function formatRelative(value: number | string | null | undefined) {
|
||||
const date = toDate(value);
|
||||
if (!date) return '–';
|
||||
const diff = (date.getTime() - Date.now()) / 1000;
|
||||
const abs = Math.abs(diff);
|
||||
if (abs < 45) return diff < 0 ? 'à l’instant' : 'dans un instant';
|
||||
if (abs < 3600) return relative.format(Math.round(diff / 60), 'minute');
|
||||
if (abs < 86400) return relative.format(Math.round(diff / 3600), 'hour');
|
||||
return relative.format(Math.round(diff / 86400), 'day');
|
||||
}
|
||||
|
||||
/**
|
||||
* Message to show for a failed API call.
|
||||
*/
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const body = error.error;
|
||||
if (body && typeof body === 'object') {
|
||||
if (typeof body.message === 'string') return body.message;
|
||||
if (Array.isArray(body.errors) && body.errors[0]?.message) return body.errors[0].message;
|
||||
}
|
||||
if (error.status === 0) return 'Le serveur ne répond pas.';
|
||||
if (error.status === 401) return 'Session expirée, reconnecte-toi.';
|
||||
return `Erreur ${error.status}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Shapes returned by the API (see api/app/controllers).
|
||||
*/
|
||||
|
||||
export interface HostSample {
|
||||
ts: number;
|
||||
cpu: number;
|
||||
load1: number | null;
|
||||
memUsed: number;
|
||||
memTotal: number;
|
||||
diskUsed: number;
|
||||
diskTotal: number;
|
||||
netRx: number | null;
|
||||
netTx: number | null;
|
||||
}
|
||||
|
||||
export type HistoryRange = '1h' | '24h' | '7d' | '30d';
|
||||
|
||||
export interface HistoryPoint {
|
||||
t: number;
|
||||
cpu: number;
|
||||
load1: number | null;
|
||||
memUsed: number;
|
||||
memTotal: number;
|
||||
diskUsed: number;
|
||||
diskTotal: number;
|
||||
netRx: number | null;
|
||||
netTx: number | null;
|
||||
}
|
||||
|
||||
export type AuditStatus = 'running' | 'success' | 'error';
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
username: string | null;
|
||||
ip?: string | null;
|
||||
action: string;
|
||||
target: string | null;
|
||||
status: AuditStatus;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
hasOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
name: string;
|
||||
type: 'db' | 'srv';
|
||||
size: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface BackupSchedule {
|
||||
dailyAt: string | null;
|
||||
keep: number;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
host: { latest: HostSample | null; uptime: number; cpus: number };
|
||||
docker:
|
||||
| { ok: true; total: number; running: number; unhealthy: string[]; stopped: string[] }
|
||||
| { ok: false; error: string };
|
||||
sites: {
|
||||
total: number;
|
||||
up: number;
|
||||
down: { id: number; domain: string; error: string | null }[];
|
||||
certSoon: { id: number; domain: string; days: number }[];
|
||||
};
|
||||
backups: { last: BackupFile | null; count: number; schedule: BackupSchedule };
|
||||
recent: AuditEntry[];
|
||||
}
|
||||
|
||||
export interface ContainerStats {
|
||||
cpu: number;
|
||||
memUsed: number;
|
||||
memLimit: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface ContainerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
health: string | null;
|
||||
createdAt: number;
|
||||
composeProject: string | null;
|
||||
composeWorkingDir: string | null;
|
||||
composeService: string | null;
|
||||
networks: string[];
|
||||
publishedPorts: string[];
|
||||
stats: ContainerStats | null;
|
||||
self: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
health: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
exitCode: number;
|
||||
restartCount: number;
|
||||
restartPolicy: string | null;
|
||||
createdAt: string;
|
||||
command: string;
|
||||
workingDir: string;
|
||||
composeProject: string | null;
|
||||
composeWorkingDir: string | null;
|
||||
composeService: string | null;
|
||||
networks: string[];
|
||||
mounts: { type: string; source: string; destination: string; readOnly: boolean }[];
|
||||
publishedPorts: string[];
|
||||
stats: ContainerStats | null;
|
||||
self: boolean;
|
||||
}
|
||||
|
||||
export type ContainerAction = 'start' | 'stop' | 'restart';
|
||||
|
||||
export interface LogLine {
|
||||
ts: string | null;
|
||||
stream: 'out' | 'err';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: number;
|
||||
domain: string;
|
||||
active: boolean;
|
||||
kind: 'proxy' | 'static' | 'other' | null;
|
||||
upstream: string | null;
|
||||
lastOk: boolean | null;
|
||||
lastStatus: number | null;
|
||||
lastResponseMs: number | null;
|
||||
lastError: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
certExpiresAt: number | null;
|
||||
}
|
||||
|
||||
export interface SiteSummary extends Site {
|
||||
uptime24h: number | null;
|
||||
uptime7d: number | null;
|
||||
avgResponseMs24h: number | null;
|
||||
}
|
||||
|
||||
export type SiteRange = '24h' | '7d' | '30d';
|
||||
|
||||
export interface SiteDetails {
|
||||
site: Site;
|
||||
range: SiteRange;
|
||||
bucket: number;
|
||||
uptime: number | null;
|
||||
avgResponseMs: number | null;
|
||||
points: { t: number; responseMs: number | null; ok: boolean; checks: number }[];
|
||||
incidents: { start: number; end: number | null; error: string | null; checks: number }[];
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface GitInfo {
|
||||
repoPath: string;
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
dirty: number;
|
||||
commit: GitCommit | null;
|
||||
}
|
||||
|
||||
export type ProjectKind = 'compose' | 'static' | 'infra';
|
||||
|
||||
export interface Project {
|
||||
category: string;
|
||||
name: string;
|
||||
kind: ProjectKind;
|
||||
path: string;
|
||||
composeFile: string | null;
|
||||
git: GitInfo | null;
|
||||
containers: { id: string; name: string; state: string; status: string; health: string | null }[];
|
||||
self: boolean;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectDetails extends Project {
|
||||
compose: string | null;
|
||||
remote: string | null;
|
||||
defaultBranch: string | null;
|
||||
commits: GitCommit[];
|
||||
}
|
||||
|
||||
export type ProjectAction =
|
||||
'deploy' | 'restart' | 'stop' | 'start' | 'recreate' | 'pull' | 'checkout' | 'checkout-branch';
|
||||
|
||||
export interface ProjectOptions {
|
||||
baseDomain: string;
|
||||
categories: string[];
|
||||
srvRoot: string;
|
||||
defaultNodeCommand: string;
|
||||
}
|
||||
|
||||
export type ProjectType = 'node' | 'adonis' | 'wordpress' | 'static';
|
||||
|
||||
export interface CreateProjectInput {
|
||||
type: ProjectType;
|
||||
name: string;
|
||||
category: string;
|
||||
gitUrl?: string;
|
||||
port?: number;
|
||||
command?: string;
|
||||
env?: string;
|
||||
createDatabase: boolean;
|
||||
domain: string;
|
||||
addCaddy: boolean;
|
||||
spa: boolean;
|
||||
}
|
||||
|
||||
export interface CaddySite {
|
||||
addresses: string[];
|
||||
domains: string[];
|
||||
kind: 'proxy' | 'static' | 'other';
|
||||
upstream: string | null;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface CaddyInfo {
|
||||
path: string;
|
||||
container: string;
|
||||
content: string;
|
||||
sites: CaddySite[];
|
||||
}
|
||||
|
||||
export interface CaddyVersion {
|
||||
id: number;
|
||||
username: string | null;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface JobLine {
|
||||
ts: number;
|
||||
stream: 'out' | 'err' | 'info';
|
||||
text: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type SseHandlers = Record<string, (data: any) => void>;
|
||||
|
||||
/**
|
||||
* Opens a Server-Sent Events stream. The browser reconnects by itself when
|
||||
* the connection drops; call the returned function to close it for good.
|
||||
*/
|
||||
export function openStream(
|
||||
url: string,
|
||||
handlers: SseHandlers,
|
||||
callbacks: { onOpen?: () => void; onError?: () => void } = {},
|
||||
): () => void {
|
||||
const source = new EventSource(url, { withCredentials: true });
|
||||
|
||||
for (const [event, handler] of Object.entries(handlers)) {
|
||||
source.addEventListener(event, (message) => {
|
||||
handler(JSON.parse((message as MessageEvent<string>).data));
|
||||
});
|
||||
}
|
||||
|
||||
source.onopen = () => callbacks.onOpen?.();
|
||||
source.onerror = () => callbacks.onError?.();
|
||||
return () => source.close();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export type ThemeChoice = 'system' | 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'bb-theme';
|
||||
|
||||
/**
|
||||
* Light / dark theme. "system" follows the OS; a choice is remembered in
|
||||
* the browser and applied with data-theme on <html>.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Theme {
|
||||
private media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
private systemDark = signal(this.media.matches);
|
||||
|
||||
readonly choice = signal<ThemeChoice>(this.load());
|
||||
readonly resolved = computed<'light' | 'dark'>(() => {
|
||||
const choice = this.choice();
|
||||
if (choice === 'system') return this.systemDark() ? 'dark' : 'light';
|
||||
return choice;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.media.addEventListener('change', (event) => this.systemDark.set(event.matches));
|
||||
this.apply(this.choice());
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const next: ThemeChoice = this.resolved() === 'dark' ? 'light' : 'dark';
|
||||
this.choice.set(next);
|
||||
this.apply(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Storage unavailable (private mode): the choice only lasts for this page.
|
||||
}
|
||||
}
|
||||
|
||||
private apply(choice: ThemeChoice) {
|
||||
if (choice === 'system') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', choice);
|
||||
}
|
||||
|
||||
private load(): ThemeChoice {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'light' || saved === 'dark' ? saved : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<header class="topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost icon-only"
|
||||
(click)="menuOpen.set(!menuOpen())"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
aria-controls="sidebar"
|
||||
>
|
||||
<app-icon name="menu" />
|
||||
<span class="sr-only">Menu</span>
|
||||
</button>
|
||||
<span class="brand"><app-icon name="eye" /> Big Brother</span>
|
||||
</header>
|
||||
|
||||
<aside id="sidebar" class="sidebar" [class.open]="menuOpen()">
|
||||
<a routerLink="/" class="brand">
|
||||
<span class="logo"><app-icon name="eye" /></span>
|
||||
Big Brother
|
||||
</a>
|
||||
|
||||
<nav aria-label="Navigation principale">
|
||||
@for (item of nav; track item.path) {
|
||||
<a
|
||||
[routerLink]="item.path"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: !!item.exact }"
|
||||
ariaCurrentWhenActive="page"
|
||||
>
|
||||
<app-icon [name]="item.icon" />
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="user">
|
||||
<app-icon name="lock" />
|
||||
{{ auth.user() }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost icon-only small"
|
||||
(click)="theme.toggle()"
|
||||
[attr.aria-label]="
|
||||
theme.resolved() === 'dark' ? 'Passer en thème clair' : 'Passer en thème sombre'
|
||||
"
|
||||
>
|
||||
<app-icon [name]="theme.resolved() === 'dark' ? 'sun' : 'moon'" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost icon-only small"
|
||||
(click)="logout()"
|
||||
aria-label="Se déconnecter"
|
||||
>
|
||||
<app-icon name="logout" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@if (menuOpen()) {
|
||||
<div class="backdrop" (click)="menuOpen.set(false)"></div>
|
||||
}
|
||||
|
||||
<main class="content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<dialog
|
||||
#dialog
|
||||
class="confirm"
|
||||
(cancel)="feedback.answer(false)"
|
||||
(close)="feedback.confirmation() && feedback.answer(false)"
|
||||
>
|
||||
@if (feedback.confirmation(); as request) {
|
||||
<h2>{{ request.title }}</h2>
|
||||
<p>{{ request.message }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="btn" (click)="feedback.answer(false)">Annuler</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
[class.primary]="!request.danger"
|
||||
[class.danger]="request.danger"
|
||||
(click)="feedback.answer(true)"
|
||||
>
|
||||
{{ request.confirmLabel ?? 'Confirmer' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</dialog>
|
||||
|
||||
<div class="toasts" aria-live="polite">
|
||||
@for (toast of feedback.toasts(); track toast.id) {
|
||||
<div class="toast" [class]="toast.kind" role="status">
|
||||
<app-icon
|
||||
[name]="toast.kind === 'success' ? 'good' : toast.kind === 'error' ? 'critical' : 'warning'"
|
||||
/>
|
||||
<span>{{ toast.text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost icon-only small"
|
||||
(click)="feedback.dismiss(toast.id)"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<app-icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,236 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 232px;
|
||||
padding: 18px 12px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 18px;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
|
||||
app-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
app-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
|
||||
app-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
padding: 12px 4px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
app-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
margin-left: 232px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
|
||||
.brand {
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.open {
|
||||
transform: none;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
display: block;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm {
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
|
||||
&::backdrop {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-2);
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 8px 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
|
||||
> app-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
&.success > app-icon {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
&.error > app-icon {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
&.info > app-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import { Auth } from '../core/auth';
|
||||
import { Feedback } from '../core/feedback';
|
||||
import { Theme } from '../core/theme';
|
||||
import { Icon, type IconName } from '../shared/icon';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ path: '/', label: 'Vue d’ensemble', icon: 'activity', exact: true },
|
||||
{ path: '/containers', label: 'Conteneurs', icon: 'box' },
|
||||
{ path: '/sites', label: 'Sites', icon: 'globe' },
|
||||
{ path: '/projects', label: 'Projets', icon: 'folder' },
|
||||
{ path: '/caddy', label: 'Caddy', icon: 'route' },
|
||||
{ path: '/backups', label: 'Sauvegardes', icon: 'archive' },
|
||||
{ path: '/journal', label: 'Journal', icon: 'list' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, Icon],
|
||||
templateUrl: './shell.html',
|
||||
styleUrl: './shell.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Shell {
|
||||
protected auth = inject(Auth);
|
||||
protected theme = inject(Theme);
|
||||
protected feedback = inject(Feedback);
|
||||
protected readonly nav = NAV;
|
||||
protected readonly menuOpen = signal(false);
|
||||
|
||||
private dialog = viewChild<ElementRef<HTMLDialogElement>>('dialog');
|
||||
|
||||
constructor() {
|
||||
inject(Router)
|
||||
.events.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => this.menuOpen.set(false));
|
||||
|
||||
/**
|
||||
* Native <dialog> for the confirmations (focus trap and Escape for free).
|
||||
*/
|
||||
effect(() => {
|
||||
const dialog = this.dialog()?.nativeElement;
|
||||
if (!dialog) return;
|
||||
if (this.feedback.confirmation() && !dialog.open) dialog.showModal();
|
||||
if (!this.feedback.confirmation() && dialog.open) dialog.close();
|
||||
});
|
||||
}
|
||||
|
||||
protected logout() {
|
||||
this.auth.logout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatBytes, formatDateTime, formatRelative } from '../../core/format';
|
||||
import type { BackupFile, BackupSchedule } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
@Component({
|
||||
selector: 'app-backups',
|
||||
imports: [Icon, JobConsole],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Sauvegardes</h1>
|
||||
<p>
|
||||
Export de toutes les bases MariaDB et archive de <span class="mono">/srv</span> (sans
|
||||
node_modules).
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary" (click)="run()" [disabled]="starting()">
|
||||
<app-icon name="archive" /> Lancer une sauvegarde
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="alert warning">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
Les sauvegardes restent sur le VPS (<span class="mono">{{ dir() }}</span
|
||||
>). Télécharge-les régulièrement : si le serveur est perdu, elles le sont aussi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<app-job-console [jobId]="id" (finished)="load()" />
|
||||
}
|
||||
|
||||
<section class="grid-tiles">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Sauvegarde automatique</span>
|
||||
<span class="tile-value small">{{
|
||||
schedule()?.dailyAt ? 'Chaque jour à ' + schedule()!.dailyAt : 'Désactivée'
|
||||
}}</span>
|
||||
<span class="tile-sub">Variable BACKUP_DAILY_AT</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Conservation</span>
|
||||
<span class="tile-value small">{{ schedule()?.keep ?? '–' }} de chaque type</span>
|
||||
<span class="tile-sub">Les plus anciennes sont supprimées</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Dernière sauvegarde</span>
|
||||
<span class="tile-value small">{{
|
||||
files()[0] ? relative(files()[0].createdAt) : 'Aucune'
|
||||
}}</span>
|
||||
<span class="tile-sub">{{ files()[0] ? dateTime(files()[0].createdAt) : '' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fichier</th>
|
||||
<th>Contenu</th>
|
||||
<th class="num">Taille</th>
|
||||
<th>Date</th>
|
||||
<th class="actions"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (file of files(); track file.name) {
|
||||
<tr>
|
||||
<td class="mono">{{ file.name }}</td>
|
||||
<td>{{ file.type === 'db' ? 'Bases de données' : 'Dossier /srv' }}</td>
|
||||
<td class="num">{{ bytes(file.size) }}</td>
|
||||
<td class="nowrap">{{ dateTime(file.createdAt) }}</td>
|
||||
<td class="actions">
|
||||
<a
|
||||
class="btn small"
|
||||
[href]="api.backupUrl(file.name)"
|
||||
[attr.download]="file.name"
|
||||
>
|
||||
<app-icon name="download" /> Télécharger
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only danger"
|
||||
(click)="remove(file)"
|
||||
[attr.aria-label]="'Supprimer ' + file.name"
|
||||
>
|
||||
<app-icon name="trash" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty">Aucune sauvegarde pour l’instant.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.tile-value.small {
|
||||
font-size: 18px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BackupsPage {
|
||||
protected api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly files = signal<BackupFile[]>([]);
|
||||
protected readonly schedule = signal<BackupSchedule | null>(null);
|
||||
protected readonly dir = signal('');
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly starting = signal(false);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly bytes = formatBytes;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly relative = formatRelative;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const result = await this.api.backups();
|
||||
this.files.set(result.files);
|
||||
this.schedule.set(result.schedule);
|
||||
this.dir.set(result.dir);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async run() {
|
||||
this.starting.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.runBackup();
|
||||
this.jobId.set(jobId);
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.starting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async remove(file: BackupFile) {
|
||||
const ok = await this.feedback.confirm({
|
||||
title: 'Supprimer cette sauvegarde ?',
|
||||
message: `${file.name} sera supprimé du serveur. C’est définitif.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await this.api.deleteBackup(file.name);
|
||||
this.feedback.success('Sauvegarde supprimée.');
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Caddy</h1>
|
||||
<p>
|
||||
Reverse proxy et HTTPS.
|
||||
@if (info(); as i) {
|
||||
Fichier <span class="mono">{{ i.path }}</span
|
||||
>, conteneur <span class="mono">{{ i.container }}</span
|
||||
>.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="reload()" [disabled]="saving()">
|
||||
<app-icon name="refresh" /> Recharger Caddy
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="layout">
|
||||
<section class="card editor-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Caddyfile</h2>
|
||||
<p>{{ lineCount() }} lignes{{ dirty() ? ' · modifications non enregistrées' : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body stack">
|
||||
<textarea
|
||||
class="textarea editor"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
aria-label="Contenu du Caddyfile"
|
||||
[ngModel]="content()"
|
||||
(ngModelChange)="content.set($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
></textarea>
|
||||
|
||||
@if (output(); as out) {
|
||||
<div class="alert" [class.info]="out.ok" [class.critical]="!out.ok" role="status">
|
||||
<app-icon [name]="out.ok ? 'good' : 'critical'" />
|
||||
<div>
|
||||
<p>
|
||||
{{
|
||||
out.ok
|
||||
? 'Configuration appliquée.'
|
||||
: 'Caddy a refusé la configuration : le fichier précédent a été remis en place.'
|
||||
}}
|
||||
</p>
|
||||
@if (out.text && out.text !== 'OK') {
|
||||
<pre class="output">{{ out.text }}</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="Motif (facultatif), ex. : ajout de monprojet"
|
||||
[ngModel]="reason()"
|
||||
(ngModelChange)="reason.set($event)"
|
||||
aria-label="Motif de la modification"
|
||||
/>
|
||||
<button type="button" class="btn" (click)="reset()" [disabled]="!dirty() || saving()">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
(click)="save()"
|
||||
[disabled]="!dirty() || saving()"
|
||||
>
|
||||
@if (saving()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
} @else {
|
||||
<app-icon name="save" />
|
||||
}
|
||||
Enregistrer et recharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Sites déclarés</h2></div>
|
||||
<div class="card-body flush">
|
||||
<ul class="site-list">
|
||||
@for (site of info()?.sites ?? []; track site.line) {
|
||||
<li>
|
||||
<span class="mono">{{ site.addresses.join(', ') }}</span>
|
||||
<span class="muted">
|
||||
@if (site.kind === 'proxy') {
|
||||
→ {{ site.upstream }}
|
||||
} @else if (site.kind === 'static') {
|
||||
→ {{ site.upstream ?? 'fichiers' }}
|
||||
} @else {
|
||||
ligne {{ site.line }}
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">Aucun site</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Versions précédentes</h2>
|
||||
<p>Sauvegardées avant chaque modification</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="version-list">
|
||||
@for (version of versions(); track version.id) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>#{{ version.id }}</strong> · {{ dateTime(version.createdAt) }}
|
||||
<span class="muted"
|
||||
>{{ version.reason ?? ''
|
||||
}}{{ version.username ? ' · ' + version.username : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button type="button" class="btn small" (click)="view(version)">Voir</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="restore(version)"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">
|
||||
Pas encore de version (elles apparaissent après la première modification).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if (viewed(); as version) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Version #{{ version.id }} · {{ dateTime(version.createdAt) }}</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn small" (click)="restore(version)" [disabled]="saving()">
|
||||
Restaurer cette version
|
||||
</button>
|
||||
<button type="button" class="btn small ghost" (click)="viewed.set(null)">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ version.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 520px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
max-height: 240px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.site-list,
|
||||
.version-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-list li {
|
||||
gap: 8px;
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime } from '../../core/format';
|
||||
import type { CaddyInfo, CaddyVersion } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-caddy',
|
||||
imports: [FormsModule, Icon],
|
||||
templateUrl: './caddy.html',
|
||||
styleUrl: './caddy.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CaddyPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly info = signal<CaddyInfo | null>(null);
|
||||
protected readonly content = signal('');
|
||||
protected readonly reason = signal('');
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly output = signal<{ ok: boolean; text: string } | null>(null);
|
||||
protected readonly versions = signal<CaddyVersion[]>([]);
|
||||
protected readonly viewed = signal<CaddyVersion | null>(null);
|
||||
|
||||
protected readonly dirty = computed(
|
||||
() => this.info() !== null && this.content() !== this.info()!.content,
|
||||
);
|
||||
protected readonly lineCount = computed(() => this.content().split('\n').length);
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
protected async load() {
|
||||
try {
|
||||
const [info, versions] = await Promise.all([this.api.caddy(), this.api.caddyVersions()]);
|
||||
this.info.set(info);
|
||||
this.content.set(info.content);
|
||||
this.versions.set(versions);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async save() {
|
||||
if (!this.dirty()) return;
|
||||
const ok = await this.feedback.confirm({
|
||||
title: 'Enregistrer et recharger Caddy ?',
|
||||
message:
|
||||
'Si Caddy refuse la nouvelle configuration, l’ancien fichier est remis en place automatiquement.',
|
||||
confirmLabel: 'Enregistrer et recharger',
|
||||
});
|
||||
if (!ok) return;
|
||||
await this.run(
|
||||
() => this.api.saveCaddy(this.content(), this.reason().trim()),
|
||||
'Caddyfile enregistré et Caddy rechargé.',
|
||||
);
|
||||
}
|
||||
|
||||
protected async reload() {
|
||||
await this.run(() => this.api.reloadCaddy(), 'Caddy rechargé.');
|
||||
}
|
||||
|
||||
protected reset() {
|
||||
this.content.set(this.info()?.content ?? '');
|
||||
this.output.set(null);
|
||||
}
|
||||
|
||||
protected async view(version: CaddyVersion) {
|
||||
try {
|
||||
this.viewed.set(await this.api.caddyVersion(version.id));
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async restore(version: CaddyVersion) {
|
||||
const ok = await this.feedback.confirm({
|
||||
title: `Restaurer la version #${version.id} ?`,
|
||||
message: `Version enregistrée le ${formatDateTime(version.createdAt)}. Le fichier actuel est lui-même gardé dans l’historique.`,
|
||||
confirmLabel: 'Restaurer',
|
||||
});
|
||||
if (!ok) return;
|
||||
this.viewed.set(null);
|
||||
await this.run(() => this.api.restoreCaddy(version.id), `Version #${version.id} restaurée.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab inserts a tab character (Caddyfile indentation) instead of
|
||||
* leaving the field.
|
||||
*/
|
||||
protected onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const next = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
|
||||
this.content.set(next);
|
||||
textarea.value = next;
|
||||
textarea.selectionStart = textarea.selectionEnd = selectionStart + 1;
|
||||
}
|
||||
|
||||
private async run(action: () => Promise<{ output: string }>, success: string) {
|
||||
this.saving.set(true);
|
||||
this.output.set(null);
|
||||
try {
|
||||
const result = await action();
|
||||
this.output.set({ ok: true, text: result.output || 'OK' });
|
||||
this.feedback.success(success);
|
||||
this.reason.set('');
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
const body = error instanceof HttpErrorResponse ? error.error : null;
|
||||
this.output.set({ ok: false, text: body?.output || errorMessage(error) });
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Feedback } from '../../core/feedback';
|
||||
import type { ContainerAction } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Asks before stopping or restarting a container (starting needs no
|
||||
* confirmation).
|
||||
*/
|
||||
export function confirmContainerAction(feedback: Feedback, name: string, action: ContainerAction) {
|
||||
if (action === 'start') return Promise.resolve(true);
|
||||
|
||||
const critical = ['caddy', 'mariadb'].includes(name);
|
||||
const consequence =
|
||||
name === 'caddy'
|
||||
? '\nTous les sites seront injoignables tant que Caddy ne tourne pas.'
|
||||
: name === 'mariadb'
|
||||
? '\nToutes les applications qui utilisent la base de données seront en panne.'
|
||||
: '';
|
||||
|
||||
return feedback.confirm({
|
||||
title: action === 'stop' ? `Arrêter ${name} ?` : `Redémarrer ${name} ?`,
|
||||
message:
|
||||
action === 'stop'
|
||||
? `Le conteneur restera arrêté jusqu’à ce que tu le relances.${consequence}`
|
||||
: `Le service sera coupé quelques secondes.${consequence}`,
|
||||
confirmLabel: action === 'stop' ? 'Arrêter' : 'Redémarrer',
|
||||
danger: action === 'stop' || critical,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/containers" class="back-link"><app-icon name="back" /> Conteneurs</a>
|
||||
<h1>{{ container()?.name ?? id() }}</h1>
|
||||
@if (container(); as c) {
|
||||
<p class="mono">{{ c.image }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (container(); as c) {
|
||||
<div class="toolbar">
|
||||
@if (c.state === 'running') {
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
@if (!c.self) {
|
||||
<button type="button" class="btn danger" (click)="act('stop')" [disabled]="busy()">
|
||||
<app-icon name="stop" /> Arrêter
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button type="button" class="btn primary" (click)="act('start')" [disabled]="busy()">
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (container(); as c) {
|
||||
<section class="grid-tiles">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">État</span>
|
||||
@if (status(); as s) {
|
||||
<app-status [level]="s.level" [label]="s.label" />
|
||||
}
|
||||
<span class="tile-sub">
|
||||
@if (c.state === 'running') {
|
||||
Depuis le {{ format.dateTime(c.startedAt) }}
|
||||
} @else {
|
||||
Arrêté le {{ format.dateTime(c.finishedAt) }} (code {{ c.exitCode }})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Processeur</span>
|
||||
<span class="tile-value">{{ c.stats ? format.percent(c.stats.cpu) : '–' }}</span>
|
||||
<span class="tile-sub">du serveur entier</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Mémoire</span>
|
||||
<span class="tile-value">{{ c.stats ? format.bytes(c.stats.memUsed) : '–' }}</span>
|
||||
<span class="tile-sub">limite {{ c.stats ? format.bytes(c.stats.memLimit) : '–' }}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Redémarrages</span>
|
||||
<span class="tile-value">{{ c.restartCount }}</span>
|
||||
<span class="tile-sub">politique : {{ c.restartPolicy || 'aucune' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card logs-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Logs</h2>
|
||||
<p>
|
||||
@switch (streamState()) {
|
||||
@case ('open') {
|
||||
En direct
|
||||
}
|
||||
@case ('connecting') {
|
||||
Connexion…
|
||||
}
|
||||
@default {
|
||||
{{ streamMessage() }}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="input filter"
|
||||
type="search"
|
||||
placeholder="Filtrer les lignes"
|
||||
[(ngModel)]="filter"
|
||||
aria-label="Filtrer les lignes"
|
||||
/>
|
||||
<select
|
||||
class="select tail"
|
||||
[ngModel]="tail()"
|
||||
(ngModelChange)="tail.set(+$event)"
|
||||
aria-label="Nombre de lignes chargées"
|
||||
>
|
||||
<option [ngValue]="100">100 lignes</option>
|
||||
<option [ngValue]="300">300 lignes</option>
|
||||
<option [ngValue]="1000">1 000 lignes</option>
|
||||
<option [ngValue]="5000">5 000 lignes</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="togglePause()"
|
||||
[attr.aria-pressed]="paused()"
|
||||
>
|
||||
<app-icon [name]="paused() ? 'play' : 'pause'" /> {{ paused() ? 'Reprendre' : 'Pause' }}
|
||||
</button>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="follow" /> Suivre</label>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="showTimestamps" /> Heure</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="download()"
|
||||
aria-label="Télécharger les logs"
|
||||
>
|
||||
<app-icon name="download" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="clear()"
|
||||
aria-label="Vider l’affichage"
|
||||
>
|
||||
<app-icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-box" #logBox>
|
||||
@for (line of visibleLines(); track $index) {
|
||||
<div class="line" [class.err]="line.stream === 'err'">
|
||||
@if (showTimestamps()) {
|
||||
<span class="ts">{{ format.time(line.ts) }}</span>
|
||||
}
|
||||
<span class="text">{{ line.text }}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="muted">Aucune ligne.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (container(); as c) {
|
||||
<section class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Informations</h2></div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Projet compose</dt>
|
||||
<dd>
|
||||
{{ c.composeProject ?? '–' }}
|
||||
@if (c.composeService) {
|
||||
<span class="muted">(service {{ c.composeService }})</span>
|
||||
}
|
||||
</dd>
|
||||
<dt>Dossier</dt>
|
||||
<dd class="mono">{{ c.composeWorkingDir ?? '–' }}</dd>
|
||||
<dt>Commande</dt>
|
||||
<dd class="mono">{{ c.command || '–' }}</dd>
|
||||
<dt>Réseaux</dt>
|
||||
<dd>{{ c.networks.join(', ') || '–' }}</dd>
|
||||
<dt>Ports publiés</dt>
|
||||
<dd class="mono">{{ c.publishedPorts.join(', ') || 'aucun (accès via Caddy)' }}</dd>
|
||||
<dt>Créé le</dt>
|
||||
<dd>{{ format.dateTime(c.createdAt) }}</dd>
|
||||
<dt>Id</dt>
|
||||
<dd class="mono">{{ c.id.slice(0, 12) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Volumes</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Dans le conteneur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (mount of c.mounts; track mount.destination) {
|
||||
<tr>
|
||||
<td class="mono">{{ mount.source }}</td>
|
||||
<td class="mono">
|
||||
{{ mount.destination }}
|
||||
@if (mount.readOnly) {
|
||||
<span class="pill">lecture seule</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="2" class="muted">Aucun volume</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
.filter {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar .checkbox {
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-box {
|
||||
height: 480px;
|
||||
overflow: auto;
|
||||
padding: 10px 0;
|
||||
background: var(--surface-2);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
|
||||
.muted {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 16px 0 13px;
|
||||
border-left: 3px solid transparent;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
&.err {
|
||||
border-left-color: var(--serious);
|
||||
}
|
||||
}
|
||||
|
||||
.ts {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
} from '../../core/format';
|
||||
import type { ContainerAction, ContainerDetails, LogLine } from '../../core/models';
|
||||
import { openStream } from '../../core/sse';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { confirmContainerAction } from './container-actions';
|
||||
|
||||
const MAX_LINES = 5000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-detail',
|
||||
imports: [FormsModule, RouterLink, Icon, Status],
|
||||
templateUrl: './container-detail.html',
|
||||
styleUrl: './container-detail.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainerDetailPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
private logBox = viewChild<ElementRef<HTMLElement>>('logBox');
|
||||
|
||||
/**
|
||||
* Container name or id (route parameter).
|
||||
*/
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly container = signal<ContainerDetails | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal(false);
|
||||
|
||||
protected readonly lines = signal<LogLine[]>([]);
|
||||
protected readonly tail = signal(300);
|
||||
protected readonly paused = signal(false);
|
||||
protected readonly follow = signal(true);
|
||||
protected readonly showTimestamps = signal(true);
|
||||
protected readonly filter = signal('');
|
||||
protected readonly streamState = signal<'connecting' | 'open' | 'ended' | 'error'>('connecting');
|
||||
protected readonly streamMessage = signal<string | null>(null);
|
||||
private pending: LogLine[] = [];
|
||||
|
||||
protected readonly visibleLines = computed(() => {
|
||||
const filter = this.filter().trim().toLowerCase();
|
||||
const lines = this.lines();
|
||||
return filter ? lines.filter((line) => line.text.toLowerCase().includes(filter)) : lines;
|
||||
});
|
||||
|
||||
protected readonly status = computed(() => {
|
||||
const container = this.container();
|
||||
return container ? containerStatus(container.state, container.health) : null;
|
||||
});
|
||||
|
||||
protected readonly format = {
|
||||
bytes: formatBytes,
|
||||
percent: (value: number) => formatPercent(value, 1),
|
||||
dateTime: formatDateTime,
|
||||
time: (ts: string | null) => (ts ? formatTime(ts, true) : ''),
|
||||
};
|
||||
|
||||
private closeStream: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
untracked(() => this.load(id));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
const tail = this.tail();
|
||||
untracked(() => this.openLogs(id, tail));
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => this.closeStream?.());
|
||||
}
|
||||
|
||||
protected async load(id = this.id()) {
|
||||
try {
|
||||
this.container.set(await this.api.container(id));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(action: ContainerAction) {
|
||||
const container = this.container();
|
||||
if (!container || !(await confirmContainerAction(this.feedback, container.name, action)))
|
||||
return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
this.container.set(await this.api.containerAction(container.id, action));
|
||||
this.feedback.success(`${container.name} : action effectuée.`);
|
||||
/**
|
||||
* A restarted container starts a new log stream.
|
||||
*/
|
||||
if (action !== 'stop') this.openLogs(this.id(), this.tail());
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected togglePause() {
|
||||
const paused = !this.paused();
|
||||
this.paused.set(paused);
|
||||
if (!paused) this.flushPending();
|
||||
}
|
||||
|
||||
protected clear() {
|
||||
this.lines.set([]);
|
||||
this.pending = [];
|
||||
}
|
||||
|
||||
protected download() {
|
||||
const content = this.lines()
|
||||
.map((line) => `${line.ts ?? ''} ${line.text}`)
|
||||
.join('\n');
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${this.container()?.name ?? 'conteneur'}-logs.txt`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private openLogs(id: string, tail: number) {
|
||||
this.closeStream?.();
|
||||
this.lines.set([]);
|
||||
this.pending = [];
|
||||
this.streamState.set('connecting');
|
||||
this.streamMessage.set(null);
|
||||
|
||||
const url = `/api/containers/${encodeURIComponent(id)}/logs?tail=${tail}`;
|
||||
this.closeStream = openStream(
|
||||
url,
|
||||
{
|
||||
line: (line: LogLine) => {
|
||||
this.pending.push(line);
|
||||
if (!this.paused()) this.scheduleFlush();
|
||||
},
|
||||
end: () => {
|
||||
this.closeStream?.();
|
||||
this.flushPending();
|
||||
this.streamState.set('ended');
|
||||
this.streamMessage.set('Le flux s’est arrêté (conteneur arrêté ou redémarré).');
|
||||
},
|
||||
failure: ({ message }: { message: string }) => {
|
||||
this.closeStream?.();
|
||||
this.streamState.set('error');
|
||||
this.streamMessage.set(message);
|
||||
},
|
||||
},
|
||||
{
|
||||
onOpen: () => this.streamState.set('open'),
|
||||
onError: () => this.streamState.set('connecting'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private flushScheduled = false;
|
||||
|
||||
/**
|
||||
* Lines arrive in bursts: they are added once per frame.
|
||||
*/
|
||||
private scheduleFlush() {
|
||||
if (this.flushScheduled) return;
|
||||
this.flushScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.flushScheduled = false;
|
||||
this.flushPending();
|
||||
});
|
||||
}
|
||||
|
||||
private flushPending() {
|
||||
if (!this.pending.length) return;
|
||||
const added = this.pending;
|
||||
this.pending = [];
|
||||
this.lines.update((lines) => {
|
||||
const next = lines.concat(added);
|
||||
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
|
||||
});
|
||||
if (this.follow()) {
|
||||
requestAnimationFrame(() => {
|
||||
const box = this.logBox()?.nativeElement;
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatBytes, formatPercent } from '../../core/format';
|
||||
import type { ContainerAction, ContainerSummary } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { confirmContainerAction } from './container-actions';
|
||||
|
||||
type StateFilter = 'all' | 'running' | 'stopped';
|
||||
|
||||
/**
|
||||
* Only Caddy should be reachable from outside (80 and 443). A port bound to
|
||||
* the loopback ("127.0.0.1:3306->3306/tcp") is not reachable either.
|
||||
*/
|
||||
const CADDY_PORTS = /^(80|443)->/;
|
||||
const LOCAL_ONLY = /^(127\.|::1:|\[::1\]:)/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-containers',
|
||||
imports: [FormsModule, RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Conteneurs</h1>
|
||||
<p>Tous les conteneurs Docker du serveur, rafraîchis toutes les 10 secondes.</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="load()">
|
||||
<app-icon name="refresh" [class.spin]="loading()" /> Rafraîchir
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="input search"
|
||||
type="search"
|
||||
placeholder="Filtrer par nom ou image"
|
||||
[(ngModel)]="search"
|
||||
aria-label="Filtrer"
|
||||
/>
|
||||
<div class="segmented" role="group" aria-label="État">
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'all'"
|
||||
(click)="stateFilter.set('all')"
|
||||
>
|
||||
Tous ({{ containers().length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'running'"
|
||||
(click)="stateFilter.set('running')"
|
||||
>
|
||||
En marche ({{ runningCount() }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'stopped'"
|
||||
(click)="stateFilter.set('stopped')"
|
||||
>
|
||||
Arrêtés ({{ containers().length - runningCount() }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (exposed().length) {
|
||||
<div class="alert warning" role="status">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
<strong>Ports publiés sur Internet :</strong> {{ exposed().join(', ') }}. Ces conteneurs
|
||||
sont joignables sans passer par Caddy (voir la procédure : pas de
|
||||
<code>ports:</code> dans les projets).
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>État</th>
|
||||
<th class="num">Processeur</th>
|
||||
<th class="num">Mémoire</th>
|
||||
<th>Image</th>
|
||||
<th class="actions"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (container of filtered(); track container.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/containers', container.name]">{{ container.name }}</a>
|
||||
@if (container.self) {
|
||||
<span class="pill">ce tableau de bord</span>
|
||||
}
|
||||
<span class="sub">
|
||||
{{
|
||||
container.composeProject
|
||||
? 'Projet ' + container.composeProject
|
||||
: 'Hors compose'
|
||||
}}
|
||||
@for (port of container.publishedPorts; track port) {
|
||||
·
|
||||
<span class="port" [class.bad]="!isAllowedPort(container, port)">{{
|
||||
port
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="status(container).level"
|
||||
[label]="status(container).label"
|
||||
/>
|
||||
<span class="sub">{{ container.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ container.stats ? percent(container.stats.cpu) : '–' }}</td>
|
||||
<td class="num">{{ container.stats ? bytes(container.stats.memUsed) : '–' }}</td>
|
||||
<td class="image ellipsis mono" [title]="container.image">
|
||||
{{ container.image }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (container.state === 'running') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(container, 'restart')"
|
||||
[disabled]="busy() === container.id"
|
||||
>
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
@if (!container.self) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only danger"
|
||||
(click)="act(container, 'stop')"
|
||||
[disabled]="busy() === container.id"
|
||||
aria-label="Arrêter"
|
||||
>
|
||||
<app-icon name="stop" />
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(container, 'start')"
|
||||
[disabled]="busy() === container.id"
|
||||
>
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun conteneur' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
.image {
|
||||
max-width: 220px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.port.bad {
|
||||
color: var(--critical-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainersPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly containers = signal<ContainerSummary[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal<string | null>(null);
|
||||
protected readonly search = signal('');
|
||||
protected readonly stateFilter = signal<StateFilter>('all');
|
||||
|
||||
protected readonly runningCount = computed(
|
||||
() => this.containers().filter((c) => c.state === 'running').length,
|
||||
);
|
||||
|
||||
protected readonly filtered = computed(() => {
|
||||
const search = this.search().trim().toLowerCase();
|
||||
const state = this.stateFilter();
|
||||
return this.containers().filter((container) => {
|
||||
if (state === 'running' && container.state !== 'running') return false;
|
||||
if (state === 'stopped' && container.state === 'running') return false;
|
||||
return (
|
||||
!search ||
|
||||
container.name.toLowerCase().includes(search) ||
|
||||
container.image.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly exposed = computed(() =>
|
||||
this.containers()
|
||||
.filter((container) =>
|
||||
container.publishedPorts.some((port) => !this.isAllowedPort(container, port)),
|
||||
)
|
||||
.map((container) => container.name),
|
||||
);
|
||||
|
||||
protected readonly percent = (value: number) => formatPercent(value, 1);
|
||||
protected readonly bytes = formatBytes;
|
||||
protected readonly status = (container: ContainerSummary) =>
|
||||
containerStatus(container.state, container.health);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
const timer = setInterval(() => this.load(), 10_000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
}
|
||||
|
||||
protected isAllowedPort(container: ContainerSummary, port: string) {
|
||||
return LOCAL_ONLY.test(port) || (container.name === 'caddy' && CADDY_PORTS.test(port));
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
this.containers.set(await this.api.containers());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(container: ContainerSummary, action: ContainerAction) {
|
||||
if (!(await confirmContainerAction(this.feedback, container.name, action))) return;
|
||||
this.busy.set(container.id);
|
||||
try {
|
||||
await this.api.containerAction(container.id, action);
|
||||
this.feedback.success(`${container.name} : action effectuée.`);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
/**
|
||||
* Output of one action of the journal (live if it is still running).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-job',
|
||||
imports: [RouterLink, Icon, JobConsole],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/journal" class="back-link"><app-icon name="back" /> Journal</a>
|
||||
<h1>Action #{{ jobId() }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<app-job-console [jobId]="jobId()" />
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class JobPage {
|
||||
readonly id = input.required<string>();
|
||||
protected readonly jobId = computed(() => Number(this.id()));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { errorMessage, formatDateTime, formatDuration } from '../../core/format';
|
||||
import type { AuditEntry, AuditStatus } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status } from '../../shared/status';
|
||||
|
||||
const STATUS = {
|
||||
success: { level: 'good', label: 'Réussi' },
|
||||
error: { level: 'critical', label: 'Échec' },
|
||||
running: { level: 'running', label: 'En cours' },
|
||||
} as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-journal',
|
||||
imports: [RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Journal</h1>
|
||||
<p>Toutes les actions faites depuis le tableau de bord, et les connexions.</p>
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filtrer par résultat">
|
||||
<button type="button" [class.active]="status() === null" (click)="setStatus(null)">
|
||||
Tout
|
||||
</button>
|
||||
<button type="button" [class.active]="status() === 'error'" (click)="setStatus('error')">
|
||||
Échecs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="status() === 'running'"
|
||||
(click)="setStatus('running')"
|
||||
>
|
||||
En cours
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Cible</th>
|
||||
<th>Par</th>
|
||||
<th class="num">Durée</th>
|
||||
<th class="actions"><span class="sr-only">Détails</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<tr>
|
||||
<td class="nowrap">{{ dateTime(entry.createdAt) }}</td>
|
||||
<td><app-status [level]="statusOf(entry).level" [label]="entry.action" /></td>
|
||||
<td class="target mono ellipsis" [title]="entry.target ?? ''">
|
||||
{{ entry.target ?? '–' }}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{{ entry.username ?? '–' }}
|
||||
<span class="sub">{{ entry.ip }}</span>
|
||||
</td>
|
||||
<td class="num">{{ duration(entry) }}</td>
|
||||
<td class="actions">
|
||||
@if (entry.hasOutput || entry.status === 'running') {
|
||||
<a class="btn small" [routerLink]="['/jobs', entry.id]">Sortie</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty">Aucune entrée</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (lastPage() > 1) {
|
||||
<nav class="pagination" aria-label="Pages">
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="page.set(page() - 1)"
|
||||
[disabled]="page() <= 1"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span class="muted">Page {{ page() }} sur {{ lastPage() }} · {{ total() }} entrées</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="page.set(page() + 1)"
|
||||
[disabled]="page() >= lastPage()"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.target {
|
||||
max-width: 280px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class JournalPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly entries = signal<AuditEntry[]>([]);
|
||||
protected readonly page = signal(1);
|
||||
protected readonly lastPage = signal(1);
|
||||
protected readonly total = signal(0);
|
||||
protected readonly status = signal<AuditStatus | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly statusOf = (entry: AuditEntry) => STATUS[entry.status];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const page = this.page();
|
||||
const status = this.status();
|
||||
untracked(() => this.load(page, status));
|
||||
});
|
||||
}
|
||||
|
||||
protected setStatus(status: AuditStatus | null) {
|
||||
this.status.set(status);
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected duration(entry: AuditEntry) {
|
||||
if (!entry.finishedAt) return '–';
|
||||
const seconds =
|
||||
(new Date(entry.finishedAt).getTime() - new Date(entry.createdAt).getTime()) / 1000;
|
||||
return seconds < 1 ? '< 1 s' : formatDuration(seconds);
|
||||
}
|
||||
|
||||
private async load(page: number, status: AuditStatus | null) {
|
||||
try {
|
||||
const result = await this.api.audit(page, status ?? undefined);
|
||||
this.entries.set(result.data);
|
||||
this.lastPage.set(result.meta.lastPage);
|
||||
this.total.set(result.meta.total);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Auth } from '../../core/auth';
|
||||
import { errorMessage } from '../../core/format';
|
||||
import { Icon } from '../../shared/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [FormsModule, Icon],
|
||||
template: `
|
||||
<main class="login">
|
||||
<form class="card" (ngSubmit)="submit()" autocomplete="on">
|
||||
<div class="brand">
|
||||
<span class="logo"><app-icon name="eye" /></span>
|
||||
<div>
|
||||
<h1>Big Brother</h1>
|
||||
<p>Gestion du VPS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Identifiant</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
class="input"
|
||||
autocomplete="username"
|
||||
required
|
||||
[(ngModel)]="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
[(ngModel)]="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="code">Code de double authentification</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
class="input code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
placeholder="123456"
|
||||
required
|
||||
[(ngModel)]="code"
|
||||
/>
|
||||
<span class="hint">Les 6 chiffres affichés par ton application (Aegis, 2FAS…).</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn primary" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
}
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
`,
|
||||
styles: `
|
||||
.login {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: min(380px, 100%);
|
||||
padding: 28px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.brand p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.logo app-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.code {
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
button[type='submit'] {
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Login {
|
||||
private auth = inject(Auth);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* Page asked before being sent to the login page.
|
||||
*/
|
||||
readonly next = input<string>();
|
||||
|
||||
protected readonly username = signal('');
|
||||
protected readonly password = signal('');
|
||||
protected readonly code = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected async submit() {
|
||||
if (this.loading()) return;
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.auth.login(this.username().trim(), this.password(), this.code().trim());
|
||||
const next = this.next();
|
||||
await this.router.navigateByUrl(
|
||||
next && next.startsWith('/') && !next.startsWith('//') ? next : '/',
|
||||
);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
this.code.set('');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Vue d’ensemble</h1>
|
||||
<p>État du serveur, mis à jour toutes les 5 secondes.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (latest(); as host) {
|
||||
<section class="grid-tiles" aria-label="Ressources du serveur">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Processeur</span>
|
||||
<span class="tile-value">{{ format.percent(host.cpu) }}</span>
|
||||
<span class="tile-sub">{{ overview()?.host?.cpus ?? '–' }} cœurs</span>
|
||||
<div class="meter" [class]="meterLevel(host.cpu)">
|
||||
<span [style.width.%]="host.cpu"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Mémoire</span>
|
||||
<span class="tile-value">{{
|
||||
format.percent(format.ratio(host.memUsed, host.memTotal))
|
||||
}}</span>
|
||||
<span class="tile-sub"
|
||||
>{{ format.bytes(host.memUsed) }} sur {{ format.bytes(host.memTotal) }}</span
|
||||
>
|
||||
<div class="meter" [class]="meterLevel(format.ratio(host.memUsed, host.memTotal))">
|
||||
<span [style.width.%]="format.ratio(host.memUsed, host.memTotal)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Disque</span>
|
||||
<span class="tile-value">{{
|
||||
format.percent(format.ratio(host.diskUsed, host.diskTotal))
|
||||
}}</span>
|
||||
<span class="tile-sub"
|
||||
>{{ format.bytes(host.diskTotal - host.diskUsed) }} libres sur
|
||||
{{ format.bytes(host.diskTotal) }}</span
|
||||
>
|
||||
<div class="meter" [class]="meterLevel(format.ratio(host.diskUsed, host.diskTotal))">
|
||||
<span [style.width.%]="format.ratio(host.diskUsed, host.diskTotal)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Réseau</span>
|
||||
<span class="tile-value small">↓ {{ format.rate(host.netRx) }}</span>
|
||||
<span class="tile-sub">↑ {{ format.rate(host.netTx) }} envoyés</span>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Charge (1 min)</span>
|
||||
<span class="tile-value">{{ format.number(host.load1, 2) }}</span>
|
||||
<span class="tile-sub">pour {{ overview()?.host?.cpus ?? '–' }} cœurs</span>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Allumé depuis</span>
|
||||
<span class="tile-value">{{ format.duration(overview()?.host?.uptime) }}</span>
|
||||
<span class="tile-sub">sans redémarrage</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (overview(); as data) {
|
||||
<section class="status-grid" aria-label="État des services">
|
||||
<a class="card status-card" routerLink="/containers">
|
||||
<div class="status-head">
|
||||
<h2>Conteneurs</h2>
|
||||
@if (!data.docker.ok) {
|
||||
<app-status level="critical" label="Docker injoignable" />
|
||||
} @else if (data.docker.unhealthy.length) {
|
||||
<app-status level="critical" [label]="data.docker.unhealthy.length + ' en erreur'" />
|
||||
} @else {
|
||||
<app-status level="good" label="OK" />
|
||||
}
|
||||
</div>
|
||||
@if (data.docker.ok) {
|
||||
<p class="big">
|
||||
{{ data.docker.running }} <span>/ {{ data.docker.total }} en marche</span>
|
||||
</p>
|
||||
@if (data.docker.unhealthy.length) {
|
||||
<p class="detail">En erreur : {{ data.docker.unhealthy.join(', ') }}</p>
|
||||
}
|
||||
@if (data.docker.stopped.length) {
|
||||
<p class="detail">Arrêtés : {{ data.docker.stopped.join(', ') }}</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="detail">{{ data.docker.error }}</p>
|
||||
}
|
||||
</a>
|
||||
|
||||
<a class="card status-card" routerLink="/sites">
|
||||
<div class="status-head">
|
||||
<h2>Sites</h2>
|
||||
@if (data.sites.down.length) {
|
||||
<app-status level="critical" [label]="data.sites.down.length + ' hors ligne'" />
|
||||
} @else if (data.sites.certSoon.length) {
|
||||
<app-status level="warning" label="Certificat à renouveler" />
|
||||
} @else {
|
||||
<app-status level="good" label="Tous en ligne" />
|
||||
}
|
||||
</div>
|
||||
<p class="big">
|
||||
{{ data.sites.up }} <span>/ {{ data.sites.total }} en ligne</span>
|
||||
</p>
|
||||
@for (site of data.sites.down; track site.id) {
|
||||
<p class="detail">{{ site.domain }} : {{ site.error }}</p>
|
||||
}
|
||||
@for (site of data.sites.certSoon; track site.id) {
|
||||
<p class="detail">Certificat de {{ site.domain }} : expire dans {{ site.days }} j</p>
|
||||
}
|
||||
</a>
|
||||
|
||||
<a class="card status-card" routerLink="/backups">
|
||||
<div class="status-head">
|
||||
<h2>Sauvegardes</h2>
|
||||
@if (!data.backups.last) {
|
||||
<app-status level="warning" label="Aucune" />
|
||||
} @else {
|
||||
<app-status level="good" label="OK" />
|
||||
}
|
||||
</div>
|
||||
@if (data.backups.last; as last) {
|
||||
<p class="big small-text">{{ format.relative(last.createdAt) }}</p>
|
||||
<p class="detail">Dernière : {{ format.dateTime(last.createdAt) }}</p>
|
||||
} @else {
|
||||
<p class="detail">Aucune sauvegarde dans le dossier.</p>
|
||||
}
|
||||
<p class="detail">
|
||||
@if (data.backups.schedule.dailyAt) {
|
||||
Automatique chaque jour à {{ data.backups.schedule.dailyAt }}
|
||||
} @else {
|
||||
Pas de sauvegarde automatique
|
||||
}
|
||||
</p>
|
||||
</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Historique</h2>
|
||||
<p>
|
||||
@if (range() === 'live') {
|
||||
10 dernières minutes, en direct
|
||||
} @else {
|
||||
Moyennes sur la période
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Période">
|
||||
@for (option of ranges; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="range() === option.value"
|
||||
(click)="range.set(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body charts" [class.loading]="loadingHistory()">
|
||||
<div>
|
||||
<h3>Processeur et mémoire</h3>
|
||||
<app-line-chart
|
||||
[series]="cpuMemSeries()"
|
||||
[formatY]="formatPercent"
|
||||
[yMax]="100"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
label="Utilisation du processeur et de la mémoire en pourcentage"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<h3>Réseau</h3>
|
||||
@if (hasNetwork()) {
|
||||
<app-line-chart
|
||||
[series]="networkSeries()"
|
||||
[formatY]="formatRate"
|
||||
[ySuggestedMax]="1024"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="180"
|
||||
label="Débit réseau reçu et envoyé"
|
||||
/>
|
||||
} @else {
|
||||
<p class="muted">Débit réseau indisponible sur cette machine.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Disque</h3>
|
||||
<app-line-chart
|
||||
[series]="diskSeries()"
|
||||
[formatY]="formatPercent"
|
||||
[yMax]="100"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="180"
|
||||
label="Espace disque utilisé en pourcentage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (overview()?.recent?.length) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Dernières actions</h2>
|
||||
<a routerLink="/journal">Tout le journal</a>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (entry of overview()!.recent; track entry.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="
|
||||
entry.status === 'success'
|
||||
? 'good'
|
||||
: entry.status === 'error'
|
||||
? 'critical'
|
||||
: 'running'
|
||||
"
|
||||
[label]="entry.action"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-2 ellipsis">{{ entry.target }}</td>
|
||||
<td class="muted nowrap">{{ entry.username ?? '–' }}</td>
|
||||
<td class="muted nowrap num">{{ format.relative(entry.createdAt) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
.tile-value.small {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.big {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
span {
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.small-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
td.ellipsis {
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatRelative,
|
||||
percent,
|
||||
} from '../../core/format';
|
||||
import type { HistoryPoint, HistoryRange, HostSample, Overview } from '../../core/models';
|
||||
import { openStream } from '../../core/sse';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { LineChart, type ChartSeries } from '../../shared/line-chart';
|
||||
import { Status } from '../../shared/status';
|
||||
|
||||
type Range = 'live' | HistoryRange;
|
||||
|
||||
const RANGES: { value: Range; label: string }[] = [
|
||||
{ value: 'live', label: 'Direct' },
|
||||
{ value: '1h', label: '1 h' },
|
||||
{ value: '24h', label: '24 h' },
|
||||
{ value: '7d', label: '7 j' },
|
||||
{ value: '30d', label: '30 j' },
|
||||
];
|
||||
|
||||
const RANGE_MS: Record<HistoryRange, number> = {
|
||||
'1h': 3600_000,
|
||||
'24h': 86400_000,
|
||||
'7d': 7 * 86400_000,
|
||||
'30d': 30 * 86400_000,
|
||||
};
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
cpu: number | null;
|
||||
mem: number | null;
|
||||
disk: number | null;
|
||||
rx: number | null;
|
||||
tx: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-overview',
|
||||
imports: [RouterLink, Icon, LineChart, Status],
|
||||
templateUrl: './overview.html',
|
||||
styleUrl: './overview.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OverviewPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly ranges = RANGES;
|
||||
protected readonly overview = signal<Overview | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly live = signal<HostSample[]>([]);
|
||||
protected readonly range = signal<Range>('live');
|
||||
protected readonly history = signal<HistoryPoint[]>([]);
|
||||
protected readonly loadingHistory = signal(false);
|
||||
private readonly now = signal(Date.now());
|
||||
|
||||
protected readonly latest = computed(
|
||||
() => this.live().at(-1) ?? this.overview()?.host.latest ?? null,
|
||||
);
|
||||
|
||||
protected readonly points = computed<Point[]>(() => {
|
||||
if (this.range() === 'live') {
|
||||
return this.live().map((sample) => ({
|
||||
x: sample.ts,
|
||||
cpu: sample.cpu,
|
||||
mem: percent(sample.memUsed, sample.memTotal),
|
||||
disk: percent(sample.diskUsed, sample.diskTotal),
|
||||
rx: sample.netRx,
|
||||
tx: sample.netTx,
|
||||
}));
|
||||
}
|
||||
return this.history().map((point) => ({
|
||||
x: point.t * 1000,
|
||||
cpu: point.cpu,
|
||||
mem: percent(point.memUsed, point.memTotal),
|
||||
disk: percent(point.diskUsed, point.diskTotal),
|
||||
rx: point.netRx,
|
||||
tx: point.netTx,
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly xRange = computed(() => {
|
||||
const range = this.range();
|
||||
if (range === 'live') return { min: null, max: null };
|
||||
const now = this.now();
|
||||
return { min: now - RANGE_MS[range], max: now };
|
||||
});
|
||||
|
||||
protected readonly cpuMemSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
label: 'Processeur',
|
||||
color: 'series-1',
|
||||
data: this.points().map((p) => ({ x: p.x, y: p.cpu })),
|
||||
},
|
||||
{ label: 'Mémoire', color: 'series-2', data: this.points().map((p) => ({ x: p.x, y: p.mem })) },
|
||||
]);
|
||||
|
||||
protected readonly networkSeries = computed<ChartSeries[]>(() => [
|
||||
{ label: 'Reçu', color: 'series-1', data: this.points().map((p) => ({ x: p.x, y: p.rx })) },
|
||||
{ label: 'Envoyé', color: 'series-2', data: this.points().map((p) => ({ x: p.x, y: p.tx })) },
|
||||
]);
|
||||
|
||||
protected readonly diskSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
label: 'Disque utilisé',
|
||||
color: 'series-1',
|
||||
fill: true,
|
||||
data: this.points().map((p) => ({ x: p.x, y: p.disk })),
|
||||
},
|
||||
]);
|
||||
|
||||
protected readonly hasNetwork = computed(
|
||||
() => this.latest()?.netRx !== null || this.range() !== 'live',
|
||||
);
|
||||
|
||||
protected readonly formatPercent = (value: number) => formatPercent(value);
|
||||
protected readonly formatRate = (value: number) => formatRate(value);
|
||||
protected readonly format = {
|
||||
bytes: formatBytes,
|
||||
rate: formatRate,
|
||||
percent: formatPercent,
|
||||
duration: formatDuration,
|
||||
number: formatNumber,
|
||||
relative: formatRelative,
|
||||
dateTime: formatDateTime,
|
||||
ratio: percent,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
this.refresh();
|
||||
const timer = setInterval(() => {
|
||||
this.refresh();
|
||||
const range = this.range();
|
||||
if (range !== 'live') {
|
||||
this.now.set(Date.now());
|
||||
this.loadHistory(range);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const close = openStream('/api/metrics/live', {
|
||||
init: (samples: HostSample[]) => this.live.set(samples),
|
||||
sample: (sample: HostSample) =>
|
||||
this.live.update((samples) => [...samples.slice(-119), sample]),
|
||||
});
|
||||
|
||||
destroyRef.onDestroy(() => {
|
||||
clearInterval(timer);
|
||||
close();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const range = this.range();
|
||||
if (range !== 'live') {
|
||||
this.now.set(Date.now());
|
||||
this.loadHistory(range);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected meterLevel(value: number) {
|
||||
if (value >= 90) return 'critical';
|
||||
if (value >= 80) return 'warning';
|
||||
return '';
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
this.overview.set(await this.api.overview());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadHistory(range: HistoryRange) {
|
||||
this.loadingHistory.set(true);
|
||||
try {
|
||||
const result = await this.api.history(range);
|
||||
if (this.range() === range) this.history.set(result.points);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loadingHistory.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/projects" class="back-link"><app-icon name="back" /> Projets</a>
|
||||
<h1>{{ name() }}</h1>
|
||||
<p>
|
||||
{{ categoryLabel() }}
|
||||
@if (project(); as p) {
|
||||
· <span class="mono">{{ p.path }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@if (status(); as s) {
|
||||
<app-status [level]="s.level" [label]="s.label" />
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<app-job-console [jobId]="id" (finished)="load()" />
|
||||
}
|
||||
|
||||
@if (project(); as p) {
|
||||
@if (p.kind !== 'static') {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Actions</h2>
|
||||
<p>Chaque action est enregistrée dans le journal avec sa sortie.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body actions-grid">
|
||||
@if (p.git) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
(click)="act('deploy')"
|
||||
[disabled]="busy() || p.git.detached"
|
||||
>
|
||||
<app-icon name="download" /> Mettre à jour
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="fetch()" [disabled]="fetching()">
|
||||
<app-icon name="refresh" [class.spin]="fetching()" /> Vérifier les nouveautés
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('recreate')" [disabled]="busy()">
|
||||
<app-icon name="refresh" /> Recréer
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('pull')" [disabled]="busy()">
|
||||
<app-icon name="upload" /> Mettre à jour les images
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('start')" [disabled]="busy()">
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
@if (!p.self) {
|
||||
<button type="button" class="btn danger" (click)="act('stop')" [disabled]="busy()">
|
||||
<app-icon name="stop" /> Arrêter
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (p.git?.detached) {
|
||||
<div class="alert warning">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
Le projet tourne sur un ancien commit ({{ p.git?.commit?.short }}). La mise à jour est
|
||||
bloquée tant qu’il n’est pas revenu sur la branche.
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act('checkout-branch')"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
Revenir sur la branche {{ p.defaultBranch }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (p.git?.dirty) {
|
||||
<div class="alert warning">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
{{ p.git?.dirty }} fichier(s) suivi(s) par git ont été modifiés directement sur le
|
||||
serveur. Le <code>git pull</code> peut échouer : modifie le code sur ton PC et pousse-le
|
||||
plutôt.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Conteneurs</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (container of p.containers; track container.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/containers', container.name]">{{ container.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="containerStatus(container.state, container.health).level"
|
||||
[label]="containerStatus(container.state, container.health).label"
|
||||
/>
|
||||
</td>
|
||||
<td class="muted">{{ container.status }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td class="empty">
|
||||
@if (p.kind === 'static') {
|
||||
Servi directement par Caddy, sans conteneur.
|
||||
} @else {
|
||||
Aucun conteneur pour ce projet (jamais lancé ou supprimé).
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (p.git; as git) {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Dépôt git</h2></div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Dépôt</dt>
|
||||
<dd class="mono">{{ p.remote ?? '–' }}</dd>
|
||||
<dt>Branche</dt>
|
||||
<dd>{{ git.detached ? 'aucune (ancien commit)' : git.branch }}</dd>
|
||||
<dt>Version en ligne</dt>
|
||||
<dd>
|
||||
@if (git.commit) {
|
||||
<span class="mono">{{ git.commit.short }}</span> · {{ git.commit.subject }}
|
||||
<span class="sub muted"
|
||||
>{{ git.commit.author }}, {{ relative(git.commit.date) }}</span
|
||||
>
|
||||
}
|
||||
</dd>
|
||||
<dt>Nouveautés</dt>
|
||||
<dd>
|
||||
@if (git.behind) {
|
||||
<app-status level="warning" [label]="git.behind + ' commit(s) à récupérer'" />
|
||||
} @else {
|
||||
À jour (depuis la dernière vérification)
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (p.kind === 'static') {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Mettre à jour le site</h2></div>
|
||||
<div class="card-body stack">
|
||||
<p class="text-2">
|
||||
Dernière mise à jour des fichiers : {{ dateTime(p.updatedAt) }}. Le build se fait sur
|
||||
ton PC, puis on envoie le dossier (voir la procédure) :
|
||||
</p>
|
||||
<pre class="code-block">{{ staticDeploy() }}</pre>
|
||||
<div>
|
||||
<button type="button" class="btn small" (click)="copy(staticDeploy())">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Dépôt git</h2></div>
|
||||
<div class="card-body text-2">
|
||||
Pas de dépôt git dans <span class="mono">project/</span> : la mise à jour par git n’est
|
||||
pas disponible.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (p.commits.length) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Historique</h2>
|
||||
<p>Revenir à un commit précédent si une mise à jour a cassé quelque chose.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (commit of p.commits; track commit.hash) {
|
||||
<tr [class.current]="isCurrent(commit)">
|
||||
<td class="mono nowrap">{{ commit.short }}</td>
|
||||
<td>
|
||||
{{ commit.subject }}
|
||||
<span class="sub">{{ commit.author }} · {{ dateTime(commit.date) }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (isCurrent(commit)) {
|
||||
<span class="pill">en ligne</span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act('checkout', commit)"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
<app-icon name="history" /> Revenir à ce commit
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (p.compose) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ p.composeFile }}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ p.compose }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert p .btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
tr.current td {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime, formatRelative } from '../../core/format';
|
||||
import type { GitCommit, ProjectAction, ProjectDetails } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { CATEGORY_LABELS, confirmProjectAction, projectStatus } from './project-helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-detail',
|
||||
imports: [RouterLink, Icon, JobConsole, Status],
|
||||
templateUrl: './project-detail.html',
|
||||
styleUrl: './project-detail.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectDetailPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
readonly category = input.required<string>();
|
||||
readonly name = input.required<string>();
|
||||
|
||||
protected readonly project = signal<ProjectDetails | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly fetching = signal(false);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly status = computed(() => {
|
||||
const project = this.project();
|
||||
return project ? projectStatus(project) : null;
|
||||
});
|
||||
|
||||
protected readonly categoryLabel = computed(
|
||||
() => CATEGORY_LABELS[this.category()] ?? this.category(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Commands of the procedure to send the build of a static site.
|
||||
*/
|
||||
protected readonly staticDeploy = computed(() => {
|
||||
const project = this.project();
|
||||
if (!project || project.kind !== 'static') return '';
|
||||
const target = project.path.replace(/\\/g, '/');
|
||||
return [
|
||||
'# sur le PC, dans le dossier du projet',
|
||||
'npm run build',
|
||||
`ssh <user>@<vps> "rm -rf ${target}.new"`,
|
||||
`scp -r dist <user>@<vps>:${target}.new`,
|
||||
`ssh <user>@<vps> "rm -rf ${target} && mv ${target}.new ${target}"`,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
protected readonly containerStatus = containerStatus;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly relative = formatRelative;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const category = this.category();
|
||||
const name = this.name();
|
||||
untracked(() => this.load(category, name));
|
||||
});
|
||||
}
|
||||
|
||||
protected async load(category = this.category(), name = this.name()) {
|
||||
try {
|
||||
this.project.set(await this.api.project(category, name));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async fetch() {
|
||||
this.fetching.set(true);
|
||||
try {
|
||||
const git = await this.api.fetchProject(this.category(), this.name());
|
||||
this.feedback.info(
|
||||
git.behind ? `${git.behind} nouveau(x) commit(s) disponible(s).` : 'Le projet est à jour.',
|
||||
);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.fetching.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(action: ProjectAction, commit?: GitCommit) {
|
||||
const project = this.project();
|
||||
if (!project) return;
|
||||
const extra = commit ? `\n\n${commit.short} · ${commit.subject}` : '';
|
||||
if (!(await confirmProjectAction(this.feedback, project, action, extra))) return;
|
||||
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.projectAction(
|
||||
project.category,
|
||||
project.name,
|
||||
action,
|
||||
commit?.hash,
|
||||
);
|
||||
this.jobId.set(jobId);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected isCurrent(commit: GitCommit) {
|
||||
return this.project()?.git?.commit?.hash === commit.hash;
|
||||
}
|
||||
|
||||
protected copy(text: string) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => this.feedback.success('Copié.'),
|
||||
() => this.feedback.error('Copie impossible.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Feedback } from '../../core/feedback';
|
||||
import type { Project, ProjectAction } from '../../core/models';
|
||||
import type { StatusLevel } from '../../shared/status';
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
api: 'API',
|
||||
frontend: 'Frontend',
|
||||
fullstack: 'Fullstack',
|
||||
wordpress: 'WordPress',
|
||||
html: 'Sites statiques',
|
||||
infra: 'Infrastructure',
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER = ['api', 'fullstack', 'frontend', 'wordpress', 'html', 'infra'];
|
||||
|
||||
export function projectStatus(project: Project): { level: StatusLevel; label: string } {
|
||||
if (project.kind === 'static') return { level: 'neutral', label: 'Fichiers statiques' };
|
||||
const total = project.containers.length;
|
||||
const running = project.containers.filter((container) => container.state === 'running').length;
|
||||
const unhealthy = project.containers.some((container) => container.health === 'unhealthy');
|
||||
if (total === 0) return { level: 'neutral', label: 'Aucun conteneur' };
|
||||
if (unhealthy) return { level: 'critical', label: 'En erreur' };
|
||||
if (running === total)
|
||||
return { level: 'good', label: total > 1 ? `En marche (${total})` : 'En marche' };
|
||||
if (running === 0) return { level: 'neutral', label: 'Arrêté' };
|
||||
return { level: 'warning', label: `${running}/${total} en marche` };
|
||||
}
|
||||
|
||||
const CONFIRMATIONS: Record<
|
||||
ProjectAction,
|
||||
{ title: string; message: string; label: string; danger?: boolean }
|
||||
> = {
|
||||
deploy: {
|
||||
title: 'Mettre à jour {name} ?',
|
||||
message:
|
||||
'git pull, puis les conteneurs sont recréés (le site est coupé pendant le redémarrage).',
|
||||
label: 'Mettre à jour',
|
||||
},
|
||||
restart: {
|
||||
title: 'Redémarrer {name} ?',
|
||||
message: 'Le service sera coupé quelques secondes.',
|
||||
label: 'Redémarrer',
|
||||
},
|
||||
stop: {
|
||||
title: 'Arrêter {name} ?',
|
||||
message: 'Le site restera hors ligne jusqu’à ce que tu le relances.',
|
||||
label: 'Arrêter',
|
||||
danger: true,
|
||||
},
|
||||
start: { title: 'Démarrer {name} ?', message: 'docker compose up -d', label: 'Démarrer' },
|
||||
recreate: {
|
||||
title: 'Recréer les conteneurs de {name} ?',
|
||||
message:
|
||||
'Relit le docker-compose.yml et le .env, reconstruit si besoin. Le service est coupé pendant l’opération.',
|
||||
label: 'Recréer',
|
||||
},
|
||||
pull: {
|
||||
title: 'Mettre à jour les images de {name} ?',
|
||||
message: 'docker compose pull puis redémarrage avec les nouvelles images.',
|
||||
label: 'Mettre à jour les images',
|
||||
},
|
||||
checkout: {
|
||||
title: 'Revenir à ce commit ?',
|
||||
message:
|
||||
'Le projet passe sur l’ancienne version puis redémarre. « Revenir sur la branche » annule ce retour.',
|
||||
label: 'Revenir à ce commit',
|
||||
danger: true,
|
||||
},
|
||||
'checkout-branch': {
|
||||
title: 'Revenir sur la branche ?',
|
||||
message: 'Le projet repasse sur la dernière version de la branche, puis redémarre.',
|
||||
label: 'Revenir sur la branche',
|
||||
},
|
||||
};
|
||||
|
||||
export function confirmProjectAction(
|
||||
feedback: Feedback,
|
||||
project: Project,
|
||||
action: ProjectAction,
|
||||
extra = '',
|
||||
) {
|
||||
const config = CONFIRMATIONS[action];
|
||||
const critical =
|
||||
project.category === 'infra' && ['stop', 'restart', 'recreate', 'pull'].includes(action);
|
||||
return feedback.confirm({
|
||||
title: config.title.replace('{name}', project.name),
|
||||
message:
|
||||
config.message +
|
||||
extra +
|
||||
(critical
|
||||
? '\n\nAttention : c’est un service d’infrastructure, d’autres projets en dépendent.'
|
||||
: '') +
|
||||
(project.self
|
||||
? '\n\nC’est le tableau de bord lui-même : la page sera indisponible quelques instants.'
|
||||
: ''),
|
||||
confirmLabel: config.label,
|
||||
danger: config.danger || critical,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/projects" class="back-link"><app-icon name="back" /> Projets</a>
|
||||
<h1>Nouveau projet</h1>
|
||||
<p>Les mêmes étapes que la procédure, faites automatiquement.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<app-job-console [jobId]="id" (finished)="jobStatus.set($event)" />
|
||||
@if (jobStatus() === 'success') {
|
||||
<div class="alert info">
|
||||
<app-icon name="good" />
|
||||
<p>
|
||||
Projet créé.
|
||||
<a [routerLink]="projectLink()">Ouvrir la page du projet</a>
|
||||
@if (addCaddy()) {
|
||||
·
|
||||
<a [href]="'https://' + domain()" target="_blank" rel="noopener noreferrer"
|
||||
>https://{{ domain() }}</a
|
||||
>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (jobStatus() === 'error') {
|
||||
<div class="alert critical">
|
||||
<app-icon name="critical" />
|
||||
<p>
|
||||
La création s’est arrêtée à l’étape en erreur (voir ci-dessus). Les étapes déjà faites ne
|
||||
sont pas annulées : corrige le problème sur le serveur ou supprime le dossier
|
||||
{{ targetPath() }} avant de réessayer.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<form class="layout" (ngSubmit)="submit()">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Type de projet</h2></div>
|
||||
<div class="card-body type-grid" role="radiogroup" aria-label="Type de projet">
|
||||
@for (option of types; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-option"
|
||||
role="radio"
|
||||
[attr.aria-checked]="type() === option.value"
|
||||
[class.selected]="type() === option.value"
|
||||
(click)="selectType(option.value)"
|
||||
>
|
||||
<strong>{{ option.label }}</strong>
|
||||
<span>{{ option.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Projet</h2></div>
|
||||
<div class="card-body stack">
|
||||
<div class="field">
|
||||
<label for="name">Nom</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
class="input"
|
||||
[(ngModel)]="name"
|
||||
placeholder="mon-projet"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="hint"
|
||||
>Minuscules, chiffres et tirets. Sert de nom de dossier, de conteneur et de
|
||||
sous-domaine.</span
|
||||
>
|
||||
@if (name() && !nameValid()) {
|
||||
<span class="field-error">Nom invalide.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isApp()) {
|
||||
<div class="field">
|
||||
<label for="category">Dossier</label>
|
||||
<select id="category" name="category" class="select" [(ngModel)]="category">
|
||||
@for (option of options()?.categories ?? []; track option) {
|
||||
@if (option !== 'wordpress') {
|
||||
<option [value]="option">/srv/{{ option }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="git">URL du dépôt git</label>
|
||||
<input
|
||||
id="git"
|
||||
name="git"
|
||||
class="input mono"
|
||||
[(ngModel)]="gitUrl"
|
||||
placeholder="https://git.nucleus-ludus.fr/compte/mon-projet.git"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="hint"
|
||||
>Dépôt privé : utilise un jeton en lecture seule dans l’URL
|
||||
(https://compte:jeton@…).</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="port">Port interne</label>
|
||||
<input
|
||||
id="port"
|
||||
name="port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
[(ngModel)]="port"
|
||||
/>
|
||||
<span class="hint">Port écouté par l’app dans le conteneur (pas publié).</span>
|
||||
</div>
|
||||
@if (type() === 'node') {
|
||||
<div class="field grow">
|
||||
<label for="command">Commande de démarrage</label>
|
||||
<input id="command" name="command" class="input mono" [(ngModel)]="command" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="env">Contenu du .env</label>
|
||||
<textarea
|
||||
id="env"
|
||||
name="env"
|
||||
class="textarea"
|
||||
[(ngModel)]="env"
|
||||
rows="8"
|
||||
placeholder="APP_KEY=... SESSION_DRIVER=cookie"
|
||||
></textarea>
|
||||
<span class="hint">
|
||||
Copie ton .env de production (sans NODE_ENV, HOST ni PORT : le compose les fixe).
|
||||
@if (type() === 'adonis') {
|
||||
APP_KEY, LOG_LEVEL et APP_URL sont ajoutés s’ils manquent.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="db" [(ngModel)]="createDatabase" />
|
||||
<span>
|
||||
Créer une base MariaDB pour ce projet
|
||||
<small
|
||||
>Base et utilisateur dédiés ; DB_HOST, DB_USER, DB_PASSWORD… sont ajoutés au
|
||||
.env.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
@if (type() === 'wordpress') {
|
||||
<div class="alert info">
|
||||
<app-icon name="warning" />
|
||||
<p>
|
||||
Une base et un utilisateur dédiés sont créés automatiquement, le mot de passe va
|
||||
dans le .env du projet.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Accès web</h2></div>
|
||||
<div class="card-body stack">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="caddy" [(ngModel)]="addCaddy" />
|
||||
<span>
|
||||
Ajouter au Caddyfile (HTTPS automatique)
|
||||
<small
|
||||
>Le domaine doit pointer sur le VPS (DNS) pour que le certificat soit
|
||||
délivré.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
@if (addCaddy()) {
|
||||
<div class="field">
|
||||
<label for="domain">Domaine</label>
|
||||
<input
|
||||
id="domain"
|
||||
name="domain"
|
||||
class="input"
|
||||
[ngModel]="domain()"
|
||||
(ngModelChange)="customDomain.set($event)"
|
||||
/>
|
||||
</div>
|
||||
@if (type() === 'static') {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="spa" [(ngModel)]="spa" />
|
||||
<span>
|
||||
Application monopage (Vue, React, Angular…)
|
||||
<small
|
||||
>Les URL inconnues renvoient index.html. À décocher pour un site multi-pages
|
||||
classique.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="card summary">
|
||||
<div class="card-header"><h2>Ce qui va être fait</h2></div>
|
||||
<div class="card-body stack">
|
||||
<ol>
|
||||
@for (step of steps(); track $index) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn primary" [disabled]="submitting() || !options()">
|
||||
@if (submitting()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
}
|
||||
Créer le projet
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.field {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--critical-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-2);
|
||||
|
||||
li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { errorMessage } from '../../core/format';
|
||||
import type {
|
||||
AuditStatus,
|
||||
CreateProjectInput,
|
||||
ProjectOptions,
|
||||
ProjectType,
|
||||
} from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
const TYPES: { value: ProjectType; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'node',
|
||||
label: 'Node.js',
|
||||
description: 'Une app Node lancée par une commande (node start.js…).',
|
||||
},
|
||||
{
|
||||
value: 'adonis',
|
||||
label: 'AdonisJS',
|
||||
description: 'Compilée (node ace build) puis lancée en production.',
|
||||
},
|
||||
{ value: 'wordpress', label: 'WordPress', description: 'Image officielle, base MariaDB dédiée.' },
|
||||
{
|
||||
value: 'static',
|
||||
label: 'Site statique',
|
||||
description: 'Fichiers servis directement par Caddy.',
|
||||
},
|
||||
];
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-new',
|
||||
imports: [FormsModule, RouterLink, Icon, JobConsole],
|
||||
templateUrl: './project-new.html',
|
||||
styleUrl: './project-new.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectNewPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly types = TYPES;
|
||||
protected readonly options = signal<ProjectOptions | null>(null);
|
||||
|
||||
protected readonly type = signal<ProjectType>('node');
|
||||
protected readonly name = signal('');
|
||||
protected readonly category = signal('api');
|
||||
protected readonly gitUrl = signal('');
|
||||
protected readonly port = signal(3333);
|
||||
protected readonly command = signal('');
|
||||
protected readonly env = signal('');
|
||||
protected readonly createDatabase = signal(false);
|
||||
protected readonly addCaddy = signal(true);
|
||||
protected readonly spa = signal(true);
|
||||
/**
|
||||
* Null while the domain follows the name.
|
||||
*/
|
||||
protected readonly customDomain = signal<string | null>(null);
|
||||
|
||||
protected readonly submitting = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
protected readonly jobStatus = signal<AuditStatus | null>(null);
|
||||
|
||||
protected readonly domain = computed(
|
||||
() =>
|
||||
this.customDomain() ??
|
||||
(this.name() ? `${this.name()}.${this.options()?.baseDomain ?? ''}` : ''),
|
||||
);
|
||||
|
||||
protected readonly isApp = computed(() => this.type() === 'node' || this.type() === 'adonis');
|
||||
protected readonly nameValid = computed(() => NAME_PATTERN.test(this.name()));
|
||||
|
||||
protected readonly targetPath = computed(() => {
|
||||
const root = this.options()?.srvRoot ?? '/srv';
|
||||
const folder =
|
||||
this.type() === 'static'
|
||||
? 'html'
|
||||
: this.type() === 'wordpress'
|
||||
? 'wordpress'
|
||||
: this.category();
|
||||
return `${root}/${folder}/${this.name() || '<nom>'}`.replace(/\\/g, '/');
|
||||
});
|
||||
|
||||
/**
|
||||
* What the creation will do, step by step.
|
||||
*/
|
||||
protected readonly steps = computed(() => {
|
||||
const steps: string[] = [`Créer le dossier ${this.targetPath()}`];
|
||||
const type = this.type();
|
||||
if (type === 'static') {
|
||||
steps.push('Y mettre une page d’attente (à remplacer par ton build)');
|
||||
} else {
|
||||
if (this.isApp()) steps.push('Cloner le dépôt dans project/');
|
||||
if (type === 'wordpress' || this.createDatabase()) {
|
||||
steps.push(
|
||||
`Créer la base MariaDB « ${(this.name() || '<nom>').replace(/-/g, '_')} » et son utilisateur`,
|
||||
);
|
||||
}
|
||||
steps.push(
|
||||
type === 'wordpress' ? 'Écrire le .env (identifiants de la base)' : 'Écrire project/.env',
|
||||
);
|
||||
steps.push('Écrire le docker-compose.yml (template de la procédure)');
|
||||
steps.push('Lancer : docker compose up -d');
|
||||
}
|
||||
if (this.addCaddy())
|
||||
steps.push(`Ajouter ${this.domain() || '<domaine>'} au Caddyfile et recharger Caddy`);
|
||||
return steps;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.api
|
||||
.projectOptions()
|
||||
.then((options) => {
|
||||
this.options.set(options);
|
||||
this.command.set(options.defaultNodeCommand);
|
||||
})
|
||||
.catch((error) => this.error.set(errorMessage(error)));
|
||||
}
|
||||
|
||||
protected selectType(type: ProjectType) {
|
||||
this.type.set(type);
|
||||
if (type === 'wordpress') this.category.set('wordpress');
|
||||
else if (this.category() === 'wordpress') this.category.set('api');
|
||||
}
|
||||
|
||||
protected async submit() {
|
||||
this.error.set(null);
|
||||
if (!this.nameValid()) {
|
||||
this.error.set('Le nom ne peut contenir que des minuscules, des chiffres et des tirets.');
|
||||
return;
|
||||
}
|
||||
if (this.isApp() && !this.gitUrl().trim()) {
|
||||
this.error.set('L’URL du dépôt git est obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.type();
|
||||
const input: CreateProjectInput = {
|
||||
type,
|
||||
name: this.name(),
|
||||
category: type === 'wordpress' ? 'wordpress' : this.category(),
|
||||
createDatabase: type === 'wordpress' || (this.isApp() && this.createDatabase()),
|
||||
domain: this.domain(),
|
||||
addCaddy: this.addCaddy(),
|
||||
spa: this.spa(),
|
||||
};
|
||||
if (this.isApp()) {
|
||||
input.gitUrl = this.gitUrl().trim();
|
||||
input.port = Number(this.port());
|
||||
input.env = this.env();
|
||||
if (type === 'node') input.command = this.command().trim();
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.createProject(input);
|
||||
this.jobStatus.set(null);
|
||||
this.jobId.set(jobId);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected projectLink() {
|
||||
const type = this.type();
|
||||
const category =
|
||||
type === 'static' ? 'html' : type === 'wordpress' ? 'wordpress' : this.category();
|
||||
return ['/projects', category, this.name()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime, formatRelative } from '../../core/format';
|
||||
import type { Project, ProjectAction } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
import { Status } from '../../shared/status';
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
confirmProjectAction,
|
||||
projectStatus,
|
||||
} from './project-helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects',
|
||||
imports: [RouterLink, Icon, JobConsole, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Projets</h1>
|
||||
<p>
|
||||
Les projets de <span class="mono">/srv</span>, avec leur dépôt git et leurs conteneurs.
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn" (click)="load()">
|
||||
<app-icon name="refresh" [class.spin]="loading()" /> Rafraîchir
|
||||
</button>
|
||||
<a routerLink="/projects/new" class="btn primary"
|
||||
><app-icon name="plus" /> Nouveau projet</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<div class="stack">
|
||||
<app-job-console [jobId]="id" (finished)="load()" />
|
||||
<div>
|
||||
<button type="button" class="btn small ghost" (click)="jobId.set(null)">Masquer</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (group of groups(); track group.category) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ group.label }}</h2>
|
||||
<span class="muted">{{ group.projects.length }}</span>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (project of group.projects; track project.path) {
|
||||
<tr>
|
||||
<td class="name">
|
||||
<a [routerLink]="['/projects', project.category, project.name]">{{
|
||||
project.name
|
||||
}}</a>
|
||||
@if (project.self) {
|
||||
<span class="pill">ce tableau de bord</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<app-status [level]="status(project).level" [label]="status(project).label" />
|
||||
</td>
|
||||
<td class="git">
|
||||
@if (project.git; as git) {
|
||||
<span class="branch">
|
||||
<app-icon name="branch" />
|
||||
{{ git.detached ? 'commit ' + (git.commit?.short ?? '') : git.branch }}
|
||||
</span>
|
||||
@if (git.commit) {
|
||||
<span class="sub ellipsis" [title]="git.commit.subject">
|
||||
{{ git.commit.short }} · {{ git.commit.subject }} ·
|
||||
{{ relative(git.commit.date) }}
|
||||
</span>
|
||||
}
|
||||
@if (git.detached) {
|
||||
<app-status level="warning" label="Sur un ancien commit" />
|
||||
} @else if (git.behind) {
|
||||
<app-status
|
||||
level="warning"
|
||||
[label]="git.behind + ' commit(s) à récupérer'"
|
||||
/>
|
||||
}
|
||||
@if (git.dirty) {
|
||||
<app-status
|
||||
level="warning"
|
||||
[label]="git.dirty + ' fichier(s) modifié(s) sur le serveur'"
|
||||
/>
|
||||
}
|
||||
} @else if (project.kind === 'static') {
|
||||
<span class="sub">Mis à jour le {{ dateTime(project.updatedAt) }}</span>
|
||||
} @else {
|
||||
<span class="sub">Pas de dépôt git</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (project.git && !project.git.detached) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(project, 'deploy')"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
<app-icon name="download" /> Mettre à jour
|
||||
</button>
|
||||
}
|
||||
@if (project.kind !== 'static') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="act(project, 'restart')"
|
||||
[disabled]="busy()"
|
||||
[attr.aria-label]="'Redémarrer ' + project.name"
|
||||
>
|
||||
<app-icon name="restart" />
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
} @empty {
|
||||
<div class="card empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun projet trouvé dans /srv' }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.name {
|
||||
width: 22%;
|
||||
font-weight: 500;
|
||||
}
|
||||
.name .pill {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.git {
|
||||
max-width: 420px;
|
||||
}
|
||||
.git app-status {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.branch app-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectsPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly projects = signal<Project[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly groups = computed(() => {
|
||||
const byCategory = new Map<string, Project[]>();
|
||||
for (const project of this.projects()) {
|
||||
byCategory.set(project.category, [...(byCategory.get(project.category) ?? []), project]);
|
||||
}
|
||||
const rank = (category: string) => {
|
||||
const index = CATEGORY_ORDER.indexOf(category);
|
||||
return index === -1 ? CATEGORY_ORDER.length - 1 : index;
|
||||
};
|
||||
return [...byCategory.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([category, projects]) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category] ?? category,
|
||||
projects,
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly status = projectStatus;
|
||||
protected readonly relative = formatRelative;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
this.projects.set(await this.api.projects());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(project: Project, action: ProjectAction) {
|
||||
if (!(await confirmProjectAction(this.feedback, project, action))) return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.projectAction(project.category, project.name, action);
|
||||
this.jobId.set(jobId);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import {
|
||||
errorMessage,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
} from '../../core/format';
|
||||
import type { SiteDetails, SiteRange } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { LineChart, type ChartSeries } from '../../shared/line-chart';
|
||||
import { Status } from '../../shared/status';
|
||||
import { certificateStatus, siteStatus, uptimeLevel } from './site-status';
|
||||
|
||||
const RANGES: { value: SiteRange; label: string; ms: number }[] = [
|
||||
{ value: '24h', label: '24 h', ms: 86400_000 },
|
||||
{ value: '7d', label: '7 j', ms: 7 * 86400_000 },
|
||||
{ value: '30d', label: '30 j', ms: 30 * 86400_000 },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-detail',
|
||||
imports: [RouterLink, Icon, LineChart, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/sites" class="back-link"><app-icon name="back" /> Sites</a>
|
||||
<h1>{{ details()?.site?.domain ?? 'Site' }}</h1>
|
||||
@if (details(); as d) {
|
||||
<p>
|
||||
@if (d.site.kind === 'proxy') {
|
||||
Reverse proxy vers <span class="mono">{{ d.site.upstream }}</span>
|
||||
} @else if (d.site.kind === 'static') {
|
||||
Fichiers statiques de <span class="mono">{{ d.site.upstream }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Période">
|
||||
@for (option of ranges; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="range() === option.value"
|
||||
(click)="range.set(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (details(); as d) {
|
||||
<section class="grid-tiles">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">État actuel</span>
|
||||
<app-status [level]="status(d).level" [label]="status(d).label" />
|
||||
<span class="tile-sub">{{
|
||||
d.site.lastError ?? 'Vérifié le ' + dateTime(d.site.lastCheckedAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Disponibilité</span>
|
||||
<span class="tile-value">{{ percent(d.uptime) }}</span>
|
||||
<app-status [level]="uptimeLevel(d.uptime)" [label]="'sur ' + rangeLabel()" />
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Temps de réponse moyen</span>
|
||||
<span class="tile-value">{{
|
||||
d.avgResponseMs !== null ? number(d.avgResponseMs, 0) + ' ms' : '–'
|
||||
}}</span>
|
||||
<span class="tile-sub">sur {{ rangeLabel() }}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Certificat HTTPS</span>
|
||||
<app-status [level]="cert(d).level" [label]="cert(d).label" />
|
||||
<span class="tile-sub">Expire le {{ dateTime(d.site.certExpiresAt) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Temps de réponse</h2>
|
||||
<p>Moyenne par tranche de {{ bucketLabel() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" [class.loading]="loading()">
|
||||
<app-line-chart
|
||||
[series]="series()"
|
||||
[formatY]="formatMs"
|
||||
[ySuggestedMax]="200"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="240"
|
||||
label="Temps de réponse du site en millisecondes, avec les vérifications en échec"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Incidents</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Début</th>
|
||||
<th>Fin</th>
|
||||
<th class="num">Durée</th>
|
||||
<th>Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (incident of d.incidents; track incident.start) {
|
||||
<tr>
|
||||
<td class="nowrap">{{ dateTime(incident.start) }}</td>
|
||||
<td class="nowrap">
|
||||
@if (incident.end) {
|
||||
{{ dateTime(incident.end) }}
|
||||
} @else {
|
||||
<app-status level="critical" label="En cours" />
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{ duration((incident.end ?? now() / 1000) - incident.start) }}
|
||||
</td>
|
||||
<td class="text-2">{{ incident.error }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="empty">Aucun incident sur {{ rangeLabel() }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.card-body.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SiteDetailPage {
|
||||
private api = inject(Api);
|
||||
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly ranges = RANGES;
|
||||
protected readonly range = signal<SiteRange>('24h');
|
||||
protected readonly details = signal<SiteDetails | null>(null);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly now = signal(Date.now());
|
||||
|
||||
protected readonly rangeLabel = computed(
|
||||
() => RANGES.find((r) => r.value === this.range())!.label,
|
||||
);
|
||||
protected readonly bucketLabel = computed(() => formatDuration(this.details()?.bucket ?? 300));
|
||||
|
||||
protected readonly xRange = computed(() => {
|
||||
const ms = RANGES.find((r) => r.value === this.range())!.ms;
|
||||
return { min: this.now() - ms, max: this.now() };
|
||||
});
|
||||
|
||||
protected readonly series = computed<ChartSeries[]>(() => {
|
||||
const points = this.details()?.points ?? [];
|
||||
return [
|
||||
{
|
||||
label: 'Temps de réponse',
|
||||
color: 'series-1',
|
||||
data: points.map((p) => ({ x: p.t * 1000, y: p.responseMs })),
|
||||
},
|
||||
{
|
||||
label: 'Vérification en échec',
|
||||
color: 'critical',
|
||||
markers: true,
|
||||
data: points.map((p) => ({ x: p.t * 1000, y: p.ok ? null : 0 })),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly status = (d: SiteDetails) => siteStatus(d.site);
|
||||
protected readonly cert = (d: SiteDetails) => certificateStatus(d.site.certExpiresAt);
|
||||
protected readonly uptimeLevel = uptimeLevel;
|
||||
protected readonly percent = (value: number | null) => formatPercent(value, 2);
|
||||
protected readonly number = formatNumber;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly duration = formatDuration;
|
||||
protected readonly formatMs = (value: number) => `${formatNumber(value, 0)} ms`;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = Number(this.id());
|
||||
const range = this.range();
|
||||
untracked(() => this.load(id, range));
|
||||
});
|
||||
}
|
||||
|
||||
private async load(id: number, range: SiteRange) {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const details = await this.api.site(id, range);
|
||||
this.now.set(Date.now());
|
||||
this.details.set(details);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StatusLevel } from '../../shared/status';
|
||||
import type { Site } from '../../core/models';
|
||||
|
||||
export function siteStatus(site: Site): { level: StatusLevel; label: string } {
|
||||
if (!site.active) return { level: 'neutral', label: 'Retiré du Caddyfile' };
|
||||
if (site.lastOk === null) return { level: 'neutral', label: 'Pas encore vérifié' };
|
||||
if (site.lastOk)
|
||||
return { level: 'good', label: site.lastStatus ? `En ligne (${site.lastStatus})` : 'En ligne' };
|
||||
return { level: 'critical', label: 'Hors ligne' };
|
||||
}
|
||||
|
||||
export function certificateStatus(expiresAt: number | null): { level: StatusLevel; label: string } {
|
||||
if (!expiresAt) return { level: 'neutral', label: 'Inconnu' };
|
||||
const days = Math.floor((expiresAt * 1000 - Date.now()) / 86400_000);
|
||||
if (days < 0) return { level: 'critical', label: 'Expiré' };
|
||||
if (days < 7) return { level: 'critical', label: `${days} j restants` };
|
||||
if (days < 14) return { level: 'warning', label: `${days} j restants` };
|
||||
return { level: 'good', label: `${days} j restants` };
|
||||
}
|
||||
|
||||
export function uptimeLevel(uptime: number | null): StatusLevel {
|
||||
if (uptime === null) return 'neutral';
|
||||
if (uptime >= 99.5) return 'good';
|
||||
if (uptime >= 95) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatNumber, formatPercent, formatRelative } from '../../core/format';
|
||||
import type { SiteSummary } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status } from '../../shared/status';
|
||||
import { certificateStatus, siteStatus, uptimeLevel } from './site-status';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sites',
|
||||
imports: [RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Sites</h1>
|
||||
<p>Les domaines du Caddyfile, vérifiés chaque minute depuis le serveur.</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="checkNow()" [disabled]="checking()">
|
||||
<app-icon name="refresh" [class.spin]="checking()" /> Vérifier maintenant
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="alert info">
|
||||
<app-icon name="warning" />
|
||||
<p>
|
||||
Les vérifications partent du VPS lui-même : elles détectent un conteneur arrêté ou une
|
||||
erreur de configuration, pas une panne complète du serveur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domaine</th>
|
||||
<th>État</th>
|
||||
<th class="num">Réponse</th>
|
||||
<th class="num">Dispo 24 h</th>
|
||||
<th class="num">Dispo 7 j</th>
|
||||
<th>Certificat HTTPS</th>
|
||||
<th class="num">Vérifié</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (site of activeSites(); track site.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/sites', site.id]">{{ site.domain }}</a>
|
||||
<a
|
||||
class="external"
|
||||
[href]="'https://' + site.domain"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="'Ouvrir ' + site.domain"
|
||||
>
|
||||
<app-icon name="external" />
|
||||
</a>
|
||||
<span class="sub">
|
||||
@if (site.kind === 'proxy') {
|
||||
→ conteneur <span class="mono">{{ site.upstream }}</span>
|
||||
} @else if (site.kind === 'static') {
|
||||
→ fichiers <span class="mono">{{ site.upstream ?? '' }}</span>
|
||||
} @else {
|
||||
autre configuration
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<app-status [level]="status(site).level" [label]="status(site).label" />
|
||||
@if (site.lastOk === false && site.lastError) {
|
||||
<span class="sub">{{ site.lastError }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{
|
||||
site.lastResponseMs !== null ? number(site.lastResponseMs, 0) + ' ms' : '–'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">
|
||||
<app-status
|
||||
[level]="uptimeLevel(site.uptime24h)"
|
||||
[label]="percent(site.uptime24h)"
|
||||
/>
|
||||
</td>
|
||||
<td class="num">
|
||||
<app-status
|
||||
[level]="uptimeLevel(site.uptime7d)"
|
||||
[label]="percent(site.uptime7d)"
|
||||
/>
|
||||
</td>
|
||||
<td><app-status [level]="cert(site).level" [label]="cert(site).label" /></td>
|
||||
<td class="num muted">{{ relative(site.lastCheckedAt) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun domaine trouvé dans le Caddyfile' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (removedSites().length) {
|
||||
<details class="card removed">
|
||||
<summary>
|
||||
Retirés du Caddyfile ({{ removedSites().length }}) : historique conservé 30 jours
|
||||
</summary>
|
||||
<ul>
|
||||
@for (site of removedSites(); track site.id) {
|
||||
<li>
|
||||
<a [routerLink]="['/sites', site.id]">{{ site.domain }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.external {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
color: var(--muted);
|
||||
vertical-align: -3px;
|
||||
}
|
||||
.external app-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
td.num app-status {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.removed summary {
|
||||
padding: 12px 18px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.removed ul {
|
||||
margin: 0;
|
||||
padding: 0 18px 14px 36px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SitesPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly sites = signal<SiteSummary[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly checking = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected readonly activeSites = computed(() => this.sites().filter((site) => site.active));
|
||||
protected readonly removedSites = computed(() => this.sites().filter((site) => !site.active));
|
||||
|
||||
protected readonly status = siteStatus;
|
||||
protected readonly cert = (site: SiteSummary) => certificateStatus(site.certExpiresAt);
|
||||
protected readonly uptimeLevel = uptimeLevel;
|
||||
protected readonly percent = (value: number | null) => formatPercent(value, 1);
|
||||
protected readonly number = formatNumber;
|
||||
protected readonly relative = formatRelative;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
const timer = setInterval(() => this.load(), 30_000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
}
|
||||
|
||||
private async load() {
|
||||
try {
|
||||
this.sites.set(await this.api.sites());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkNow() {
|
||||
this.checking.set(true);
|
||||
try {
|
||||
this.sites.set(await this.api.checkSites());
|
||||
this.feedback.success('Vérification terminée.');
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.checking.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
/**
|
||||
* Outline icons (24×24, stroke 2), drawn with currentColor.
|
||||
*/
|
||||
const ICONS = {
|
||||
eye: '<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
||||
globe:
|
||||
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
folder:
|
||||
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
route:
|
||||
'<circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/>',
|
||||
archive:
|
||||
'<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
|
||||
list: '<path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/>',
|
||||
logout:
|
||||
'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
refresh:
|
||||
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||
play: '<path d="M6 3 20 12 6 21Z"/>',
|
||||
stop: '<rect width="14" height="14" x="5" y="5" rx="2"/>',
|
||||
restart: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
download:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
|
||||
upload:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
|
||||
trash:
|
||||
'<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
branch:
|
||||
'<path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
||||
external:
|
||||
'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
back: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
terminal: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
|
||||
pause:
|
||||
'<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||
history:
|
||||
'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
good: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||
critical: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
||||
warning: '<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
|
||||
neutral: '<circle cx="12" cy="12" r="10"/><path d="M8 12h8"/>',
|
||||
loader: '<path d="M21 12a9 9 0 1 1-6.22-8.56"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
save: '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
|
||||
table:
|
||||
'<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
|
||||
chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="m19 9-5 5-4-4-3 3"/>',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon',
|
||||
template: '',
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
'[innerHTML]': 'svg()',
|
||||
},
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
}
|
||||
:host ::ng-deep svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Icon {
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
readonly name = input.required<IconName>();
|
||||
|
||||
/**
|
||||
* The SVG markup comes from the constant table above only.
|
||||
*/
|
||||
protected readonly svg = computed(() =>
|
||||
this.sanitizer.bypassSecurityTrustHtml(
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[this.name()]}</svg>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Api } from '../core/api';
|
||||
import type { AuditStatus, JobLine } from '../core/models';
|
||||
import { openStream } from '../core/sse';
|
||||
import { Status } from './status';
|
||||
|
||||
const STATUS = {
|
||||
running: { level: 'running', label: 'En cours…' },
|
||||
success: { level: 'good', label: 'Terminé' },
|
||||
error: { level: 'critical', label: 'Échec' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Live output of a background action (deployment, backup, creation…).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-job-console',
|
||||
imports: [Status],
|
||||
template: `
|
||||
<div class="head">
|
||||
<div>
|
||||
<strong>{{ title() }}</strong>
|
||||
@if (target()) {
|
||||
<span class="muted"> · {{ target() }}</span>
|
||||
}
|
||||
</div>
|
||||
<app-status [level]="status().level" [label]="status().label" />
|
||||
</div>
|
||||
<pre
|
||||
class="output"
|
||||
#output
|
||||
aria-live="polite"
|
||||
>@for (line of lines(); track $index) {<span [class]="line.stream">{{ line.text }}
|
||||
</span>}</pre>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.output {
|
||||
max-height: 420px;
|
||||
min-height: 120px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-2);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.info {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.err {
|
||||
color: var(--text-2);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class JobConsole {
|
||||
private api = inject(Api);
|
||||
private outputEl = viewChild<ElementRef<HTMLElement>>('output');
|
||||
|
||||
readonly jobId = input.required<number>();
|
||||
readonly finished = output<AuditStatus>();
|
||||
|
||||
protected readonly title = signal('Action');
|
||||
protected readonly target = signal<string | null>(null);
|
||||
protected readonly lines = signal<JobLine[]>([]);
|
||||
protected readonly state = signal<AuditStatus>('running');
|
||||
protected readonly status = () => STATUS[this.state()];
|
||||
|
||||
private close: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.jobId();
|
||||
this.close?.();
|
||||
this.lines.set([]);
|
||||
this.state.set('running');
|
||||
|
||||
this.close = openStream(this.api.jobStreamUrl(id), {
|
||||
meta: (meta: { action: string; target: string | null }) => {
|
||||
this.title.set(meta.action);
|
||||
this.target.set(meta.target);
|
||||
},
|
||||
lines: (lines: JobLine[]) => {
|
||||
this.lines.update((current) => [...current, ...lines]);
|
||||
this.scrollToEnd();
|
||||
},
|
||||
end: ({ status }: { status: AuditStatus }) => {
|
||||
/**
|
||||
* Closed right away: the browser would otherwise reconnect and
|
||||
* replay the whole output.
|
||||
*/
|
||||
this.close?.();
|
||||
this.state.set(status);
|
||||
this.finished.emit(status);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => this.close?.());
|
||||
}
|
||||
|
||||
private scrollToEnd() {
|
||||
requestAnimationFrame(() => {
|
||||
const element = this.outputEl()?.nativeElement;
|
||||
if (element) element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Chart,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
type ChartDataset,
|
||||
type Plugin,
|
||||
} from 'chart.js';
|
||||
import { Theme } from '../core/theme';
|
||||
import { formatAxisTime, formatDateTime } from '../core/format';
|
||||
import { Icon } from './icon';
|
||||
|
||||
Chart.register(LineController, LineElement, PointElement, LinearScale, Tooltip, Filler);
|
||||
Chart.defaults.font.family = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
Chart.defaults.font.size = 12;
|
||||
|
||||
export type SeriesColor = 'series-1' | 'series-2' | 'critical';
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string;
|
||||
color: SeriesColor;
|
||||
/**
|
||||
* All the series of a chart share the same x values (null = no data).
|
||||
*/
|
||||
data: { x: number; y: number | null }[];
|
||||
/**
|
||||
* Markers only, without line (failed checks…).
|
||||
*/
|
||||
markers?: boolean;
|
||||
/**
|
||||
* Light wash under the line (single series charts).
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical hairline following the hovered x (the tooltip lists every series).
|
||||
*/
|
||||
const crosshair: Plugin<'line'> = {
|
||||
id: 'crosshair',
|
||||
afterDatasetsDraw(chart) {
|
||||
const active = chart.tooltip?.getActiveElements() ?? [];
|
||||
if (!active.length) return;
|
||||
const x = active[0].element.x;
|
||||
const { top, bottom } = chart.chartArea;
|
||||
const ctx = chart.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle =
|
||||
(chart.options.plugins as { crosshair?: { color: string } }).crosshair?.color ?? '#999';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Time series chart (Chart.js) with a crosshair tooltip, a legend from two
|
||||
* series and a table view for the same data.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-line-chart',
|
||||
imports: [Icon],
|
||||
template: `
|
||||
<div class="chart-head">
|
||||
@if (series().length > 1) {
|
||||
<ul class="legend">
|
||||
@for (item of series(); track item.label) {
|
||||
<li>
|
||||
<span
|
||||
class="key"
|
||||
[class.marker]="item.markers"
|
||||
[style.background]="colors()[item.color]"
|
||||
></span>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<span></span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost small"
|
||||
(click)="showTable.set(!showTable())"
|
||||
[attr.aria-pressed]="showTable()"
|
||||
>
|
||||
<app-icon [name]="showTable() ? 'chart' : 'table'" />
|
||||
{{ showTable() ? 'Graphique' : 'Tableau' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-box" [style.height.px]="height()" [hidden]="showTable()">
|
||||
<canvas #canvas role="img" [attr.aria-label]="label()"></canvas>
|
||||
@if (empty()) {
|
||||
<p class="empty-msg">Pas encore de données sur cette période</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showTable()) {
|
||||
<div class="table-box" [style.max-height.px]="height() + 40">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
@for (item of series(); track item.label) {
|
||||
<th class="num">{{ item.label }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track row.x) {
|
||||
<tr>
|
||||
<td class="nowrap">{{ formatDate(row.x) }}</td>
|
||||
@for (value of row.values; track $index) {
|
||||
<td class="num">{{ value === null ? '–' : formatY()(value) }}</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="series().length + 1" class="muted">Aucune donnée</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.legend li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.key {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.key.marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.canvas-box {
|
||||
position: relative;
|
||||
}
|
||||
.canvas-box[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.empty-msg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.table-box {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.table-box .table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LineChart {
|
||||
private theme = inject(Theme);
|
||||
private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||
private chart: Chart<'line', { x: number; y: number | null }[]> | null = null;
|
||||
|
||||
readonly series = input.required<ChartSeries[]>();
|
||||
readonly formatY = input<(value: number) => string>((value) => String(value));
|
||||
readonly yMin = input<number | null>(0);
|
||||
readonly yMax = input<number | null>(null);
|
||||
/**
|
||||
* Upper bound used while the values stay below it (empty chart, tiny values).
|
||||
*/
|
||||
readonly ySuggestedMax = input<number | null>(null);
|
||||
/**
|
||||
* Shown x range (defaults to the data extent).
|
||||
*/
|
||||
readonly xMin = input<number | null>(null);
|
||||
readonly xMax = input<number | null>(null);
|
||||
readonly height = input(220);
|
||||
readonly label = input('Graphique');
|
||||
|
||||
protected readonly showTable = signal(false);
|
||||
|
||||
protected readonly colors = computed(() => {
|
||||
this.theme.resolved();
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const read = (name: string) => style.getPropertyValue(name).trim();
|
||||
return {
|
||||
'series-1': read('--series-1'),
|
||||
'series-2': read('--series-2'),
|
||||
critical: read('--critical'),
|
||||
surface: read('--surface'),
|
||||
text: read('--text'),
|
||||
text2: read('--text-2'),
|
||||
muted: read('--muted'),
|
||||
grid: read('--grid'),
|
||||
axis: read('--axis'),
|
||||
border: read('--border-strong'),
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly empty = computed(() =>
|
||||
this.series().every((item) => item.data.every((point) => point.y === null)),
|
||||
);
|
||||
|
||||
protected readonly rows = computed(() => {
|
||||
const series = this.series();
|
||||
const first = series[0]?.data ?? [];
|
||||
return first
|
||||
.map((point, index) => ({
|
||||
x: point.x,
|
||||
values: series.map((item) => item.data[index]?.y ?? null),
|
||||
}))
|
||||
.filter((row) => row.values.some((value) => value !== null))
|
||||
.reverse();
|
||||
});
|
||||
|
||||
protected formatDate = (x: number) => formatDateTime(x);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.chart = new Chart(this.canvas().nativeElement, {
|
||||
type: 'line',
|
||||
data: { datasets: [] },
|
||||
options: { animation: false, responsive: true, maintainAspectRatio: false },
|
||||
plugins: [crosshair],
|
||||
});
|
||||
this.render();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.series();
|
||||
this.colors();
|
||||
this.formatY();
|
||||
this.yMin();
|
||||
this.yMax();
|
||||
this.ySuggestedMax();
|
||||
this.xMin();
|
||||
this.xMax();
|
||||
this.render();
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => this.chart?.destroy());
|
||||
}
|
||||
|
||||
private render() {
|
||||
const chart = this.chart;
|
||||
if (!chart) return;
|
||||
|
||||
const colors = this.colors();
|
||||
const series = this.series();
|
||||
const formatY = this.formatY();
|
||||
const xs = series.flatMap((item) => item.data.map((point) => point.x));
|
||||
const xMin = this.xMin() ?? (xs.length ? Math.min(...xs) : Date.now() - 3600_000);
|
||||
const xMax = this.xMax() ?? (xs.length ? Math.max(...xs) : Date.now());
|
||||
const span = xMax - xMin;
|
||||
|
||||
chart.data.datasets = series.map(
|
||||
(item): ChartDataset<'line', { x: number; y: number | null }[]> => {
|
||||
const color = colors[item.color];
|
||||
return {
|
||||
label: item.label,
|
||||
data: item.data,
|
||||
borderColor: color,
|
||||
backgroundColor: item.fill ? withAlpha(color, 0.1) : color,
|
||||
fill: item.fill ? 'origin' : false,
|
||||
showLine: !item.markers,
|
||||
spanGaps: false,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
pointRadius: item.markers ? 4 : 0,
|
||||
pointHoverRadius: item.markers ? 5 : 4,
|
||||
pointBorderWidth: 2,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: colors.surface,
|
||||
pointHoverBorderColor: colors.surface,
|
||||
pointHitRadius: 12,
|
||||
/**
|
||||
* Markers on the first or last x stay whole.
|
||||
*/
|
||||
clip: item.markers ? false : undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
chart.options = {
|
||||
animation: false,
|
||||
/**
|
||||
* No hover animation: the tooltip and the crosshair follow the pointer
|
||||
* right away.
|
||||
*/
|
||||
transitions: { active: { animation: { duration: 0 } } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
parsing: false,
|
||||
normalized: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { top: 4, right: 4 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
grid: { display: false },
|
||||
border: { color: colors.axis },
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
maxTicksLimit: 6,
|
||||
maxRotation: 0,
|
||||
callback: (value) => formatAxisTime(Number(value), span),
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: this.yMin() ?? undefined,
|
||||
max: this.yMax() ?? undefined,
|
||||
suggestedMax: this.ySuggestedMax() ?? undefined,
|
||||
grid: { color: colors.grid },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
maxTicksLimit: 5,
|
||||
callback: (value) => formatY(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
titleColor: colors.text2,
|
||||
bodyColor: colors.text,
|
||||
titleFont: { weight: 'normal' },
|
||||
bodyFont: { weight: 'bold' },
|
||||
padding: 10,
|
||||
boxWidth: 14,
|
||||
boxHeight: 2,
|
||||
boxPadding: 6,
|
||||
filter: (item) => item.parsed.y !== null,
|
||||
callbacks: {
|
||||
title: (items) => (items[0] ? formatDateTime(items[0].parsed.x ?? 0) : ''),
|
||||
label: (item) => `${formatY(item.parsed.y ?? 0)} ${item.dataset.label}`,
|
||||
labelColor: (item) => {
|
||||
const color = (item.dataset.borderColor as string) ?? colors.text;
|
||||
return { borderColor: color, backgroundColor: color, borderWidth: 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
(chart.options.plugins as Record<string, unknown>)['crosshair'] = { color: colors.axis };
|
||||
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number) {
|
||||
const hex = color.replace('#', '');
|
||||
if (hex.length !== 6) return color;
|
||||
const [r, g, b] = [0, 2, 4].map((index) => parseInt(hex.slice(index, index + 2), 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { Icon, type IconName } from './icon';
|
||||
|
||||
export type StatusLevel = 'good' | 'warning' | 'serious' | 'critical' | 'neutral' | 'running';
|
||||
|
||||
const ICON: Record<StatusLevel, IconName> = {
|
||||
good: 'good',
|
||||
warning: 'warning',
|
||||
serious: 'warning',
|
||||
critical: 'critical',
|
||||
neutral: 'neutral',
|
||||
running: 'loader',
|
||||
};
|
||||
|
||||
/**
|
||||
* A state shown with an icon AND a label: the color never carries the
|
||||
* meaning alone.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-status',
|
||||
imports: [Icon],
|
||||
template: `<app-icon [name]="icon()" [class.spin]="level() === 'running'" /><span>{{
|
||||
label()
|
||||
}}</span>`,
|
||||
host: { '[class]': 'level()' },
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
app-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
:host(.good) app-icon {
|
||||
color: var(--good);
|
||||
}
|
||||
:host(.warning) app-icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
:host(.serious) app-icon {
|
||||
color: var(--serious);
|
||||
}
|
||||
:host(.critical) app-icon {
|
||||
color: var(--critical);
|
||||
}
|
||||
:host(.neutral) app-icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
:host(.running) app-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Status {
|
||||
readonly level = input.required<StatusLevel>();
|
||||
readonly label = input.required<string>();
|
||||
protected readonly icon = computed(() => ICON[this.level()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker state -> status shown in the lists.
|
||||
*/
|
||||
export function containerStatus(
|
||||
state: string,
|
||||
health: string | null,
|
||||
): { level: StatusLevel; label: string } {
|
||||
if (state === 'running') {
|
||||
if (health === 'unhealthy') return { level: 'critical', label: 'En erreur' };
|
||||
if (health === 'starting') return { level: 'running', label: 'Démarrage' };
|
||||
return { level: 'good', label: 'En marche' };
|
||||
}
|
||||
if (state === 'restarting') return { level: 'serious', label: 'Redémarre en boucle' };
|
||||
if (state === 'paused') return { level: 'warning', label: 'En pause' };
|
||||
if (state === 'created') return { level: 'neutral', label: 'Créé' };
|
||||
if (state === 'dead') return { level: 'critical', label: 'Mort' };
|
||||
return { level: 'neutral', label: 'Arrêté' };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Big Brother</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
@@ -0,0 +1,657 @@
|
||||
/*
|
||||
* Design tokens. Chart colors come from a validated palette (blue / orange
|
||||
* for series, reserved status colors), stepped separately for dark mode.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f9f9f7;
|
||||
--surface: #fcfcfb;
|
||||
--surface-2: #f1f0ec;
|
||||
--surface-3: #e7e6e0;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--border-strong: rgba(11, 11, 11, 0.18);
|
||||
--text: #0b0b0b;
|
||||
--text-2: #52514e;
|
||||
--muted: #6f6e69;
|
||||
--grid: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
|
||||
--accent: #2a78d6;
|
||||
--accent-hover: #256abf;
|
||||
--accent-soft: rgba(42, 120, 214, 0.1);
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
|
||||
--good: #0ca30c;
|
||||
--warning: #fab219;
|
||||
--serious: #ec835a;
|
||||
--critical: #d03b3b;
|
||||
--good-text: #006300;
|
||||
--critical-text: #b42a2a;
|
||||
--critical-soft: rgba(208, 59, 59, 0.08);
|
||||
--warning-soft: rgba(250, 178, 25, 0.12);
|
||||
|
||||
--shadow: 0 1px 2px rgba(11, 11, 11, 0.04), 0 4px 16px rgba(11, 11, 11, 0.04);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
@mixin dark-tokens {
|
||||
color-scheme: dark;
|
||||
--page: #0d0d0d;
|
||||
--surface: #1a1a19;
|
||||
--surface-2: #232321;
|
||||
--surface-3: #2e2e2b;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--border-strong: rgba(255, 255, 255, 0.18);
|
||||
--text: #ffffff;
|
||||
--text-2: #c3c2b7;
|
||||
--muted: #9a9890;
|
||||
--grid: #2c2c2a;
|
||||
--axis: #383835;
|
||||
|
||||
--accent: #3987e5;
|
||||
--accent-hover: #5598e7;
|
||||
--accent-soft: rgba(57, 135, 229, 0.16);
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
|
||||
--good-text: #0ca30c;
|
||||
--critical-text: #e66767;
|
||||
--critical-soft: rgba(208, 59, 59, 0.16);
|
||||
--warning-soft: rgba(250, 178, 25, 0.12);
|
||||
|
||||
--shadow: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
@include dark-tokens;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
@include dark-tokens;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Page layout ---------- */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 48px;
|
||||
|
||||
@media (max-width: 720px) {
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-2);
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.card-body.flush {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---------- Stat tiles ---------- */
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
|
||||
.tile-label {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tile-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tile-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.meter {
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--warning-soft);
|
||||
|
||||
> span {
|
||||
background: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.critical {
|
||||
background: var(--critical-soft);
|
||||
|
||||
> span {
|
||||
background: var(--critical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--critical-text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--critical-soft);
|
||||
}
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.icon-only {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
app-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segmented control (time ranges, filters) */
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
|
||||
button {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
> label,
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline-offset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Misc ---------- */
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text-2 {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 18px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--critical);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--critical-soft);
|
||||
|
||||
&.warning {
|
||||
border-left-color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
&.info {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
app-icon {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.alert.critical app-icon {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
.alert.warning app-icon {
|
||||
color: #b07800;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
max-height: 480px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, max-content) minmax(0, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user