printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit
This commit is contained in:
@@ -9,15 +9,16 @@ Two audiences:
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response, send_file
|
||||
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
@@ -50,7 +51,12 @@ def _require_service_token(scope):
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
token = authorized_service_token(scope)
|
||||
if token is not None:
|
||||
# Stash for the route so it can honor the token's resource
|
||||
# binding (token.resourcescopelist restricts which manifest
|
||||
# scopes + blobs this token may pull; None = unrestricted).
|
||||
g.geenforce_token = token
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
@@ -58,6 +64,14 @@ def _require_service_token(scope):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _token_resource_scopes():
|
||||
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||
token = getattr(g, 'geenforce_token', None)
|
||||
return token.resourcescopelist if token is not None else None
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
@@ -79,6 +93,11 @@ def get_manifest():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and scopename not in allowed:
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'token is not allowed this scope', http_code=403)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
@@ -101,6 +120,59 @@ def get_manifest():
|
||||
|
||||
# -- client payload download (share-less installer delivery) ------------------
|
||||
|
||||
# GET /payload hardening. This endpoint is reachable with only a read-only
|
||||
# geenforce.fetch token, so a leaked display token must not be able to pull
|
||||
# unbounded bytes or hammer it. Two bounds cap the blast radius:
|
||||
# - a per-IP fixed-window rate limit (same shape and cache extension as the
|
||||
# login limiter in shopdb.core.api.auth, so no new dependency), and
|
||||
# - a served-size ceiling: refuse to stream a blob larger than the cap.
|
||||
# Both are overridable via app config for a site that ships bigger installers.
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
||||
(mirrors shopdb.core.api.auth._login_ip)."""
|
||||
forwarded = request.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or 'unknown'
|
||||
|
||||
|
||||
def _payload_max_bytes():
|
||||
return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES)
|
||||
|
||||
|
||||
def _payload_download_ratelimited():
|
||||
"""Fixed-window per-IP limiter for the payload download endpoint.
|
||||
|
||||
Backed by the existing cache extension (no new dependency), same shape as
|
||||
the login limiter. Under the default SimpleCache the counter is per-process,
|
||||
so with N gunicorn workers the effective budget is N x the configured max; a
|
||||
shared cache backend (Redis/memcached) tightens it to a true global budget.
|
||||
Returns True when the caller is over budget for the current window.
|
||||
"""
|
||||
if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
|
||||
return False
|
||||
window = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
|
||||
maxhits = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
|
||||
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
|
||||
count = cache.get(key) or 0
|
||||
if count >= maxhits:
|
||||
return True
|
||||
cache.set(key, count + 1, timeout=window)
|
||||
return False
|
||||
|
||||
|
||||
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
|
||||
@require_fetch_token
|
||||
def get_payload(sha256):
|
||||
@@ -111,17 +183,40 @@ def get_payload(sha256):
|
||||
http payloads) first, then an inline DB payload with this hash. The client
|
||||
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
|
||||
hash (content is immutable).
|
||||
|
||||
Hardened: per-IP rate limited, and a blob over the served-size ceiling is
|
||||
refused (413) rather than streamed.
|
||||
"""
|
||||
if _payload_download_ratelimited():
|
||||
return error_response('RATE_LIMITED',
|
||||
'Too many payload downloads. Try again later.',
|
||||
http_code=429)
|
||||
|
||||
sha = (sha256 or '').strip().lower()
|
||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||
http_code=400)
|
||||
|
||||
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{sha}"'
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
|
||||
maxbytes = _payload_max_bytes()
|
||||
|
||||
blob = db.session.get(ManifestBlob, sha)
|
||||
if blob and os.path.isfile(service.blob_path(sha)):
|
||||
if blob.sizebytes is not None and blob.sizebytes > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
response = send_file(
|
||||
service.blob_path(sha),
|
||||
mimetype=blob.contenttype or 'application/octet-stream',
|
||||
@@ -131,6 +226,10 @@ def get_payload(sha256):
|
||||
|
||||
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
|
||||
if inline:
|
||||
if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
return Response(
|
||||
inline.payloadbytes,
|
||||
mimetype=inline.contenttype or 'application/octet-stream',
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.PARAMETER IncludeCommon
|
||||
Also fetch the fleet-wide CommonScope and merge it into -Scope, so the PC
|
||||
enforces its own scope entries PLUS common's (on a Name conflict the -Scope
|
||||
entry wins). OFF by default: a scope is enforced ALONE (self-sufficient).
|
||||
Displays are self-sufficient and do NOT set this. Only a share-less non-display
|
||||
PC that genuinely needs the fleet-wide common entries over HTTPS turns it on.
|
||||
|
||||
.PARAMETER CommonScope
|
||||
Name of the fleet-wide scope merged when -IncludeCommon is set (default
|
||||
'common'). Ignored without -IncludeCommon.
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
@@ -34,6 +45,8 @@ param(
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[switch]$IncludeCommon,
|
||||
[string]$CommonScope = 'common',
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
@@ -46,6 +59,27 @@ function Write-Log {
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
function Write-ShopdbEventLog {
|
||||
<#
|
||||
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
||||
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
||||
observable to whoever watches the display. Best-effort: registering the
|
||||
source needs admin, which the SYSTEM scheduled task has; any failure is
|
||||
swallowed so it can never break the fail-safe.
|
||||
#>
|
||||
param([string]$Message,
|
||||
[string]$EntryType = 'Warning',
|
||||
[int]$EventId = 1001)
|
||||
$source = 'ShopdbEnforce'
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
||||
-EventId $EventId -Message $Message -ErrorAction Stop
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
@@ -57,7 +91,21 @@ try {
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
||||
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
||||
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
||||
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
||||
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
||||
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
||||
try {
|
||||
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
||||
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
||||
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
||||
}
|
||||
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
||||
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
||||
}
|
||||
} catch {}
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
@@ -75,29 +123,50 @@ try {
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
if ($ShadowMode -and $ShareManifestPath) {
|
||||
# Shadow: install from the share exactly as today (no payload resolve).
|
||||
# Shadow: install from the share exactly as today (no payload resolve,
|
||||
# no common merge - the share already carries its own common scope).
|
||||
$manifestToRun = $ShareManifestPath
|
||||
} else {
|
||||
# Optional common-scope inheritance (OFF by default; displays are
|
||||
# self-sufficient). Only when -IncludeCommon is set do we fetch the
|
||||
# fleet-wide common scope (best-effort, same fail-safe cache) and merge
|
||||
# it in with the pctype winning on conflict. Skipped when this run IS
|
||||
# the common scope.
|
||||
$manifestToMerge = $sync.Path
|
||||
if ($IncludeCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
||||
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
||||
if ($commonSync.Path) {
|
||||
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
||||
if ($manifestToMerge -ne $sync.Path) {
|
||||
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
||||
}
|
||||
} else {
|
||||
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Cutover: stage any http/inline payloads to local files and rewrite the
|
||||
# manifest to point at them, so the UNCHANGED engine installs from local
|
||||
# (no SMB needed for share-less PCs).
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
|
||||
if ($manifestToRun -ne $sync.Path) {
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
||||
if ($manifestToRun -ne $manifestToMerge) {
|
||||
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
||||
}
|
||||
}
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
||||
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
||||
# Installed / Skipped / Failed / Filtered
|
||||
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
||||
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
||||
# The engine may not honor that yet: it might return $null, a bare return
|
||||
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
||||
# returns into a well-formed summary hashtable so the report stage always
|
||||
# gets clean input (we do NOT assume the engine was fixed).
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Set-ShopdbTls {
|
||||
<#
|
||||
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
|
||||
5.1 (what runs as SYSTEM on the display image) does not always negotiate
|
||||
TLS 1.2 by default, so every network helper calls this first. Mirrors the
|
||||
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
|
||||
#>
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||
}
|
||||
|
||||
function Get-ShopdbProperty {
|
||||
<#
|
||||
Read a property/key from either a hashtable or a PSCustomObject, matching
|
||||
any of the given names case-insensitively. Returns $null when absent
|
||||
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
|
||||
and summary may arrive as either shape, so all normalization goes through
|
||||
this.
|
||||
#>
|
||||
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
|
||||
if ($null -eq $InputObject) { return $null }
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($key in @($InputObject.Keys)) {
|
||||
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$properties = $InputObject.PSObject.Properties
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($property in $properties) {
|
||||
if ($property.Name -ieq $wanted) { return $property.Value }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-ShopdbConfig {
|
||||
param([string]$BaseUrl, [string]$ApiToken)
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
|
||||
[Parameter(Mandatory)] [hashtable]$Config,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
# Nothing cached (e.g. a fresh display): report why so the failure is not
|
||||
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
|
||||
# non-status transport error) and the exception message.
|
||||
$errText = $_.Exception.Message
|
||||
if ($status) { $errText = "HTTP $status - $errText" }
|
||||
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
|
||||
}
|
||||
# 304 without exception (some PS versions) -> use cache.
|
||||
if (Test-Path $manifestPath) {
|
||||
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
|
||||
#>
|
||||
param([Parameter(Mandatory)][hashtable]$Config,
|
||||
[Parameter(Mandatory)][hashtable]$Report)
|
||||
Set-ShopdbTls
|
||||
try {
|
||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
|
||||
function New-ShopdbReport {
|
||||
<#
|
||||
Build a report payload from an engine summary. `Summary` is expected to
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
|
||||
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
|
||||
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
|
||||
ExitCode/Message); the shopdb report contract is entirely lowercase, so
|
||||
this function maps every per-entry key down to lowercase. Case is matched
|
||||
case-insensitively, so a caller that already lowercased still works.
|
||||
#>
|
||||
param([string]$Hostname = $env:COMPUTERNAME,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$AppliedVersion,
|
||||
[Parameter(Mandatory)][hashtable]$Summary)
|
||||
$results = @(foreach ($entry in @($Summary.Results)) {
|
||||
if ($null -eq $entry) { continue }
|
||||
$mapped = @{
|
||||
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
|
||||
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
|
||||
}
|
||||
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
|
||||
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
|
||||
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
|
||||
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
|
||||
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
|
||||
if ($message) { $mapped['message'] = [string]$message }
|
||||
$mapped
|
||||
})
|
||||
return @{
|
||||
hostname = $Hostname
|
||||
scopename = $Scope
|
||||
@@ -156,7 +217,7 @@ function New-ShopdbReport {
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
results = $results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
|
||||
[string]$Filename,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
$sha = $Sha256.Trim().ToLower()
|
||||
$payloadDir = Join-Path $CacheDir 'payloads'
|
||||
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
|
||||
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
|
||||
return $out
|
||||
}
|
||||
|
||||
function Merge-ShopdbManifests {
|
||||
<#
|
||||
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
|
||||
manifest and write the merged result to a sibling file. This mirrors how
|
||||
the real GE-Enforce.ps1 applies scopes in order (common first, then the
|
||||
pctype), except we produce a single merged manifest for the unchanged
|
||||
engine to run once. Merge rules:
|
||||
|
||||
- Entries are keyed by Name (case-insensitive).
|
||||
- common's unique entries come first, then all pctype entries, so
|
||||
common's own apps enforce ahead of the pctype's, matching the real
|
||||
script's common-then-pctype ordering.
|
||||
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
|
||||
common's version, and keeps common's slot out of the list).
|
||||
|
||||
Returns the merged manifest path. If there is no common manifest, returns
|
||||
the primary path unchanged. The merged manifest keeps the pctype
|
||||
manifest's top-level Version (that is the version the display reports as
|
||||
applied).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PrimaryManifestPath,
|
||||
[string]$CommonManifestPath
|
||||
)
|
||||
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
|
||||
|
||||
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
|
||||
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
|
||||
|
||||
$primaryApps = @($primary.Applications)
|
||||
$commonApps = @($common.Applications)
|
||||
|
||||
$primaryNames = @{}
|
||||
foreach ($app in $primaryApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name) { $primaryNames[$name.ToLower()] = $true }
|
||||
}
|
||||
|
||||
# common entries the pctype does not override, then all pctype entries.
|
||||
$merged = @()
|
||||
foreach ($app in $commonApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
|
||||
$merged += $app
|
||||
}
|
||||
foreach ($app in $primaryApps) { $merged += $app }
|
||||
|
||||
$primary.Applications = $merged
|
||||
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
|
||||
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
|
||||
return $out
|
||||
}
|
||||
|
||||
function ConvertTo-ShopdbSummary {
|
||||
<#
|
||||
Normalize whatever the engine returns into a well-formed summary hashtable
|
||||
so New-ShopdbReport always has clean input, without assuming the engine was
|
||||
fixed.
|
||||
|
||||
Expected engine contract (what a compliant engine returns):
|
||||
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
|
||||
EnforcerVersion=<string>;
|
||||
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
|
||||
|
||||
This adapter tolerates any of:
|
||||
- $null / empty -> a zeroed summary.
|
||||
- a hashtable or PSCustomObject with those keys (any casing).
|
||||
- an array / multiple emitted objects -> the last element that looks
|
||||
like a summary (has any count or a Results list) is used.
|
||||
- a bare return code (int) or unrecognized object -> a zeroed summary.
|
||||
|
||||
Counts are coerced to int; a missing EnforcerVersion is left for the caller
|
||||
to default. Always returns a hashtable.
|
||||
#>
|
||||
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
|
||||
|
||||
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
|
||||
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$candidate = $EngineResult
|
||||
if ($candidate -is [System.Array]) {
|
||||
$picked = $null
|
||||
foreach ($item in $candidate) {
|
||||
if ($null -eq $item) { continue }
|
||||
$looksLikeSummary = $false
|
||||
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
|
||||
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
|
||||
}
|
||||
if ($looksLikeSummary) { $picked = $item }
|
||||
}
|
||||
$candidate = $picked
|
||||
}
|
||||
|
||||
if ($null -eq $candidate) { return $zero }
|
||||
# A bare return code (int) or any object without the expected members reads
|
||||
# as all-null through Get-ShopdbProperty below, which yields the zeroed
|
||||
# summary - exactly the fail-open behavior we want for a non-compliant engine.
|
||||
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
|
||||
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
|
||||
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$toInt = {
|
||||
param($value)
|
||||
$parsed = 0
|
||||
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
|
||||
return 0
|
||||
}
|
||||
|
||||
return @{
|
||||
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
|
||||
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
|
||||
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
|
||||
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
|
||||
Results = @($results)
|
||||
EnforcerVersion = $enforcerVersion
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
|
||||
ConvertTo-ShopdbSummary
|
||||
|
||||
264
plugins/geenforce/seed_display_scope.py
Normal file
264
plugins/geenforce/seed_display_scope.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Author the gea-shopfloor-display runtime scope programmatically.
|
||||
|
||||
Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their
|
||||
manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are
|
||||
BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it
|
||||
heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at
|
||||
the right target for the display subtype.
|
||||
|
||||
What this scope contains:
|
||||
- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||
relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display
|
||||
that loses those policies self-heals on the next enforce cycle without a
|
||||
keyboard or mouse on site).
|
||||
- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and
|
||||
launches the kiosk target for the subtype. The display-type -> target map is
|
||||
a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit.
|
||||
|
||||
Self-sufficient: the display scope carries EVERYTHING a display enforces and
|
||||
does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with
|
||||
common-merge off (the client default), so common's SMB-backed fleet entries
|
||||
never reach a share-less display. This keeps the display path simple and needs
|
||||
no common-payload repackaging.
|
||||
|
||||
Authoring path mirrors how every other scope is created: build a manifest dict
|
||||
and hand it to service.replace_scope_draft (the same call the import-share CLI
|
||||
uses), then attach the inline dispatcher payload and optionally publish. Re-
|
||||
running replace_scope_draft is an idempotent draft rebuild.
|
||||
"""
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from . import service
|
||||
|
||||
|
||||
SCOPE_NAME = 'gea-shopfloor-display'
|
||||
SCOPE_PHASE = 'runtime'
|
||||
SCOPE_VERSION = '2.0'
|
||||
|
||||
# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1
|
||||
# exactly so the imaging-time state and the enforced state never disagree.
|
||||
EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||
RELAUNCH_WINDOW_JSON = (
|
||||
'{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}')
|
||||
|
||||
# Data-driven display-type -> kiosk target map. The value of
|
||||
# C:\Enrollment\display-type.txt selects the row; the target is a route the
|
||||
# kiosk browser opens against the local kiosk base URL. Edit here to retarget a
|
||||
# subtype. Keys are matched case-insensitively by the dispatcher.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
|
||||
# before this scope is published to production displays.
|
||||
DISPLAY_TYPE_TARGETS = {
|
||||
'Dashboard': '/shopfloor',
|
||||
'Lobby': '/tv',
|
||||
'3DPrintRoom': '/parts-kiosk',
|
||||
}
|
||||
|
||||
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
||||
|
||||
|
||||
def _relaunch_window_targets_comment():
|
||||
"""Human note that lists the data-driven targets, for the manifest comment."""
|
||||
pairs = ', '.join(f'{name}={route}'
|
||||
for name, route in DISPLAY_TYPE_TARGETS.items())
|
||||
return pairs
|
||||
|
||||
|
||||
def build_dispatcher_script():
|
||||
"""Return the inline dispatcher PowerShell as text.
|
||||
|
||||
The display-type -> target map is emitted as a hashtable at the top of the
|
||||
script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the
|
||||
manifest metadata agree and both stay easy to edit.
|
||||
"""
|
||||
table_lines = []
|
||||
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||
table_lines.append(f" '{display_type}' = '{route}'")
|
||||
table_body = ';\n'.join(table_lines)
|
||||
|
||||
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
|
||||
#
|
||||
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the
|
||||
# route mapped for that display subtype. The kiosk browser is baked into the
|
||||
# display image; this script only points it at the right target.
|
||||
#
|
||||
# The DisplayTypeTargets table below is the single source of truth for the
|
||||
# subtype -> route map. Edit a row to retarget a subtype.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# --- Data-driven subtype -> kiosk route map ---
|
||||
$DisplayTypeTargets = @{{
|
||||
{table_body}
|
||||
}}
|
||||
|
||||
# Base URL the kiosk browser opens; the route from the table is appended. Edit
|
||||
# to point at this site's shopdb host. Kept here so the map above stays pure.
|
||||
$KioskBaseUrl = 'https://localhost'
|
||||
|
||||
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
||||
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
||||
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
|
||||
return
|
||||
}}
|
||||
|
||||
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
||||
Write-Host 'display-type.txt is empty; nothing to launch.'
|
||||
return
|
||||
}}
|
||||
|
||||
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
|
||||
$matchedKey = $DisplayTypeTargets.Keys |
|
||||
Where-Object {{ $_ -ieq $displayType }} |
|
||||
Select-Object -First 1
|
||||
if (-not $matchedKey) {{
|
||||
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
|
||||
return
|
||||
}}
|
||||
|
||||
$targetRoute = $DisplayTypeTargets[$matchedKey]
|
||||
$kioskUrl = "$KioskBaseUrl$targetRoute"
|
||||
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
|
||||
|
||||
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
|
||||
# so an enforce cycle does not relaunch the kiosk every run.
|
||||
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
|
||||
if ($alreadyRunning) {{
|
||||
Write-Host 'Kiosk already running for this target; leaving it in place.'
|
||||
return
|
||||
}}
|
||||
|
||||
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
|
||||
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
|
||||
Write-Host 'Launched kiosk browser.'
|
||||
"""
|
||||
|
||||
|
||||
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
|
||||
"""One Type=Registry entry that writes a value and detects drift via
|
||||
ValueMatches against that same path/name.
|
||||
|
||||
DetectionValue is stored as a string; the engine string-coerces for
|
||||
ValueMatches, so a DWord value of 2 detects against '2'.
|
||||
"""
|
||||
return {
|
||||
'_comment': comment,
|
||||
'Name': name,
|
||||
'Type': 'Registry',
|
||||
'RegPath': EDGE_POLICY_PATH,
|
||||
'RegName': regname,
|
||||
'RegValue': regvalue,
|
||||
'RegType': regtype,
|
||||
'DetectionMethod': 'ValueMatches',
|
||||
'DetectionPath': EDGE_POLICY_PATH,
|
||||
'DetectionName': regname,
|
||||
'DetectionValue': str(regvalue),
|
||||
}
|
||||
|
||||
|
||||
def build_display_manifest():
|
||||
"""Return the gea-shopfloor-display manifest dict (Applications in order).
|
||||
|
||||
Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher
|
||||
entry. The dispatcher is declared PayloadSource=inline with no hash yet; the
|
||||
seed attaches the real payload bytes (and its sha256) after the draft rows
|
||||
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
|
||||
into the display image, not shipped over HTTPS.
|
||||
"""
|
||||
applications = [
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
||||
'RelaunchNotification', 2, 'DWord',
|
||||
'RelaunchNotification=2 (Required): Edge auto-restarts after the '
|
||||
'notification period. Displays have no operator to dismiss the '
|
||||
'update dialog, so this is the only mode that recovers unattended. '
|
||||
'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotificationPeriod (1 hour)',
|
||||
'RelaunchNotificationPeriod', 3600000, 'DWord',
|
||||
'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchHeadsUpPeriod (1 minute)',
|
||||
'RelaunchHeadsUpPeriod', 60000, 'DWord',
|
||||
'Milliseconds of final warning before auto-restart. 60000 ms = 1 '
|
||||
'minute.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchWindow (02:00-04:00)',
|
||||
'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String',
|
||||
'Overnight forced-restart window (02:00 start, 120 minute '
|
||||
'duration) so business-hour updates wait until off-hours and the '
|
||||
'dialog stays invisible during the day.'),
|
||||
{
|
||||
'_comment': (
|
||||
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
|
||||
'launches the kiosk target for the subtype. Data-driven map: '
|
||||
+ _relaunch_window_targets_comment()
|
||||
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
|
||||
'TODO-confirm the real target. Delivered inline over HTTPS '
|
||||
'(share-less displays); DetectionMethod Always so it re-asserts '
|
||||
'each cycle, but the script is idempotent (skips if the kiosk is '
|
||||
'already serving the target URL).'),
|
||||
'Name': 'Display kiosk dispatcher (display-type.txt)',
|
||||
'Type': 'PS1',
|
||||
'Script': DISPATCHER_FILENAME,
|
||||
'PayloadSource': 'inline',
|
||||
'PayloadRef': DISPATCHER_FILENAME,
|
||||
'DetectionMethod': 'Always',
|
||||
},
|
||||
]
|
||||
return {
|
||||
'Version': SCOPE_VERSION,
|
||||
'_comment': (
|
||||
'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch '
|
||||
'policy drift and dispatches the kiosk to the subtype target. No '
|
||||
'EXE payloads: kiosk engine and browser are baked into the display '
|
||||
'image. Self-sufficient: displays do NOT inherit the common '
|
||||
'scope.'),
|
||||
'Applications': applications,
|
||||
}
|
||||
|
||||
|
||||
def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
|
||||
"""Create/refresh the gea-shopfloor-display draft scope and its entries.
|
||||
|
||||
Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and
|
||||
the inline dispatcher payload is content-addressed (a re-run stores the same
|
||||
bytes to the same sha256). Set publish=True to also freeze a published
|
||||
snapshot (that step is NOT idempotent: it always creates a new version).
|
||||
|
||||
Commits the session. Returns a summary dict:
|
||||
{scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}.
|
||||
"""
|
||||
manifest = build_display_manifest()
|
||||
scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest)
|
||||
# Flush so the new entries get entryids before the inline payload attaches.
|
||||
db.session.flush()
|
||||
|
||||
dispatcher = next(entry for entry in scope.entries
|
||||
if entry.name == 'Display kiosk dispatcher (display-type.txt)')
|
||||
scriptbytes = build_dispatcher_script().encode('utf-8')
|
||||
payload = service.store_inline_payload(
|
||||
dispatcher, DISPATCHER_FILENAME,
|
||||
'text/plain; charset=utf-8', scriptbytes)
|
||||
|
||||
publishedversion = None
|
||||
if publish:
|
||||
publishedversion = service.publish_scope(
|
||||
SCOPE_NAME, SCOPE_PHASE, notes=notes)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'entrycount': len(scope.entries),
|
||||
'entrytypes': [entry.entrytype for entry in scope.entries],
|
||||
'dispatchersha256': payload.payloadsha256,
|
||||
'publishedversion': publishedversion,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -288,6 +289,34 @@ def blob_path(sha256):
|
||||
return os.path.join(_payload_dir(), sha256)
|
||||
|
||||
|
||||
def blob_referenced_by_scopes(sha256, scopenames):
|
||||
"""True when any of these scopes' CURRENT published manifest references
|
||||
sha256 as an entry payload.
|
||||
|
||||
Backs the resource-bound token check on GET /payload: a token pinned to its
|
||||
own scope(s) may only pull blobs those scopes actually ship, not any blob by
|
||||
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
|
||||
"""
|
||||
if not scopenames:
|
||||
return False
|
||||
rows = db.session.query(ManifestPublishedVersion).join(
|
||||
ManifestScope,
|
||||
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
|
||||
).filter(
|
||||
ManifestScope.scopename.in_(list(scopenames)),
|
||||
ManifestPublishedVersion.iscurrent == True, # noqa: E712
|
||||
).all()
|
||||
for row in rows:
|
||||
try:
|
||||
doc = json.loads(row.manifestjson)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
for entry in doc.get('Applications', []) or []:
|
||||
if entry.get('PayloadSha256') == sha256:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def store_blob(rawbytes, filename, contenttype=None):
|
||||
"""Store bytes in the content-addressed payload store; return the sha256.
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ def _alert_team_webhook():
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
from shopdb.core.models import SupportTeam
|
||||
from shopdb.api import SupportTeam
|
||||
team = db.session.get(SupportTeam, int(team_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -20,6 +20,12 @@ export default [
|
||||
component: () => import('./views/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printer-alerts',
|
||||
name: 'printer-alerts',
|
||||
component: () => import('./views/PrinterAlertsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
|
||||
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Low-Toner Alerts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<p class="field-hint">
|
||||
A scheduled poll checks Zabbix toner levels. A warning email fires when
|
||||
a toner crosses to the warning threshold or below, a critical email at
|
||||
the critical threshold. Alerts fire once per crossing; a refill re-arms
|
||||
them.
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Warning threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_warning_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Critical threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_critical_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-toner alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-toner alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printers_alert_email" type="text"
|
||||
class="form-control" placeholder="print-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert support team (Teams webhook)</label>
|
||||
<select v-model="values.printers_alert_supportteamid" class="form-control">
|
||||
<option value="">Site default webhook</option>
|
||||
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
||||
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Low-toner alerts post to this team's webhook (set on Settings >
|
||||
Support Teams). Empty uses the site-wide alert webhook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printers_alert_email',
|
||||
'printers_alert_userids',
|
||||
'printers_alert_roleids',
|
||||
'printers_alert_supportteamid',
|
||||
'printers_alert_warning_threshold',
|
||||
'printers_alert_critical_threshold'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printers_alert_email: '',
|
||||
printers_alert_userids: '',
|
||||
printers_alert_roleids: '',
|
||||
printers_alert_supportteamid: '',
|
||||
printers_alert_warning_threshold: 5,
|
||||
printers_alert_critical_threshold: 0
|
||||
})
|
||||
const supportTeams = ref([])
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printers' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
values.value.printers_alert_warning_threshold =
|
||||
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
|
||||
values.value.printers_alert_critical_threshold =
|
||||
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printers_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printers_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
const teamsResponse = await supportteamsApi.list()
|
||||
supportTeams.value = teamsResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printers_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.form-row { display: flex; gap: 1rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
"""printers: printersupplyalerts table (toner alert crossing state).
|
||||
|
||||
Stores the last tier alerted per (printer, supply) so the poller fires once
|
||||
on a downward crossing and re-arms after a refill. Idempotent create.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0002supplyalerts'
|
||||
down_revision = 'printers0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
'printersupplyalerts',
|
||||
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
|
||||
sa.Column('printerid', sa.Integer(), nullable=False),
|
||||
sa.Column('supplykey', sa.String(length=64), nullable=False),
|
||||
sa.Column('lasttier', sa.String(length=16), nullable=False,
|
||||
server_default='ok'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(['printerid'], ['printers.printerid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
op.create_index('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' not in inspector.get_table_names():
|
||||
return
|
||||
op.drop_table('printersupplyalerts')
|
||||
@@ -8,12 +8,14 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||
SUPPLY_COLORS,
|
||||
CAPACITY_TIERS,
|
||||
)
|
||||
from .supply_alert import PrinterSupplyAlert # per-printer toner alert state
|
||||
|
||||
__all__ = [
|
||||
'Printer',
|
||||
'PrinterType',
|
||||
'PrinterDriver',
|
||||
'ModelSupply',
|
||||
'PrinterSupplyAlert',
|
||||
'SUPPLY_TYPES',
|
||||
'SUPPLY_COLORS',
|
||||
'CAPACITY_TIERS',
|
||||
|
||||
40
plugins/printers/models/supply_alert.py
Normal file
40
plugins/printers/models/supply_alert.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Per-printer supply alert state.
|
||||
|
||||
The supply poller (flask printers check-toner-alerts) is stateless: it reads
|
||||
live levels from Zabbix each run. To alert once per downward crossing (and
|
||||
re-arm after a refill) it needs to remember the last tier it alerted for each
|
||||
printer + supply. One row per (printerid, supplykey); supplykey is the toner
|
||||
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class PrinterSupplyAlert(BaseModel):
|
||||
"""Last-alerted tier for one printer supply. lasttier in ok/warning/critical."""
|
||||
|
||||
__tablename__ = 'printersupplyalerts'
|
||||
|
||||
printersupplyalertid = db.Column(db.Integer, primary_key=True)
|
||||
printerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printers.printerid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
supplykey = db.Column(
|
||||
db.String(64),
|
||||
nullable=False,
|
||||
comment='Toner color, or Zabbix item name when color is unknown',
|
||||
)
|
||||
lasttier = db.Column(
|
||||
db.String(16),
|
||||
nullable=False,
|
||||
default='ok',
|
||||
comment='Last tier alerted: ok, warning, or critical',
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
@@ -9,9 +9,11 @@ from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
from shopdb.api import db, AssetType, Setting
|
||||
|
||||
from .models import Printer, PrinterType, ModelSupply, PrinterDriver
|
||||
from .models import (
|
||||
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
||||
)
|
||||
from .api import printers_asset_bp
|
||||
from .services import ZabbixService
|
||||
|
||||
@@ -76,6 +78,7 @@ class PrintersPlugin(BasePlugin):
|
||||
PrinterType, # printer type classification
|
||||
PrinterDriver, # driver links (SMB/HTTP)
|
||||
ModelSupply, # model -> toner/drum/waste part numbers
|
||||
PrinterSupplyAlert, # per-printer toner alert crossing state
|
||||
]
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
@@ -103,8 +106,41 @@ class PrintersPlugin(BasePlugin):
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_printer_types()
|
||||
self._seed_settings()
|
||||
logger.info("Printers plugin installed")
|
||||
|
||||
def on_enable(self, app: Flask) -> None:
|
||||
# Idempotent re-seed so settings added in later versions reach sites
|
||||
# that installed earlier (enable runs on every upgrade cycle).
|
||||
with app.app_context():
|
||||
self._seed_settings()
|
||||
|
||||
def _seed_settings(self) -> None:
|
||||
"""Seed low-toner alert recipient settings (idempotent)."""
|
||||
defaults = [
|
||||
('printers_alert_email', '', 'string',
|
||||
'Comma-separated low-toner alert recipients; empty uses the '
|
||||
'site alert_recipients'),
|
||||
('printers_alert_userids', '', 'string',
|
||||
'Comma-separated shopdb user ids whose account emails receive '
|
||||
'low-toner alerts'),
|
||||
('printers_alert_roleids', '', 'string',
|
||||
'Comma-separated role ids; every active member of these roles '
|
||||
'receives low-toner alerts'),
|
||||
('printers_alert_supportteamid', '', 'string',
|
||||
'Support team whose webhook receives low-toner alerts; empty '
|
||||
'uses the site alert_webhook_url'),
|
||||
('printers_alert_warning_threshold', '5', 'integer',
|
||||
'Toner percent remaining at or below which a warning email fires'),
|
||||
('printers_alert_critical_threshold', '0', 'integer',
|
||||
'Toner percent remaining at or below which a critical email fires'),
|
||||
]
|
||||
for key, value, valuetype, description in defaults:
|
||||
if Setting.get(key) is None:
|
||||
Setting.set(key, value, valuetype=valuetype,
|
||||
category='printers', description=description)
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure printer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='printer').first()
|
||||
@@ -151,6 +187,19 @@ class PrintersPlugin(BasePlugin):
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Printers plugin uninstalled")
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'group': 'Printers',
|
||||
'to': '/settings/printer-alerts',
|
||||
'icon': 'bell',
|
||||
'title': 'Low-Toner Alerts',
|
||||
'description': 'Who gets warning (5%) and critical (0%) toner '
|
||||
'emails, and which support team webhook',
|
||||
'position': 48,
|
||||
},
|
||||
]
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@@ -159,6 +208,22 @@ class PrintersPlugin(BasePlugin):
|
||||
"""Printers plugin commands."""
|
||||
pass
|
||||
|
||||
@printerscli.command('check-toner-alerts')
|
||||
def checktoneralerts():
|
||||
"""Poll Zabbix for all printers and email/webhook low-toner crossings.
|
||||
|
||||
Run on a schedule (scheduled task / cron). Fires a warning at or
|
||||
below 5 percent and a critical at 0 percent, once per crossing."""
|
||||
from flask import current_app
|
||||
from .services import check_supplies
|
||||
|
||||
with current_app.app_context():
|
||||
summary = check_supplies()
|
||||
click.echo(
|
||||
f"Toner poll: {summary['polled']}/{summary['printers']} "
|
||||
f"printers reachable, {summary['alerts']} alert(s) sent, "
|
||||
f"{summary['rearmed']} re-armed.")
|
||||
|
||||
@printerscli.command('check-supplies')
|
||||
@click.argument('ip')
|
||||
def checksupplies(ip):
|
||||
|
||||
@@ -6,7 +6,9 @@ from .supply_parts import (
|
||||
derivesupplytype,
|
||||
derivecolor,
|
||||
lookupsupplies,
|
||||
alerttier,
|
||||
)
|
||||
from .supply_alerts import check_supplies
|
||||
from .seed_supplies import seedsupplies
|
||||
|
||||
__all__ = [
|
||||
@@ -15,5 +17,7 @@ __all__ = [
|
||||
'derivesupplytype',
|
||||
'derivecolor',
|
||||
'lookupsupplies',
|
||||
'alerttier',
|
||||
'check_supplies',
|
||||
'seedsupplies',
|
||||
]
|
||||
|
||||
190
plugins/printers/services/supply_alerts.py
Normal file
190
plugins/printers/services/supply_alerts.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Low-toner email/webhook alerting.
|
||||
|
||||
The report shows live levels; this fires an alert when a toner crosses a
|
||||
threshold on its way DOWN. A warning email fires at or below 5 percent, a
|
||||
critical email at 0 percent (empty). State lives in printersupplyalerts so an
|
||||
alert fires once per crossing and re-arms only after a refill.
|
||||
|
||||
Recipients mirror the printedparts pattern: a plugin-scoped set of shopdb
|
||||
users + roles + a free-text email list, falling back to the site-wide
|
||||
alert_recipients; the webhook routes to a chosen support team's webhook,
|
||||
falling back to the site-wide alert_webhook_url.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from shopdb.api import db, Setting
|
||||
|
||||
from ..models import Printer, PrinterSupplyAlert
|
||||
from .supply_parts import derivesupplytype, derivecolor, alerttier, TIER_RANK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def alert_recipients():
|
||||
"""Merge selected shopdb users' account emails with the free-text list.
|
||||
|
||||
Empty result means fall back to the site-wide alert_recipients."""
|
||||
from shopdb.api import User, Role
|
||||
recipients = []
|
||||
userids = (Setting.get('printers_alert_userids') or '').strip()
|
||||
for rawid in userids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
user = db.session.get(User, int(rawid))
|
||||
if user and user.isactive and user.email:
|
||||
recipients.append(user.email)
|
||||
roleids = (Setting.get('printers_alert_roleids') or '').strip()
|
||||
for rawid in roleids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
role = db.session.get(Role, int(rawid))
|
||||
if role:
|
||||
for user in role.users:
|
||||
if user.isactive and user.email:
|
||||
recipients.append(user.email)
|
||||
freetext = Setting.get('printers_alert_email') or ''
|
||||
for addr in freetext.replace(';', ',').split(','):
|
||||
addr = addr.strip()
|
||||
if addr:
|
||||
recipients.append(addr)
|
||||
# de-dupe, preserve order
|
||||
seen = set()
|
||||
unique = []
|
||||
for addr in recipients:
|
||||
low = addr.lower()
|
||||
if low not in seen:
|
||||
seen.add(low)
|
||||
unique.append(addr)
|
||||
return unique
|
||||
|
||||
|
||||
def _threshold(key, default):
|
||||
"""Read a numeric threshold setting, tolerating blank/garbage values."""
|
||||
try:
|
||||
raw = Setting.get(key)
|
||||
return float(raw) if raw not in (None, '') else float(default)
|
||||
except (ValueError, TypeError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def site_alert_recipients():
|
||||
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
|
||||
raw = Setting.get('alert_recipients') or ''
|
||||
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
|
||||
|
||||
|
||||
def alert_team_webhook():
|
||||
"""Webhook URL of the support team chosen for toner alerts, or None.
|
||||
|
||||
printers_alert_supportteamid selects a SupportTeam; alerts route to that
|
||||
team's webhook. None -> send_webhook uses the site-wide default."""
|
||||
team_id = Setting.get('printers_alert_supportteamid')
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
from shopdb.api import SupportTeam
|
||||
team = db.session.get(SupportTeam, int(team_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return (team.webhookurl or None) if team else None
|
||||
|
||||
|
||||
def _printer_ipaddress(printer):
|
||||
"""Primary (else any) communication IP for a printer's asset, or None."""
|
||||
from shopdb.api import Communication
|
||||
asset = printer.asset
|
||||
if not asset:
|
||||
return None
|
||||
comm = Communication.query.filter_by(
|
||||
assetid=asset.assetid, isprimary=True).first() \
|
||||
or Communication.query.filter_by(assetid=asset.assetid).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
|
||||
def _fire_alert(printer, color, remaining, tier):
|
||||
"""Best-effort email + webhook for one toner crossing. Never raises."""
|
||||
from shopdb.api import send_email, send_webhook
|
||||
name = printer.asset.name or printer.asset.assetnumber if printer.asset \
|
||||
else f'Printer {printer.printerid}'
|
||||
label = 'CRITICAL - empty' if tier == 'critical' else 'Low'
|
||||
subject = f'{label} toner: {name} ({color}) - {remaining:.0f}% remaining'
|
||||
base = (Setting.get('site_base_url') or '').rstrip('/')
|
||||
link = f'{base}/printers/{printer.printerid}' if base else ''
|
||||
link_html = f'<p><a href="{link}">View {name}</a></p>' if link else ''
|
||||
html = (f'<p><strong>{name}</strong> {color} toner is at '
|
||||
f'<strong>{remaining:.0f}%</strong> ({tier}).</p>{link_html}')
|
||||
webhook_text = f'{name} {color} toner at {remaining:.0f}% ({tier})'
|
||||
try:
|
||||
send_webhook(subject, webhook_text, url=alert_team_webhook())
|
||||
recipients = alert_recipients() or site_alert_recipients()
|
||||
if recipients:
|
||||
send_email(recipients, subject, html)
|
||||
except Exception:
|
||||
logger.exception('Toner alert failed for printer %s', printer.printerid)
|
||||
|
||||
|
||||
def check_supplies(service=None):
|
||||
"""Poll Zabbix for all active printers' toner and alert on crossings.
|
||||
|
||||
Returns a summary dict. Intended to run on a schedule (a scheduled task or
|
||||
cron calling `flask printers check-toner-alerts`)."""
|
||||
from shopdb.api import Asset
|
||||
from .zabbix_service import ZabbixService
|
||||
|
||||
service = service or ZabbixService()
|
||||
summary = {'printers': 0, 'polled': 0, 'alerts': 0, 'rearmed': 0}
|
||||
if not service.isconfigured:
|
||||
logger.warning('Zabbix not configured; skipping toner alert poll')
|
||||
return summary
|
||||
|
||||
warning_threshold = _threshold('printers_alert_warning_threshold', 5)
|
||||
critical_threshold = _threshold('printers_alert_critical_threshold', 0)
|
||||
|
||||
printers = db.session.query(Printer).join(Asset).filter(
|
||||
Asset.isactive == True).all() # noqa: E712
|
||||
for printer in printers:
|
||||
summary['printers'] += 1
|
||||
ipaddress = _printer_ipaddress(printer)
|
||||
if not ipaddress or ipaddress == 'USB':
|
||||
continue
|
||||
supplies = service.getsuppliesbyip(ipaddress)
|
||||
if not supplies:
|
||||
continue
|
||||
summary['polled'] += 1
|
||||
for supply in supplies:
|
||||
supplyname = supply.get('name', '')
|
||||
if derivesupplytype(supplyname) != 'toner':
|
||||
continue
|
||||
try:
|
||||
remaining = float(supply.get('level', 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
color = supply.get('color') or derivecolor(supplyname)
|
||||
supplykey = color if color and color != 'none' else supplyname
|
||||
supplykey = supplykey[:64]
|
||||
tier = alerttier(remaining, warning_threshold, critical_threshold)
|
||||
|
||||
row = PrinterSupplyAlert.query.filter_by(
|
||||
printerid=printer.printerid, supplykey=supplykey).first()
|
||||
prior = row.lasttier if row else 'ok'
|
||||
if tier == prior:
|
||||
continue
|
||||
if row is None:
|
||||
row = PrinterSupplyAlert(
|
||||
printerid=printer.printerid, supplykey=supplykey,
|
||||
lasttier=tier)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.lasttier = tier
|
||||
|
||||
if TIER_RANK[tier] > TIER_RANK[prior]:
|
||||
_fire_alert(printer, color, remaining, tier)
|
||||
summary['alerts'] += 1
|
||||
else:
|
||||
summary['rearmed'] += 1
|
||||
|
||||
db.session.commit()
|
||||
return summary
|
||||
@@ -10,10 +10,34 @@ and reading the matching part numbers out of the database.
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# alert thresholds (percent remaining)
|
||||
# display thresholds (percent remaining) for the ok/low/critical report badge
|
||||
CRITICAL_THRESHOLD = 5
|
||||
LOW_THRESHOLD = 10
|
||||
|
||||
# toner email-alert tiers (percent remaining). Distinct from the display
|
||||
# badge above: a warning email fires at or below TONER_WARNING_THRESHOLD, a
|
||||
# critical email at or below TONER_CRITICAL_THRESHOLD (empty).
|
||||
TONER_WARNING_THRESHOLD = 5
|
||||
TONER_CRITICAL_THRESHOLD = 0
|
||||
|
||||
|
||||
def alerttier(remaining: float, warning: float = TONER_WARNING_THRESHOLD,
|
||||
critical: float = TONER_CRITICAL_THRESHOLD) -> str:
|
||||
"""Map a toner percent-remaining to an email-alert tier.
|
||||
|
||||
critical at or below the critical threshold (default 0 = empty), warning at
|
||||
or below the warning threshold (default 5), else ok. Both are configurable
|
||||
(printers_alert_critical_threshold / printers_alert_warning_threshold)."""
|
||||
if remaining <= critical:
|
||||
return 'critical'
|
||||
if remaining <= warning:
|
||||
return 'warning'
|
||||
return 'ok'
|
||||
|
||||
|
||||
# tier severity rank; an alert fires only when the rank increases (worsens)
|
||||
TIER_RANK = {'ok': 0, 'warning': 1, 'critical': 2}
|
||||
|
||||
|
||||
def derivesupplytype(name: str) -> str:
|
||||
"""Map a Zabbix item name to a supply type."""
|
||||
|
||||
Reference in New Issue
Block a user