first commit

This commit is contained in:
Rutra committed 2026-10-12 14:25:22 +02:00
commit 3c0479faf0
169 files changed
+27617

No files matched your search

+17
View File
@@ -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
+44
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+108
View File
@@ -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"
}
}
}
}
}
}
+5962
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:3333",
"secure": false,
"changeOrigin": false
}
}
+1
View File
@@ -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

+17
View File
@@ -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])),
],
};
+82
View File
@@ -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: '' },
];
+10
View File
@@ -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 {}
+178
View File
@@ -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);
}
+82
View File
@@ -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);
}),
);
};
+58
View File
@@ -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);
}
}
+128
View File
@@ -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);
}
+253
View File
@@ -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;
}
+23
View File
@@ -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();
}
+52
View File
@@ -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';
}
}
}
+110
View File
@@ -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>
+236
View File
@@ -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);
}
}
+69
View File
@@ -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();
}
}
+182
View File
@@ -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));
}
}
}
+173
View File
@@ -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>
+71
View File
@@ -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;
}
+127
View File
@@ -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;
});
}
}
}
+281
View File
@@ -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);
}
}
}
+28
View File
@@ -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()));
}
+178
View File
@@ -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));
}
}
}
+161
View File
@@ -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);
}
}
}
+252
View File
@@ -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>
+73
View File
@@ -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;
}
+205
View File
@@ -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,
});
}
+239
View File
@@ -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&#64;…).</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=...&#10;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;
}
}
+177
View File
@@ -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()];
}
}
+236
View File
@@ -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);
}
}
}
+235
View File
@@ -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);
}
}
}
+26
View File
@@ -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';
}
+213
View File
@@ -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);
}
}
}
+98
View File
@@ -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>`,
),
);
}
+139
View File
@@ -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;
});
}
}
+412
View File
@@ -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})`;
}
+83
View File
@@ -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é' };
}
+14
View File
@@ -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>
+5
View File
@@ -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));
+657
View File
@@ -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;
}
+14
View File
@@ -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"
]
}
+28
View File
@@ -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"
}
]
}
+14
View File
@@ -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"
]
}