';
+}
+
+add_action('admin_post_yb_save_profile', function () {
+ if (!current_user_can('edit_pages')) wp_die('Accès refusé.');
+ check_admin_referer('yb_save_profile');
+
+ $identity = array();
+ $raw = isset($_POST['yb_identity']) ? (array) wp_unslash($_POST['yb_identity']) : array();
+ foreach (yb2026_identity_fields() as $key => $f) {
+ $val = isset($raw[$key]) ? $raw[$key] : '';
+ $identity[$key] = $f[1] === 'textarea' ? sanitize_textarea_field($val) : sanitize_text_field($val);
+ }
+ update_option('yb_identity', $identity, false);
+
+ foreach (yb2026_profile_repeaters() as $opt => $rep) {
+ $rows = isset($_POST[$opt]) ? (array) wp_unslash($_POST[$opt]) : array();
+ $clean = array();
+ foreach ($rows as $row) {
+ $item = array();
+ foreach ($rep['fields'] as $key => $f) {
+ $val = isset($row[$key]) ? $row[$key] : '';
+ if ($f[1] === 'number') $item[$key] = max(0, min(100, (int) $val));
+ elseif ($f[1] === 'textarea') $item[$key] = sanitize_textarea_field($val);
+ else $item[$key] = sanitize_text_field($val);
+ }
+ /* Ligne vide ignorée */
+ if (implode('', array_map('strval', array_filter($item, 'is_string')))) $clean[] = $item;
+ }
+ update_option($opt, $clean, false);
+ }
+
+ wp_safe_redirect(admin_url('admin.php?page=yb-profil&updated=1'));
+ exit;
+});
+
+/* ── Données structurées schema.org Person ─────────────────────────────
+ SEOPress émet déjà un Person minimal avec le même @id : les deux nœuds
+ fusionnent, celui-ci ajoute poste, employeur et zone géographique. */
+add_action('wp_head', function () {
+ $id = yb2026_profile('yb_identity');
+ $person = array(
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ '@id' => home_url('/#person'),
+ 'name' => 'Yasser Boulayoune',
+ 'url' => home_url('/'),
+ 'jobTitle' => $id['poste'],
+ 'worksFor' => array('@type' => 'Organization', 'name' => $id['entreprise']),
+ 'address' => array('@type' => 'PostalAddress', 'addressLocality' => $id['ville_schema'], 'addressRegion' => $id['region'], 'addressCountry' => 'FR'),
+ 'knowsAbout' => array('Support logiciel', 'MCO', 'Développement web', 'Administration systèmes et réseaux', 'Linux', 'Docker', 'WordPress', 'Symfony'),
+ );
+ echo '' . "\n";
+}, 20);
+
+/* ── Protection anti-collecte de l'email et du téléphone ───────────────
+ Dans le HTML, ces valeurs n'apparaissent jamais en clair : elles sont
+ encodées (ROT13 → inversion → base64) et décodées en JS au premier geste
+ d'un visiteur humain (voir YBProtect dans effects.js). Un robot qui lit
+ le HTML brut ne trouve ni « @ », ni « mailto: », ni numéro. */
+
+/** Encode une chaîne (décodage JS : atob → inversion → ROT13). */
+function yb2026_obf($s)
+{
+ return base64_encode(strrev(str_rot13((string) $s)));
+}
+
+/** Texte protégé : affiché « •••• » tant que le JS ne l'a pas décodé. */
+function yb2026_pv($value)
+{
+ return '' . str_repeat('•', 8) . '';
+}
+
+/** Attributs d'un lien protégé (href décodé au premier geste). */
+function yb2026_pv_href($href)
+{
+ return 'href="#" data-pv-href="' . esc_attr(yb2026_obf($href)) . '"';
+}
+
+/** Contenu des pages (mentions légales…) : email et téléphone du profil protégés automatiquement. */
+add_filter('the_content', function ($html) {
+ $email = yb2026_id('email');
+ $tel = yb2026_id('telephone');
+ if ($email) {
+ /* x → lien protégé */
+ $html = preg_replace_callback('#]*?)href=(["\'])mailto:' . preg_quote($email, '#') . '\2([^>]*)>(.*?)#is', function ($m) use ($email) {
+ $inner = str_ireplace($email, yb2026_pv($email), $m[4]);
+ return '' . $inner . '';
+ }, $html);
+ /* Email en texte brut restant (hors attributs) */
+ $html = preg_replace_callback('#(>[^<]*?)' . preg_quote($email, '#') . '#i', function ($m) use ($email) { return $m[1] . yb2026_pv($email); }, $html);
+ }
+ if ($tel) {
+ $digits = preg_replace('/\D/', '', $tel);
+ $loose = implode('[\s.\-]?', str_split($digits)); // « 06 00 00 00 00 », « 06.00… », « 0600000000 »
+ $html = preg_replace_callback('#(>[^<]*?)' . $loose . '#', function ($m) use ($tel) { return $m[1] . yb2026_pv($tel); }, $html);
+ }
+ return $html;
+}, 30);
diff --git a/inc/proofs.php b/inc/proofs.php
new file mode 100644
index 0000000..b8a2263
--- /dev/null
+++ b/inc/proofs.php
@@ -0,0 +1,210 @@
+ nom, 'items' => [ID, ID…] ].
+ * Les items sont des réalisations ou des articles ; le premier est la carte
+ * de devant dans le paquet. Nom court d'un article sur les cartes : méta `_yb_short_label`.
+ *
+ * Admin : Profil → Compétences (créer / renommer / réordonner / supprimer
+ * des compétences, y ajouter des réalisations et des articles).
+ */
+
+/** Liste prête pour le gabarit : [ nom => [ [post, kind, name, tag, thumb, wipe], … ] ]. */
+function yb2026_get_proofs()
+{
+ $rows = get_option('yb_proofs', array());
+ $out = array();
+ foreach ((array) $rows as $row) {
+ $links = array();
+ foreach ((array) ($row['items'] ?? array()) as $id) {
+ $p = get_post((int) $id);
+ if (!$p || $p->post_status !== 'publish' || !in_array($p->post_type, array('realisation', 'post'), true)) continue;
+ $is_post = $p->post_type === 'post';
+ $short = $is_post ? (string) get_post_meta($p->ID, '_yb_short_label', true) : '';
+ $cat = $is_post ? get_the_category($p->ID) : array();
+ $links[] = array(
+ 'post' => $p,
+ 'kind' => $is_post ? 'article' : 'projet',
+ 'name' => $short !== '' ? $short : yb2026_short_title($p),
+ 'tag' => $is_post ? 'ARTICLE · ' . mb_strtoupper($cat ? $cat[0]->name : 'BLOG') : (string) get_post_meta($p->ID, '_yb_tag', true),
+ 'thumb' => get_the_post_thumbnail_url($p, 'large'),
+ 'wipe' => $is_post ? 'LECTURE' : mb_strtoupper(yb2026_short_title($p)),
+ );
+ }
+ if ($links && !empty($row['skill'])) $out[$row['skill']] = $links;
+ }
+ return $out;
+}
+
+/* ── Admin ──────────────────────────────────────────────────────────── */
+
+add_action('admin_menu', function () {
+ add_submenu_page('yb-profil', 'Compétences', 'Compétences', 'edit_pages', 'yb-competences', 'yb2026_proofs_page');
+});
+
+function yb2026_proofs_page()
+{
+ $rows = (array) get_option('yb_proofs', array());
+ $reals = get_posts(array('post_type' => 'realisation', 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC'));
+ $arts = get_posts(array('post_type' => 'post', 'post_status' => 'publish', 'numberposts' => -1));
+ $label = function ($p) {
+ $s = $p->post_type === 'post' ? (string) get_post_meta($p->ID, '_yb_short_label', true) : '';
+ return ($p->post_type === 'post' ? 'Article · ' : 'Réalisation · ') . ($s !== '' ? $s : yb2026_short_title($p));
+ };
+ $all = array();
+ foreach (array_merge($reals, $arts) as $p) $all[$p->ID] = $label($p);
+
+ /* Contenus rattachés à aucune compétence : signalés en haut de page */
+ $used = array();
+ foreach ($rows as $r) foreach ((array) ($r['items'] ?? array()) as $id) $used[(int) $id] = true;
+ $orphans = array_diff_key($all, $used);
+
+ $options = function () use ($reals, $arts, $label) {
+ $h = '';
+ };
+ $chip = function ($id, $row) use ($all) {
+ return ''
+ . esc_html($all[$id] ?? ('#' . $id)) . ' ';
+ };
+ ?>
+
+
Compétences
+
Chaque compétence apparaît sur la page Compétences, dans « Chaque compétence a son projet ». Le premier contenu d'une compétence est la carte de devant ; avec plusieurs contenus, un clic ouvre le menu circulaire.
+
Compétences enregistrées.
+
+
Pas encore rattachés :
+
+
+
+
+
+
+ $skill !== '' ? $skill : 'Sans nom', 'items' => $items);
+ }
+ update_option('yb_proofs', $clean, false);
+ foreach ((array) wp_unslash($_POST['yb_short_label'] ?? array()) as $id => $lbl) {
+ $id = (int) $id;
+ if (get_post_type($id) !== 'post') continue;
+ $lbl = sanitize_text_field($lbl);
+ $lbl === '' ? delete_post_meta($id, '_yb_short_label') : update_post_meta($id, '_yb_short_label', $lbl);
+ }
+ wp_safe_redirect(admin_url('admin.php?page=yb-competences&updated=1'));
+ exit;
+});
diff --git a/inc/seasons.php b/inc/seasons.php
new file mode 100644
index 0000000..084230a
--- /dev/null
+++ b/inc/seasons.php
@@ -0,0 +1,222 @@
+).
+ * Auto : voir yb2026_season_auto() (dates en heure de Paris).
+ * Aperçu sans rien enregistrer : ?saison= | aucune
+ * Le visiteur peut couper le décor (bouton dans la barre, mémorisé : localStorage yb-season=off).
+ */
+
+function yb2026_seasons()
+{
+ $ico = array(
+ 'pumpkin' => '',
+ 'flake' => '',
+ 'burst' => '',
+ 'clover' => '',
+ 'egg' => '',
+ 'fish' => '',
+ 'note' => '',
+ 'cake' => '',
+ 'crescent' => '',
+ 'torii' => '',
+ 'prompt' => '',
+ 'tag' => '',
+ );
+ $s = array(
+ 'nouvel-an' => array('Nouvel An', 'burst', 'du 28 décembre au 6 janvier'),
+ 'st-patrick' => array('Saint-Patrick', 'clover', 'du 14 au 17 mars'),
+ 'anniversaire'=> array('Anniversaire', 'cake', 'le 14 mai'),
+ 'algerie' => array('Indépendance de l\'Algérie', 'crescent', 'du 2 au 8 juillet'),
+ 'japon' => array('Japon', 'torii', 'du 20 mars au 10 avril (hanami)'),
+ 'hack' => array('Hack', 'prompt', 'le 30 novembre (journée de la sécurité informatique)'),
+ 'paques' => array('Pâques', 'egg', 'du jeudi au lundi de Pâques'),
+ 'poisson' => array('Poisson d\'avril', 'fish', 'le 1er avril'),
+ 'musique' => array('Fête de la musique', 'note', 'du 19 au 21 juin'),
+ 'juillet' => array('14 juillet', 'burst', 'du 12 au 14 juillet'),
+ 'halloween' => array('Halloween', 'pumpkin', 'du 1er octobre au 2 novembre'),
+ 'black-friday'=> array('Black Friday', 'tag', 'du Black Friday au Cyber Monday'),
+ 'noel' => array('Noël', 'flake', 'du 1er au 27 décembre'),
+ );
+ $out = array();
+ foreach ($s as $k => $v) $out[$k] = array(
+ 'label' => $v[0], 'when' => $v[2],
+ 'off' => 'Couper le décor ' . $v[0], 'on' => 'Remettre le décor ' . $v[0],
+ 'icon' => '',
+ );
+ return $out;
+}
+
+/* Saison selon la date (heure de Paris). $ts : horodatage à tester (défaut : maintenant) */
+function yb2026_season_auto($ts = null)
+{
+ $ts = $ts ?? time();
+ $y = (int) wp_date('Y', $ts);
+ $md = (int) wp_date('nd', $ts); // ex. 1031 = 31 octobre, 106 = 6 janvier
+ $day = function ($m, $d, $yy) { return (int) wp_date('Ymd', mktime(12, 0, 0, $m, $d, $yy)); };
+ $today = (int) wp_date('Ymd', $ts);
+
+ /* Pâques (calcul grégorien de Meeus) : du jeudi saint au lundi de Pâques */
+ $a = $y % 19; $b = intdiv($y, 100); $c = $y % 100; $d = intdiv($b, 4); $e = $b % 4;
+ $f = intdiv($b + 8, 25); $g = intdiv($b - $f + 1, 3); $h = (19 * $a + $b - $d - $g + 15) % 30;
+ $i = intdiv($c, 4); $k = $c % 4; $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; $m = intdiv($a + 11 * $h + 22 * $l, 451);
+ $em = intdiv($h + $l - 7 * $m + 114, 31); $ed = (($h + $l - 7 * $m + 114) % 31) + 1;
+ /* Black Friday : lendemain du 4e jeudi de novembre, jusqu'au Cyber Monday */
+ $thu = 1 + ((4 - (int) date('w', mktime(12, 0, 0, 11, 1, $y)) + 7) % 7) + 21;
+
+ if ($md === 401) return 'poisson';
+ if ($today >= $day($em, $ed - 3, $y) && $today <= $day($em, $ed + 1, $y)) return 'paques';
+ if ($md === 1130) return 'hack'; // Computer Security Day, passe devant le Cyber Monday
+ if ($today >= $day(11, $thu + 1, $y) && $today <= $day(11, $thu + 4, $y)) return 'black-friday';
+ if ($md >= 1228 || $md <= 106) return 'nouvel-an';
+ if ($md >= 314 && $md <= 317) return 'st-patrick';
+ if ($md === 514) return 'anniversaire';
+ if ($md >= 702 && $md <= 708) return 'algerie'; // semaine autour du 5 juillet
+ if ($md >= 320 && $md <= 410) return 'japon'; // hanami (après poisson d'avril et Pâques, qui passent devant)
+ if ($md >= 619 && $md <= 621) return 'musique';
+ if ($md >= 712 && $md <= 714) return 'juillet';
+ if ($md >= 1001 && $md <= 1102) return 'halloween';
+ if ($md >= 1201 && $md <= 1227) return 'noel';
+ return '';
+}
+
+/* Saison active pour cette requête ('' = aucune) */
+/* Pages sobres, jamais décorées (comme les pages d'erreur Apache / NPM, qui ne connaissent pas la saison) */
+function yb2026_season_blocked() { return is_404(); }
+
+function yb2026_season()
+{
+ static $s = null;
+ if ($s !== null) return $s;
+ if (yb2026_season_blocked()) return $s = '';
+ $all = yb2026_seasons();
+ if (isset($_GET['saison'])) {
+ $q = sanitize_key($_GET['saison']);
+ return $s = isset($all[$q]) ? $q : '';
+ }
+ $mode = (string) get_option('yb_season', 'auto');
+ if ($mode === 'off') return $s = '';
+ return $s = isset($all[$mode]) ? $mode : yb2026_season_auto();
+}
+
+/* Quantité d'éléments animés par fête, en % (100 = normal). Option yb_season_density */
+function yb2026_season_density()
+{
+ $d = (array) get_option('yb_season_density', array());
+ $out = array();
+ foreach (array_keys(yb2026_seasons()) as $k) if (isset($d[$k])) $out[$k] = max(0, min(300, (int) $d[$k]));
+ return $out;
+}
+
+/* Fêtes qui ont un CV assorti (wp-content/uploads/cv/saisons//) */
+function yb2026_season_cvs()
+{
+ $out = array();
+ foreach (array_keys(yb2026_seasons()) as $k) if (file_exists(WP_CONTENT_DIR . '/uploads/cv/saisons/' . $k . '/CV_Yasser_Boulayoune.pdf')) $out[] = $k;
+ return $out;
+}
+
+/* Attribut de : fête active */
+function yb2026_season_attrs()
+{
+ if (yb2026_season_blocked()) return ' data-no-season';
+ $a = '';
+ if (yb2026_season()) $a .= ' data-season="' . esc_attr(yb2026_season()) . '"';
+ return $a;
+}
+
+add_action('wp_enqueue_scripts', function () {
+ if (yb2026_season_blocked()) return;
+ $dir = get_template_directory();
+ $uri = get_template_directory_uri();
+ wp_enqueue_style('yb-seasons', $uri . '/assets/css/seasons.css', array('yb-style'), filemtime($dir . '/assets/css/seasons.css'));
+ wp_enqueue_script('yb-seasons', $uri . '/assets/js/seasons.js', array('yb-effects'), filemtime($dir . '/assets/js/seasons.js'), true);
+ $list = array();
+ /* accent (mode sombre) de chaque fête : sert à teinter la main du curseur dans la roue */
+ $acc = array('nouvel-an' => '#f2c14e', 'st-patrick' => '#3ddc84', 'anniversaire' => '#2ee6c5', 'paques' => '#ff9fcb', 'poisson' => '#4fd1ff',
+ 'musique' => '#ff5cc8', 'juillet' => '#ff4a5a', 'halloween' => '#ff7b1c', 'black-friday' => '#ffd400', 'noel' => '#ff4a5a',
+ 'algerie' => '#2fbf71', 'japon' => '#ff5a3c', 'hack' => '#00ff66');
+ foreach (yb2026_seasons() as $k => $t) $list[$k] = array('label' => $t['label'], 'when' => $t['when'], 'icon' => $t['icon'], 'accent' => $acc[$k] ?? '');
+ wp_add_inline_script('yb-seasons', 'window.YB_SEASONS=' . wp_json_encode(array('list' => $list, 'auto' => yb2026_season_auto(), 'cv' => yb2026_season_cvs(), 'density' => (object) yb2026_season_density())) . ';', 'before');
+}, 20);
+
+/* Bouton de la barre (icône de la fête) : coupe / remet le décor */
+function yb2026_season_toggle()
+{
+ if (yb2026_season_blocked()) return;
+ $s = yb2026_season();
+ $t = $s ? yb2026_seasons()[$s] : array('label' => 'de fête', 'off' => 'Thème de fête', 'on' => 'Thème de fête', 'icon' => yb2026_seasons()['nouvel-an']['icon']);
+ printf(
+ '',
+ esc_attr($t['off']), esc_attr($t['on']), esc_attr('Décor ' . $t['label']), $t['icon']
+ );
+}
+
+/* ── Admin : Profil → Saisons ───────────────────────────────────────── */
+
+add_action('admin_menu', function () {
+ add_submenu_page('yb-profil', 'Saisons', 'Saisons', 'edit_pages', 'yb-saisons', 'yb2026_seasons_page');
+});
+
+function yb2026_seasons_page()
+{
+ $mode = (string) get_option('yb_season', 'auto');
+ $auto = yb2026_season_auto();
+ $all = yb2026_seasons();
+ $opts = array('auto' => 'Automatique' . ($auto ? ' (en ce moment : ' . $all[$auto]['label'] . ')' : ' (en ce moment : aucune fête)'));
+ foreach ($all as $k => $t) $opts[$k] = 'Toujours ' . $t['label'] . ' (en auto : ' . $t['when'] . ')';
+ $opts['off'] = 'Désactivé';
+ ?>
+
+
Thèmes saisonniers
+
Réglage enregistré.
+
Palette, décor animé et orbe de l'accueil changent selon la saison. Les visiteurs peuvent couper le décor avec le bouton de la barre de navigation.
+
+
+ $v) {
+ $k = sanitize_key($k);
+ if (isset(yb2026_seasons()[$k])) $dens[$k] = max(0, min(300, (int) $v));
+ }
+ update_option('yb_season_density', $dens, false);
+ update_option('yb_season', ($v === 'auto' || $v === 'off' || isset(yb2026_seasons()[$v])) ? $v : 'auto');
+ wp_safe_redirect(admin_url('admin.php?page=yb-saisons&updated=1'));
+ exit;
+});
diff --git a/page-competences.php b/page-competences.php
new file mode 100644
index 0000000..8e2b1a9
--- /dev/null
+++ b/page-competences.php
@@ -0,0 +1,173 @@
+publish;
+$nb_posts = (int) wp_count_posts('post')->publish;
+
+/* Boîte à outils : uniquement ce que prouvent les réalisations, articles et le poste */
+$toolbox = array(
+ array('t' => 'Développement web', 'i' => array('PHP 8', 'Symfony', 'Twig Components', 'Doctrine', 'WordPress sur mesure', 'PHP/MVC sans framework', 'JavaScript vanilla', 'HTML / CSS responsive')),
+ array('t' => 'Données & outillage', 'i' => array('MariaDB / MySQL', 'PostgreSQL', 'SQL', 'API HTTP / JSON', 'Python 3', 'Node.js', 'JS ESM', 'FFmpeg', 'librsvg')),
+ array('t' => 'Systèmes & conteneurs', 'i' => array('Debian 12', 'Docker / Compose', 'systemd', 'Apache', 'Nginx Proxy Manager', 'TLS Let\'s Encrypt', 'Réglage mémoire JVM')),
+ array('t' => 'Réseau & sécurité', 'i' => array('VLAN', 'DNS', 'DHCP', 'WireGuard', 'Pi-hole', 'Active Directory', 'Reverse proxy', 'Services privés par défaut')),
+ array('t' => 'Support & MCO', 'i' => array('Diagnostic d\'incidents', 'Lecture de logs', 'Accompagnement utilisateurs', 'Procédures documentées', 'Supervision Uptime Kuma', 'Sauvegardes planifiées')),
+ array('t' => 'Outils du quotidien', 'i' => array('Git', 'Forgejo / Gitea', 'GitHub', 'VS Code / code-server', 'Terminal & SSH')),
+);
+
+/* Méthode : tirée de mes retours d'expérience (OOM killer, faux bug, servyass) */
+$method = array(
+ array('t' => 'Observer', 'd' => 'Lire les journaux en entier, reproduire le symptôme, noter ce qui change et ce qui ne change pas.'),
+ array('t' => 'Isoler', 'd' => 'Réduire le périmètre : environnement, ressources, configuration, code. Vérifier aussi l\'outil de mesure.'),
+ array('t' => 'Corriger', 'd' => 'Formuler une hypothèse, la tester, appliquer le correctif le plus simple qui traite la vraie cause.'),
+ array('t' => 'Documenter', 'd' => 'Écrire la procédure, pour que la même panne se règle en cinq minutes la prochaine fois.'),
+);
+
+/* Preuves : gérées dans l'admin (Profil → Compétences), voir inc/proofs.php */
+$proofs = yb2026_get_proofs();
+
+$containers = array('npm', 'jellyfin', 'forgejo', 'code-server', 'wordpress + mariadb', 'passbolt + mariadb', 'poste.io', 'uptime-kuma', 'filebrowser · alist', 'wireguard');
+$infra_points = array(
+ array('t' => 'Pas un lab jetable', 'd' => 'De la prod perso avec de vrais utilisateurs, de vraies pannes et de vrais correctifs. Si Jellyfin tombe, on me le fait savoir.'),
+ array('t' => 'Incidents gérés', 'd' => 'OOM, conflits de ports, migration complète de matériel, watchdog réseau, certificats TLS : diagnostiqués et résolus seul.'),
+ array('t' => 'Méthode', 'd' => 'Supervision Uptime Kuma, sauvegardes planifiées, mises à jour de sécurité régulières, procédures documentées.'),
+);
+?>
+
+
+
+
+
+
+
CE QUE JE SAIS FAIRE
+
COMPÉTENCES
+
+ J'ai livré projets,
+ écrit articles techniques,
+ et je fais tourner 37 services en production
+ depuis 2 ans.
+
+
+
+
+
+ $sv) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
BOÎTE À OUTILS
+
CE QUE J'UTILISE
+
+ $tb) : ?>
+
+
+
+
+
+ $tool) : ?>✦
+
+
+
+
+
+
+
+
+
+
+
MA MÉTHODE
+
QUAND ÇA CASSE
+
+ $m) : ?>
+
+
+
+
+
+ $m) : ?>
+
+ /
+
+
+
+
+
+
+
+
+
+
$ ssh yass@servyass
+
L'INFRA SERVYASS · EN PROD DEPUIS 2 ANS
+
Je ne me contente pas de lister des compétences : 2 ans d'infrastructure Linux/Docker auto-hébergée, administrée seul, en production réelle. Debian 12 · Docker Engine · Ryzen 3 3200G · 13 GB RAM.
+
+
+
+
+ yass@servyass: docker ps
+
+
+
$ docker ps --format table · NAME · STATUS
+
+
Up (healthy)
+
+
$ uptime · ▲ en production depuis 2 ans, maintenu en autonomie
+
+
+
+
+
+
+
+
+
+
+
+
FLUX EN DIRECT · 16+ CONTENEURS · 1 SEUL ADMIN · SUPERVISION UPTIME-KUMA · BACKUPS PLANIFIÉS · MÀJ SÉCU HEBDO