Compare commits
2 Commits
f32e74c713
...
89c37cf28d
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c37cf28d | |||
| 1813603bb3 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.deploy.example
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
terminals
|
||||||
|
**/*.log
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ dist-ssr
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.env.deploy
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Build front Vite (variables VITE_* et JIRA_DOMAIN figées au build)
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Origine Jira pour les liens « ouvrir le ticket » (__JIRA_ORIGIN__ dans vite.config.js)
|
||||||
|
ARG JIRA_DOMAIN=
|
||||||
|
ENV JIRA_DOMAIN=$JIRA_DOMAIN
|
||||||
|
|
||||||
|
# Obligatoire en prod : URL de ton proxy HTTPS vers Jira (même origine ou sous-chemin)
|
||||||
|
ARG VITE_JIRA_BASE_URL=
|
||||||
|
ENV VITE_JIRA_BASE_URL=$VITE_JIRA_BASE_URL
|
||||||
|
|
||||||
|
ARG VITE_JIRA_BROWSE_BASE_URL=
|
||||||
|
ENV VITE_JIRA_BROWSE_BASE_URL=$VITE_JIRA_BROWSE_BASE_URL
|
||||||
|
ARG VITE_JIRA_EPIC_KEY=
|
||||||
|
ENV VITE_JIRA_EPIC_KEY=$VITE_JIRA_EPIC_KEY
|
||||||
|
ARG VITE_JIRA_PAGE_SIZE=
|
||||||
|
ENV VITE_JIRA_PAGE_SIZE=$VITE_JIRA_PAGE_SIZE
|
||||||
|
ARG VITE_JIRA_BOARD_ID=
|
||||||
|
ENV VITE_JIRA_BOARD_ID=$VITE_JIRA_BOARD_ID
|
||||||
|
ARG VITE_JIRA_SPRINT_FIELD=
|
||||||
|
ENV VITE_JIRA_SPRINT_FIELD=$VITE_JIRA_SPRINT_FIELD
|
||||||
|
ARG VITE_JIRA_STORY_POINTS_FIELD=
|
||||||
|
ENV VITE_JIRA_STORY_POINTS_FIELD=$VITE_JIRA_STORY_POINTS_FIELD
|
||||||
|
ARG VITE_MY_JIRA_ACCOUNT_ID=
|
||||||
|
ENV VITE_MY_JIRA_ACCOUNT_ID=$VITE_MY_JIRA_ACCOUNT_ID
|
||||||
|
ARG VITE_MY_JIRA_EMAIL=
|
||||||
|
ENV VITE_MY_JIRA_EMAIL=$VITE_MY_JIRA_EMAIL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
31
deploy/hooks.json.example
Normal file
31
deploy/hooks.json.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "jira-descours-deploy",
|
||||||
|
"execute-command": "/config/deploy.sh",
|
||||||
|
"command-working-directory": "/",
|
||||||
|
"trigger-rule": {
|
||||||
|
"and": [
|
||||||
|
{
|
||||||
|
"match": {
|
||||||
|
"type": "value",
|
||||||
|
"value": "refs/heads/main",
|
||||||
|
"parameter": {
|
||||||
|
"source": "payload",
|
||||||
|
"name": "ref"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": {
|
||||||
|
"type": "value",
|
||||||
|
"value": "CHANGEME_SECRET_TOKEN",
|
||||||
|
"parameter": {
|
||||||
|
"source": "url",
|
||||||
|
"name": "token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
8
deploy/nas-deploy.sh.example
Normal file
8
deploy/nas-deploy.sh.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Copier vers le NAS (ex. /volume1/docker/jira-descours/deploy.sh), chmod +x,
|
||||||
|
# adapter APP_DIR et redémarrer le conteneur « webhook » qui l’invoque.
|
||||||
|
set -e
|
||||||
|
APP_DIR="/volume1/docker/jira-descours/src"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Déploiement NAS : variables dans un fichier `.env.deploy` à côté de ce fichier (non versionné).
|
||||||
|
# Voir docs/DEPLOY_SYNOLOGY_GITEA.md
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
JIRA_DOMAIN: ${JIRA_DOMAIN:-}
|
||||||
|
VITE_JIRA_BASE_URL: ${VITE_JIRA_BASE_URL:-}
|
||||||
|
VITE_JIRA_BROWSE_BASE_URL: ${VITE_JIRA_BROWSE_BASE_URL:-}
|
||||||
|
VITE_JIRA_EPIC_KEY: ${VITE_JIRA_EPIC_KEY:-}
|
||||||
|
VITE_JIRA_PAGE_SIZE: ${VITE_JIRA_PAGE_SIZE:-}
|
||||||
|
VITE_JIRA_BOARD_ID: ${VITE_JIRA_BOARD_ID:-}
|
||||||
|
VITE_JIRA_SPRINT_FIELD: ${VITE_JIRA_SPRINT_FIELD:-}
|
||||||
|
VITE_JIRA_STORY_POINTS_FIELD: ${VITE_JIRA_STORY_POINTS_FIELD:-}
|
||||||
|
VITE_MY_JIRA_ACCOUNT_ID: ${VITE_MY_JIRA_ACCOUNT_ID:-}
|
||||||
|
VITE_MY_JIRA_EMAIL: ${VITE_MY_JIRA_EMAIL:-}
|
||||||
|
image: jira-descours:local
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8080}:80"
|
||||||
|
restart: unless-stopped
|
||||||
18
docker/nginx.conf
Normal file
18
docker/nginx.conf
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
227
docs/DEPLOY_SYNOLOGY_GITEA.md
Normal file
227
docs/DEPLOY_SYNOLOGY_GITEA.md
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# Déploiement automatique : Gitea + Docker sur Synology NAS
|
||||||
|
|
||||||
|
Ce dépôt contient un **`Dockerfile`** et un **`docker-compose.yml`** pour servir le build Vite avec **Nginx**.
|
||||||
|
L’objectif : à chaque **push sur `main`**, le NAS **tire** le code et **reconstruit** le conteneur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue d’ensemble
|
||||||
|
|
||||||
|
| Étape | Rôle |
|
||||||
|
|--------|------|
|
||||||
|
| 1–3 | Cloner le dépôt sur le NAS, créer `.env.deploy` |
|
||||||
|
| 4 | Premier déploiement manuel (`docker compose up`) |
|
||||||
|
| 5–7 | Conteneur **webhook** qui exécute un script au push Gitea |
|
||||||
|
| 8 | Configurer le **webhook** dans Gitea (URL + secret en query) |
|
||||||
|
| 9–10 | **Proxy inverse** Synology + HTTPS pour l’accès extérieur |
|
||||||
|
| 11–12 | Vérifications et dépannage |
|
||||||
|
|
||||||
|
Chemins d’exemple : `/volume1/docker/jira-descours/` — à adapter à ton volume DSM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Préparer un dossier sur le NAS
|
||||||
|
|
||||||
|
En **SSH** (utilisateur admin ou compte avec droits `docker`) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /volume1/docker/jira-descours
|
||||||
|
cd /volume1/docker/jira-descours
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cloner le dépôt Gitea (une fois)
|
||||||
|
|
||||||
|
Utilise l’URL **SSH** ou **HTTPS** de ton Gitea.
|
||||||
|
|
||||||
|
**Dépôt privé (recommandé)** : créer une **clé SSH** sur le NAS, enregistrer la clé **publique** dans Gitea
|
||||||
|
(*Paramètres du dépôt → Clés de déploiement* ou compte utilisateur → Clés SSH).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /volume1/docker/jira-descours
|
||||||
|
git clone git@GITEA_HOST:UTILISATEUR/jira-descours.git src
|
||||||
|
cd src
|
||||||
|
```
|
||||||
|
|
||||||
|
Le dossier `src` contiendra le `docker-compose.yml` à la racine du dépôt après clone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fichier d’environnement de build `.env.deploy`
|
||||||
|
|
||||||
|
Sur le NAS, dans **`src/`** (racine du clone) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.deploy.example .env.deploy
|
||||||
|
nano .env.deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Renseigne au minimum :
|
||||||
|
|
||||||
|
- **`JIRA_DOMAIN`** — sous-domaine Atlassian (comme en dev).
|
||||||
|
- **`VITE_JIRA_BASE_URL`** — URL **HTTPS** de ton **proxy Jira** (obligatoire en prod : le navigateur n’utilise pas le proxy Vite du `npm run dev`).
|
||||||
|
|
||||||
|
Optionnel : `VITE_JIRA_*` comme dans `.env.example`.
|
||||||
|
|
||||||
|
**`HOST_PORT`** : port sur lequel le NAS écoute (ex. `8080`). Tu le brancheras ensuite sur le **proxy inverse** DSM.
|
||||||
|
|
||||||
|
Ne **commit pas** `.env.deploy` (déjà ignoré par `.gitignore`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Premier build manuel
|
||||||
|
|
||||||
|
Toujours dans `src/` :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --env-file .env.deploy up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste en local (LAN) : `http://IP_DU_NAS:8080` (ou le port choisi).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Script de déploiement appelé par le webhook
|
||||||
|
|
||||||
|
Toujours sur le NAS, crée un script **hors** du dépôt (pour ne pas l’écraser au `git pull`), par ex. :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo tee /volume1/docker/jira-descours/deploy.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
APP_DIR="/volume1/docker/jira-descours/src"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /volume1/docker/jira-descours/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapte **`APP_DIR`** si ton chemin diffère.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Fichier `hooks.json` pour [adnanh/webhook](https://github.com/adnanh/webhook)
|
||||||
|
|
||||||
|
Copie `deploy/hooks.json.example` vers le NAS, par ex. :
|
||||||
|
|
||||||
|
`/volume1/docker/jira-descours/hooks.json`
|
||||||
|
|
||||||
|
1. Remplace **`CHANGEME_SECRET_TOKEN`** par un **secret long** (génère-en un et garde-le pour Gitea).
|
||||||
|
2. Vérifie que **`refs/heads/main`** correspond à ta branche de prod (sinon change la valeur).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lancer le récepteur webhook (Docker)
|
||||||
|
|
||||||
|
Le conteneur webhook doit pouvoir exécuter **`docker`** : montage du socket Docker.
|
||||||
|
|
||||||
|
**Choisir une IP joignable depuis le conteneur Gitea** :
|
||||||
|
|
||||||
|
- Gitea **hors Docker** sur le NAS → souvent `http://127.0.0.1:PORT` depuis le NAS… mais le webhook doit écouter sur une interface que Gitea peut appeler.
|
||||||
|
- Gitea **dans Docker** sur le même hôte → souvent `http://172.17.0.1:9888` (bridge Docker vers l’hôte) ou **l’IP LAN du NAS** (`http://192.168.x.x:9888`).
|
||||||
|
|
||||||
|
Exemple (adapter chemins et tag d’image) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name gitea-deploy-hook --restart unless-stopped \
|
||||||
|
-p 127.0.0.1:9888:9000 \
|
||||||
|
-v /volume1/docker/jira-descours/hooks.json:/etc/webhook/hooks.json:ro \
|
||||||
|
-v /volume1/docker/jira-descours/deploy.sh:/config/deploy.sh:ro \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
ghcr.io/adnanh/webhook:2.8.2 \
|
||||||
|
-verbose -hooks=/etc/webhook/hooks.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`-p 127.0.0.1:9888:9000`** : le hook n’est pas exposé sur toute la carte réseau (plus sûr). Gitea sur le **même hôte** doit joindre `127.0.0.1:9888` **depuis l’hôte** ; si Gitea est **dans un autre conteneur**, utilise plutôt l’IP LAN du NAS et `-p 9888:9000` **avec pare-feu** ou réseau Docker partagé.
|
||||||
|
|
||||||
|
Vérifie la doc de ton image `webhook` pour le chemin des hooks (`-hooks=...`).
|
||||||
|
|
||||||
|
Test manuel (depuis le NAS) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "http://127.0.0.1:9888/hooks/jira-descours-deploy?token=CHANGEME_SECRET_TOKEN" \
|
||||||
|
-X POST -H "Content-Type: application/json" \
|
||||||
|
-d '{"ref":"refs/heads/main"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le hook doit s’exécuter (voir logs du conteneur `gitea-deploy-hook`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Webhook dans Gitea
|
||||||
|
|
||||||
|
1. Ouvre le dépôt → **Paramètres** → **Webhooks** → **Ajouter un webhook** → **Gitea**.
|
||||||
|
2. **URL cible** (exemple si hook sur le NAS, port 9888, secret en query) :
|
||||||
|
|
||||||
|
`http://IP_LAN_DU_NAS:9888/hooks/jira-descours-deploy?token=TON_SECRET_LONG`
|
||||||
|
|
||||||
|
Si Gitea et le hook sont sur le **même** OS Docker, teste d’abord avec l’IP LAN ; ajuste selon ce qui fonctionne chez toi.
|
||||||
|
|
||||||
|
3. **Déclencher sur** : « Push » (événements de push).
|
||||||
|
4. Branche : si l’UI le permet, limite à **`main`** ; sinon le filtre est déjà dans `hooks.json` (`ref`).
|
||||||
|
|
||||||
|
Enregistre, puis **« Test de livraison »** ou un **push** sur `main` : le site doit se reconstruire après quelques minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Accès depuis l’extérieur (HTTPS)
|
||||||
|
|
||||||
|
1. **Nom de domaine** ou **DDNS Synology** pointant vers l’IP publique de ta box.
|
||||||
|
2. **Box internet** : redirection **TCP 443** (et éventuellement **80**) vers l’**IP du NAS** (même ports ou ceux de ton reverse proxy).
|
||||||
|
3. Sur DSM : **Panneau de configuration** → **Portail de connexion** → **Proxy inverse** :
|
||||||
|
- **Nom d’hôte** : ex. `jira-descours.mondomaine.fr`
|
||||||
|
- **Destination** : `http://127.0.0.1:8080` (ou le `HOST_PORT` défini dans `.env.deploy`)
|
||||||
|
- **HTTPS** activé, certificat Let’s Encrypt pour ce nom d’hôte.
|
||||||
|
|
||||||
|
4. Accès : `https://jira-descours.mondomaine.fr`
|
||||||
|
|
||||||
|
**Alternative sans ouvrir les ports** : [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) sur le NAS (très courant en homelab).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sécurité (rappel court)
|
||||||
|
|
||||||
|
- Secret **long** dans l’URL du webhook ; ne le commite pas.
|
||||||
|
- Préférer le hook en **127.0.0.1** si Gitea appelle depuis la même machine ; sinon **pare-feu** DSM limitant le port du hook aux IP de confiance.
|
||||||
|
- Mettre à jour DSM, Gitea et les images Docker régulièrement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Check-list après un push
|
||||||
|
|
||||||
|
- Gitea → **Paramètres du dépôt** → **Webhooks** : dernière livraison en vert.
|
||||||
|
- Sur le NAS : `docker logs gitea-deploy-hook` puis `docker ps` : conteneur `web` à jour.
|
||||||
|
- Site public : hard refresh (Ctrl+F5) pour éviter le cache du navigateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Dépannage
|
||||||
|
|
||||||
|
| Problème | Piste |
|
||||||
|
|----------|--------|
|
||||||
|
| `git pull` échoue | Clé SSH / droits dépôt ; URL du `origin`. |
|
||||||
|
| Build Docker OOM | NAS peu de RAM : build sur une machine CI puis `docker pull` (évolution). |
|
||||||
|
| Page blanche / erreur Jira | `VITE_JIRA_BASE_URL` absent ou incorrect au **build** ; refaire `up --build` après correction de `.env.deploy`. |
|
||||||
|
| Webhook jamais reçu | URL depuis le conteneur Gitea (test `curl` depuis **dedans** le conteneur Gitea vers l’URL du hook). |
|
||||||
|
| 403 Let’s Encrypt | Ports 80/443 bien redirigés ; nom DNS correct. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers utiles dans ce dépôt
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `Dockerfile` | Build Node + image Nginx |
|
||||||
|
| `docker-compose.yml` | Service `web` + args de build |
|
||||||
|
| `docker/nginx.conf` | SPA `try_files` → `index.html` |
|
||||||
|
| `.env.deploy.example` | Modèle pour le NAS |
|
||||||
|
| `deploy/hooks.json.example` | Modèle webhook |
|
||||||
|
| `deploy/nas-deploy.sh.example` | Ancienne variante (script dans le dépôt) ; le guide privilégie `deploy.sh` **sur le NAS** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variante : Gitea Actions + runner sur le NAS
|
||||||
|
|
||||||
|
Si tu préfères tout voir dans l’UI Gitea : active **Actions**, installe **act_runner** sur le NAS, enregistre-le sur ton instance, puis ajoute un workflow `.gitea/workflows/deploy.yml` qui exécute les mêmes commandes que `deploy.sh`. C’est plus lourd à configurer (dont l’accès sécurisé à `docker.sock`) mais très propre à long terme.
|
||||||
@ -423,6 +423,7 @@ export default function App() {
|
|||||||
groups={groups}
|
groups={groups}
|
||||||
sprintFieldId={sprintFieldResolved}
|
sprintFieldId={sprintFieldResolved}
|
||||||
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
|
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
|
||||||
|
ganttNonWorkingDates={dashboardCfg.ganttNonWorkingDates}
|
||||||
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
|
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
GANTT_SPRINT_METRIC_OPTIONS,
|
GANTT_SPRINT_METRIC_OPTIONS,
|
||||||
mergeImportedConfig,
|
mergeImportedConfig,
|
||||||
normalizeFunctionalGapsForSave,
|
normalizeFunctionalGapsForSave,
|
||||||
|
parseGanttNonWorkingDatesFromText,
|
||||||
sanitizeExcludedSprintIds,
|
sanitizeExcludedSprintIds,
|
||||||
type DashboardConfig,
|
type DashboardConfig,
|
||||||
type FunctionalGapBadge,
|
type FunctionalGapBadge,
|
||||||
@ -108,11 +109,18 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
|
|||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const [draft, setDraft] = useState<DashboardConfig>(config)
|
const [draft, setDraft] = useState<DashboardConfig>(config)
|
||||||
|
const [ganttNonWorkInput, setGanttNonWorkInput] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setDraft(config)
|
if (open) setDraft(config)
|
||||||
}, [open, config])
|
}, [open, config])
|
||||||
|
|
||||||
|
const configNonWorkKey = config.ganttNonWorkingDates.join('|')
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setGanttNonWorkInput(config.ganttNonWorkingDates.join('\n'))
|
||||||
|
}, [open, configNonWorkKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = dialogRef.current
|
const el = dialogRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -146,8 +154,10 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(String(reader.result)) as unknown
|
const parsed = JSON.parse(String(reader.result)) as unknown
|
||||||
const merged = mergeImportedConfig(draft, parsed)
|
const merged = mergeImportedConfig(draft, parsed)
|
||||||
if (merged) setDraft(merged)
|
if (merged) {
|
||||||
else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
|
setDraft(merged)
|
||||||
|
setGanttNonWorkInput(merged.ganttNonWorkingDates.join('\n'))
|
||||||
|
} else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
|
||||||
} catch {
|
} catch {
|
||||||
alert('Impossible de lire ce fichier JSON.')
|
alert('Impossible de lire ce fichier JSON.')
|
||||||
}
|
}
|
||||||
@ -373,6 +383,21 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<label className="mt-3 block text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
|
||||||
|
Jours non travaillés (Gantt)
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
|
||||||
|
Une date par ligne au format <span className="font-mono text-slate-400">AAAA-MM-JJ</span> (fuseau
|
||||||
|
local du navigateur). Même style que sam. / dim. : fériés, ponts, fermeture.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'2026-05-01\n2026-05-08'}
|
||||||
|
className="mt-1.5 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-indigo-500/20 focus:ring-1"
|
||||||
|
value={ganttNonWorkInput}
|
||||||
|
onChange={(e) => setGanttNonWorkInput(e.target.value)}
|
||||||
|
/>
|
||||||
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
|
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
|
||||||
Sprints à masquer
|
Sprints à masquer
|
||||||
</p>
|
</p>
|
||||||
@ -649,6 +674,7 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
|
|||||||
...draft,
|
...draft,
|
||||||
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
|
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
|
||||||
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
|
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
|
||||||
|
ganttNonWorkingDates: parseGanttNonWorkingDatesFromText(ganttNonWorkInput),
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ type Props = {
|
|||||||
groups: StoryGroup[]
|
groups: StoryGroup[]
|
||||||
sprintFieldId: string | null
|
sprintFieldId: string | null
|
||||||
ganttSprintRowMetric: GanttSprintRowMetric
|
ganttSprintRowMetric: GanttSprintRowMetric
|
||||||
|
/** Dates yyyy-mm-dd (local) en plus des week-ends pour le fond du Gantt. */
|
||||||
|
ganttNonWorkingDates: string[]
|
||||||
onGanttSprintRowMetricChange: (m: GanttSprintRowMetric) => void
|
onGanttSprintRowMetricChange: (m: GanttSprintRowMetric) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
@ -105,7 +107,7 @@ function GanttTimelineBackdrop({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dayColumns
|
{dayColumns
|
||||||
.filter((c) => c.isWeekend)
|
.filter((c) => c.isGanttNonWork)
|
||||||
.map((c) => (
|
.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.dayStartMs}
|
key={c.dayStartMs}
|
||||||
@ -145,6 +147,7 @@ export function SprintGanttView({
|
|||||||
groups,
|
groups,
|
||||||
sprintFieldId,
|
sprintFieldId,
|
||||||
ganttSprintRowMetric,
|
ganttSprintRowMetric,
|
||||||
|
ganttNonWorkingDates,
|
||||||
onGanttSprintRowMetricChange,
|
onGanttSprintRowMetricChange,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -187,9 +190,10 @@ export function SprintGanttView({
|
|||||||
[startMs, endMs, ppd],
|
[startMs, endMs, ppd],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nonWorkingSet = useMemo(() => new Set(ganttNonWorkingDates), [ganttNonWorkingDates])
|
||||||
const dayColumns = useMemo(
|
const dayColumns = useMemo(
|
||||||
() => ganttDayColumns(startMs, endMs, widthPx),
|
() => ganttDayColumns(startMs, endMs, widthPx, nonWorkingSet),
|
||||||
[startMs, endMs, widthPx],
|
[startMs, endMs, widthPx, nonWorkingSet],
|
||||||
)
|
)
|
||||||
const monthBands = useMemo(
|
const monthBands = useMemo(
|
||||||
() => ganttMonthBands(startMs, endMs, widthPx),
|
() => ganttMonthBands(startMs, endMs, widthPx),
|
||||||
@ -271,8 +275,9 @@ export function SprintGanttView({
|
|||||||
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
|
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
|
||||||
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
|
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
|
||||||
timeline s’étire en pixels par jour — faites défiler horizontalement. En-tête : mois puis
|
timeline s’étire en pixels par jour — faites défiler horizontalement. En-tête : mois puis
|
||||||
jours / repères ; week-ends en fond plus sombre. Barre = charge (champ Sprint) ou
|
jours (sans répéter le mois) ; week-ends et jours configurés en fond plus sombre. Remplissage
|
||||||
avancement calendaire. Losanges = jalons (survol pour le détail).
|
des barres = % de sous-tâches terminées (champ Sprint). Losanges = jalons (survol pour le
|
||||||
|
détail).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -286,9 +291,10 @@ export function SprintGanttView({
|
|||||||
|
|
||||||
{!sprintFieldId && (
|
{!sprintFieldId && (
|
||||||
<p className="mb-3 rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-xs text-sky-100/90">
|
<p className="mb-3 rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-xs text-sky-100/90">
|
||||||
Sans champ Sprint, la barre reflète surtout l’
|
Sans champ Sprint, le <span className="font-medium text-sky-50">remplissage des barres reste à 0 %</span>{' '}
|
||||||
<span className="font-medium text-sky-50">avancement temporel</span>. Ajoutez{' '}
|
(aucune sous-tâche rattachée au sprint). Ajoutez{' '}
|
||||||
<code className="rounded bg-black/30 px-1 font-mono">customfield_…</code> pour la charge réelle.
|
<code className="rounded bg-black/30 px-1 font-mono">customfield_…</code> pour agréger les sous-tâches
|
||||||
|
par sprint.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -457,8 +463,10 @@ export function SprintGanttView({
|
|||||||
)
|
)
|
||||||
const barTitle =
|
const barTitle =
|
||||||
sprintFieldId && delivery.total > 0
|
sprintFieldId && delivery.total > 0
|
||||||
? `${s.name}\n${formatSprintRangeFr(s)}\nSous-tâches : ${delivery.done} / ${delivery.total} (${delivery.percent} %)\n${subtitleLines.join('\n')}`
|
? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : ${delivery.percent} % des sous-tâches terminées (${delivery.done} / ${delivery.total})\n${subtitleLines.join('\n')}`
|
||||||
: `${s.name}\n${formatSprintRangeFr(s)}\nAvancée calendaire : ${fill} %\n${subtitleLines.join('\n')}`
|
: sprintFieldId
|
||||||
|
? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — aucune sous-tâche (non annulée) dans ce sprint avec le champ Sprint.\n${subtitleLines.join('\n')}`
|
||||||
|
: `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — configurez le champ Sprint dans les réglages.\n${subtitleLines.join('\n')}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={s.id} className="contents">
|
<div key={s.id} className="contents">
|
||||||
|
|||||||
@ -149,6 +149,28 @@ export function sanitizeExcludedSprintIds(raw: unknown): number[] {
|
|||||||
return [...new Set(out)]
|
return [...new Set(out)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
/** Jours fériés / ponts (clé locale yyyy-mm-dd) — même fond atténué que les week-ends sur le Gantt. */
|
||||||
|
export function sanitizeGanttNonWorkingDates(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const x of raw) {
|
||||||
|
const s = typeof x === 'string' ? x.trim().slice(0, 10) : ''
|
||||||
|
if (ISO_DATE_RE.test(s)) out.push(s)
|
||||||
|
}
|
||||||
|
return [...new Set(out)].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse une zone de texte (lignes ou séparateurs virgule / point-virgule). */
|
||||||
|
export function parseGanttNonWorkingDatesFromText(raw: string): string[] {
|
||||||
|
const parts = raw
|
||||||
|
.split(/[\n,;\t]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return sanitizeGanttNonWorkingDates(parts)
|
||||||
|
}
|
||||||
|
|
||||||
export type DashboardConfig = {
|
export type DashboardConfig = {
|
||||||
version: 1
|
version: 1
|
||||||
milestones: Milestone[]
|
milestones: Milestone[]
|
||||||
@ -174,6 +196,8 @@ export type DashboardConfig = {
|
|||||||
excludedSprintIds: number[]
|
excludedSprintIds: number[]
|
||||||
/** Lignes d’info sous les barres de sprint (Gantt). */
|
/** Lignes d’info sous les barres de sprint (Gantt). */
|
||||||
ganttSprintRowMetric: GanttSprintRowMetric
|
ganttSprintRowMetric: GanttSprintRowMetric
|
||||||
|
/** Dates non travaillées (yyyy-mm-dd, fuseau local) — fond Gantt comme week-end. */
|
||||||
|
ganttNonWorkingDates: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
||||||
@ -189,6 +213,7 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
|
|||||||
functionalGaps: defaultFunctionalGaps(),
|
functionalGaps: defaultFunctionalGaps(),
|
||||||
excludedSprintIds: [],
|
excludedSprintIds: [],
|
||||||
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
|
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
|
||||||
|
ganttNonWorkingDates: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
|
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
|
||||||
@ -246,6 +271,7 @@ export function loadDashboardConfig(): DashboardConfig {
|
|||||||
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
|
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
|
||||||
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
|
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
|
||||||
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
|
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
|
||||||
|
ganttNonWorkingDates: sanitizeGanttNonWorkingDates(parsed.ganttNonWorkingDates),
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return defaultDashboardConfig()
|
return defaultDashboardConfig()
|
||||||
@ -329,5 +355,9 @@ export function mergeImportedConfig(
|
|||||||
o.ganttSprintRowMetric !== undefined
|
o.ganttSprintRowMetric !== undefined
|
||||||
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
|
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
|
||||||
: current.ganttSprintRowMetric,
|
: current.ganttSprintRowMetric,
|
||||||
|
ganttNonWorkingDates:
|
||||||
|
o.ganttNonWorkingDates !== undefined
|
||||||
|
? sanitizeGanttNonWorkingDates(o.ganttNonWorkingDates)
|
||||||
|
: current.ganttNonWorkingDates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,19 +200,33 @@ export function monthTicksBetween(startMs: number, endMs: number): { ms: number;
|
|||||||
return ticks
|
return ticks
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colonne calendaire (jour) projetée sur la timeline — week-ends, grille jour. */
|
/** Colonne calendaire (jour) projetée sur la timeline — week-ends, jours configurés, grille. */
|
||||||
export type GanttDayColumn = {
|
export type GanttDayColumn = {
|
||||||
dayStartMs: number
|
dayStartMs: number
|
||||||
weekday: number
|
weekday: number
|
||||||
x0: number
|
x0: number
|
||||||
x1: number
|
x1: number
|
||||||
isWeekend: boolean
|
/** Fond atténué : samedi / dimanche ou date listée dans les réglages Gantt. */
|
||||||
|
isGanttNonWork: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIsoDateKey(dayStartMs: number): string {
|
||||||
|
const d = new Date(dayStartMs)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Une entrée par jour local qui intersecte [startMs, endMs] (bornes en pixels pour le fond).
|
* Une entrée par jour local qui intersecte [startMs, endMs] (bornes en pixels pour le fond).
|
||||||
*/
|
*/
|
||||||
export function ganttDayColumns(startMs: number, endMs: number, widthPx: number): GanttDayColumn[] {
|
export function ganttDayColumns(
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
widthPx: number,
|
||||||
|
nonWorkingKeys?: ReadonlySet<string>,
|
||||||
|
): GanttDayColumn[] {
|
||||||
const cols: GanttDayColumn[] = []
|
const cols: GanttDayColumn[] = []
|
||||||
const iter = new Date(startMs)
|
const iter = new Date(startMs)
|
||||||
iter.setHours(0, 0, 0, 0)
|
iter.setHours(0, 0, 0, 0)
|
||||||
@ -229,12 +243,15 @@ export function ganttDayColumns(startMs: number, endMs: number, widthPx: number)
|
|||||||
const x0 = msToX(Math.max(dayStart, startMs), startMs, endMs, widthPx)
|
const x0 = msToX(Math.max(dayStart, startMs), startMs, endMs, widthPx)
|
||||||
const x1 = msToX(Math.min(dayEnd, endMs), startMs, endMs, widthPx)
|
const x1 = msToX(Math.min(dayEnd, endMs), startMs, endMs, widthPx)
|
||||||
if (x1 > x0 + 0.02) {
|
if (x1 > x0 + 0.02) {
|
||||||
|
const key = localIsoDateKey(dayStart)
|
||||||
|
const isWeekend = wd === 0 || wd === 6
|
||||||
|
const isConfigured = nonWorkingKeys?.has(key) ?? false
|
||||||
cols.push({
|
cols.push({
|
||||||
dayStartMs: dayStart,
|
dayStartMs: dayStart,
|
||||||
weekday: wd,
|
weekday: wd,
|
||||||
x0,
|
x0,
|
||||||
x1,
|
x1,
|
||||||
isWeekend: wd === 0 || wd === 6,
|
isGanttNonWork: isWeekend || isConfigured,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -308,10 +325,10 @@ export function ganttSubheaderTicks(
|
|||||||
for (let i = 0; i < cols.length; i += step) {
|
for (let i = 0; i < cols.length; i += step) {
|
||||||
const c = cols[i]!
|
const c = cols[i]!
|
||||||
const d = new Date(c.dayStartMs)
|
const d = new Date(c.dayStartMs)
|
||||||
|
const wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
|
||||||
|
const dom = d.getDate()
|
||||||
const label =
|
const label =
|
||||||
step === 1 && avgW >= 26
|
step === 1 && avgW >= 26 ? `${wdShort} ${dom}` : `${dom}`
|
||||||
? d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
|
||||||
: d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
|
||||||
const x = (c.x0 + c.x1) / 2
|
const x = (c.x0 + c.x1) / 2
|
||||||
const major = d.getDate() === 1 || d.getDay() === 1
|
const major = d.getDate() === 1 || d.getDay() === 1
|
||||||
out.push({ ms: c.dayStartMs, label, x, major })
|
out.push({ ms: c.dayStartMs, label, x, major })
|
||||||
@ -326,7 +343,8 @@ export function ganttSubheaderTicks(
|
|||||||
while (t <= endMs + MS_DAY && guard++ < 140) {
|
while (t <= endMs + MS_DAY && guard++ < 140) {
|
||||||
if (t + 6 * MS_DAY >= startMs) {
|
if (t + 6 * MS_DAY >= startMs) {
|
||||||
const d = new Date(t)
|
const d = new Date(t)
|
||||||
const label = d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
const wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
|
||||||
|
const label = `${wdShort} ${d.getDate()}`
|
||||||
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
|
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
|
||||||
out.push({ ms: t, label, x, major: true })
|
out.push({ ms: t, label, x, major: true })
|
||||||
}
|
}
|
||||||
@ -427,8 +445,8 @@ export function sprintTimeElapsedPercent(s: JiraSprintSnapshot, nowMs = Date.now
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remplissage de la barre : priorité au % sous-tâches terminées (périmètre + champ Sprint),
|
* Remplissage de la barre : uniquement le % de sous-tâches terminées (périmètre + champ Sprint).
|
||||||
* sinon avancement calendaire du sprint.
|
* Pas d’avancement calendaire : sans données, la barre reste à 0 %.
|
||||||
*/
|
*/
|
||||||
export function sprintBarFillPercent(
|
export function sprintBarFillPercent(
|
||||||
s: JiraSprintSnapshot,
|
s: JiraSprintSnapshot,
|
||||||
@ -437,8 +455,8 @@ export function sprintBarFillPercent(
|
|||||||
cfg: StatusBucketConfig,
|
cfg: StatusBucketConfig,
|
||||||
): number {
|
): number {
|
||||||
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
||||||
if (fieldId && delivery.total > 0) return Math.min(100, delivery.percent)
|
if (!fieldId || delivery.total === 0) return 0
|
||||||
return sprintTimeElapsedPercent(s)
|
return Math.min(100, delivery.percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function milestoneTooltipText(m: Milestone): string {
|
export function milestoneTooltipText(m: Milestone): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user