- contracts.js: built-in install chain fallback when no Flow Template matches on_contract_signed — every accepted contract now creates a master Issue + chained Dispatch Jobs (fiber_install template) so we never lose a signed contract to a missing flow config. - acceptance.js: export createDeferredJobs + propagate assigned_group into Dispatch Job payload (was only in notes, not queryable). - dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal, setJobStatusWithChain) + terminal-node detection that activates pending Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing convention: activation day is free, first period starts next day. - dispatch.js: fix Sales Invoice 417 by resolving company default income account (Ventes - T) and passing company + income_account on each item. - dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech self-assignment from the group queue; enriches with customer_name / service_location via per-job fetches since those fetch_from fields aren't queryable in list API. - TechTasksPage.vue: redesigned mobile-first UI with progress arc, status chips, and new "Tâches du groupe" section showing claimable unassigned jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked. - NetworkPage.vue + poller-control.js: poller toggle semantics flipped — green when enabled, red/gray when paused; explicit status chips for clarity. E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress → Completed walks chain → SUB-0000100002 activated (start=2026-04-24) → SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
109 lines
3.8 KiB
JavaScript
109 lines
3.8 KiB
JavaScript
'use strict'
|
|
// Poller pause control — test-phase friendly on/off switches for the two
|
|
// network pollers that run inside targo-hub:
|
|
//
|
|
// 1. device — GenieACS device-cache poll (~6000 ONTs every 5 min)
|
|
// 2. olt — SNMP sweep of the 4 OLTs (every 5 min)
|
|
//
|
|
// State is persisted to data/poller-control.json so pauses survive container
|
|
// restarts. Each poller's tick function first calls isPaused('x') and bails
|
|
// early (logging a one-liner so you can see pauses in the container log).
|
|
//
|
|
// HTTP surface:
|
|
// GET /admin/pollers → { device: {paused, ...}, olt: {...} }
|
|
// POST /admin/pollers { device, olt } → updates the flags (partial ok)
|
|
//
|
|
// Auth alignment: same tier as /olt/config + /olt/poll (unauthenticated at
|
|
// hub layer — the ops SPA that calls these is already behind Authentik SSO
|
|
// at the edge). Don't expose this without that front door in place.
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody } = require('./helpers')
|
|
|
|
const STATE_PATH = path.join(__dirname, '..', 'data', 'poller-control.json')
|
|
const DEFAULT_STATE = {
|
|
device: { paused: false, lastChange: null, reason: '' },
|
|
olt: { paused: false, lastChange: null, reason: '' },
|
|
}
|
|
|
|
let state = { ...DEFAULT_STATE }
|
|
|
|
function load () {
|
|
try {
|
|
if (fs.existsSync(STATE_PATH)) {
|
|
const raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'))
|
|
state = {
|
|
device: { ...DEFAULT_STATE.device, ...(raw.device || {}) },
|
|
olt: { ...DEFAULT_STATE.olt, ...(raw.olt || {}) },
|
|
}
|
|
if (state.device.paused || state.olt.paused) {
|
|
log(`[poller-control] loaded — device.paused=${state.device.paused} olt.paused=${state.olt.paused}`)
|
|
}
|
|
}
|
|
} catch (e) { log(`[poller-control] load failed: ${e.message} — using defaults`) }
|
|
}
|
|
|
|
function persist () {
|
|
try {
|
|
const dir = path.dirname(STATE_PATH)
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2))
|
|
} catch (e) { log(`[poller-control] persist failed: ${e.message}`) }
|
|
}
|
|
|
|
function isPaused (kind) {
|
|
return !!(state[kind] && state[kind].paused)
|
|
}
|
|
|
|
function setPaused (kind, paused, reason = '') {
|
|
if (!state[kind]) return false
|
|
const prev = state[kind].paused
|
|
state[kind].paused = !!paused
|
|
state[kind].lastChange = new Date().toISOString()
|
|
state[kind].reason = reason || ''
|
|
if (prev !== state[kind].paused) {
|
|
log(`[poller-control] ${kind} poller ${paused ? 'PAUSED' : 'RESUMED'}${reason ? ` — ${reason}` : ''}`)
|
|
}
|
|
persist()
|
|
return true
|
|
}
|
|
|
|
function getState () {
|
|
return JSON.parse(JSON.stringify(state))
|
|
}
|
|
|
|
// ── HTTP handler ────────────────────────────────────────────────────────────
|
|
|
|
async function handle (req, res, method, reqPath) {
|
|
if (reqPath === '/admin/pollers' && method === 'GET') {
|
|
return json(res, 200, getState())
|
|
}
|
|
|
|
if (reqPath === '/admin/pollers' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const updates = {}
|
|
for (const kind of ['device', 'olt']) {
|
|
if (body && typeof body[kind] === 'object' && body[kind] !== null) {
|
|
if (typeof body[kind].paused === 'boolean') {
|
|
setPaused(kind, body[kind].paused, body[kind].reason || '')
|
|
updates[kind] = true
|
|
}
|
|
} else if (body && typeof body[kind] === 'boolean') {
|
|
// shorthand: { device: true, olt: false }
|
|
setPaused(kind, body[kind])
|
|
updates[kind] = true
|
|
}
|
|
}
|
|
return json(res, 200, { ok: true, updates, state: getState() })
|
|
}
|
|
|
|
return json(res, 404, { error: 'not found' })
|
|
}
|
|
|
|
// initial load on require
|
|
load()
|
|
|
|
module.exports = { isPaused, setPaused, getState, handle, load }
|