feat: memory-only sessions on HTTP, streamed exports, UI refresh

Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 17:58:49 +02:00
co-authored by Claude Opus 5
parent ed41f7f520
commit b397d3dc3d
38 changed files with 3739 additions and 1014 deletions
+338 -160
View File
@@ -1,9 +1,11 @@
{% import "partials/icons.html" as ico %}
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ImpTune</title>
<title>{% block head_title %}ImpTune{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230d6f79'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' text-anchor='middle' fill='white'%3EiT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="stylesheet" href="/static/app.css">
<script>
@@ -11,7 +13,11 @@
// Theme store: cycles Light -> Dark -> System, persists in localStorage
Alpine.store('theme', {
current: localStorage.getItem('imptune_theme') || 'auto',
icons: { light: '&#9728;', dark: '&#9790;', auto: '&#9681;' },
icons: {
light: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/></svg>',
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
},
init() {
document.documentElement.setAttribute('data-theme', this.current);
},
@@ -23,6 +29,10 @@
}
});
// Client-side list filter. Lives in a store so rows swapped in by HTMX
// (partials/printer_list.html) find it no matter which page included them.
Alpine.store('filter', { q: '' });
// i18n store: FR/EN toggle, persists in localStorage
Alpine.store('i18n', {
lang: (() => {
@@ -46,49 +56,78 @@
packages: 'Paquets',
add_printer: 'Ajouter une imprimante',
add_client: 'Ajouter un client',
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
printer_library: 'Bibliothèque dimprimantes',
edit: 'Modifier',
delete: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
upload_driver: 'T\u00e9l\u00e9charger un pilote',
printer_name: 'Nom de l\u2019imprimante',
upload_driver: 'Importer un pilote',
printer_name: 'Nom de limprimante',
ip_address: 'Adresse IP',
port_name: 'Nom du port',
driver: 'Pilote',
duplex_mode: 'Mode recto-verso',
one_sided: 'Recto simple',
long_edge: 'Grand c\u00f4t\u00e9',
short_edge: 'Petit c\u00f4t\u00e9',
color_mode: 'Mode couleur',
long_edge: 'Grand côté',
short_edge: 'Petit côté',
color_mode: 'Couleur',
paper_size: 'Format papier',
collate: 'Assembler',
client: 'Client',
edit_printer: 'Modifier l\u2019imprimante',
no_printers: 'Aucune imprimante configur\u00e9e.',
no_clients: 'Aucun client configur\u00e9.',
edit_printer: 'Modifier limprimante',
no_printers: 'Aucune imprimante configurée.',
no_clients: 'Aucun client configuré.',
client_list: 'Liste des clients',
name: 'Nom',
created: 'Cr\u00e9\u00e9 le',
created: 'Créé le',
back_to_printers: 'Retour aux imprimantes',
theme_label: 'Th\u00e8me',
theme_label: 'Thème',
lang_label: 'FR',
// Dashboard page
new_printer: 'Nouvelle imprimante',
upload_driver_btn: 'T\u00e9l\u00e9charger un pilote',
export_package: 'Exporter un paquet',
recent_activity: 'Activit\u00e9 r\u00e9cente',
recent_printers: 'Imprimantes r\u00e9centes',
recent_packages: 'Paquets r\u00e9cents',
no_packages: 'Aucun paquet export\u00e9 pour l\u2019instant.',
// Printers new page
// Shell
brand_sub: 'Paquets dimpression',
nav_library: 'Bibliothèque',
nav_deploy: 'Déploiement',
session_menu: 'Cette session',
session_download_key: 'Télécharger ma clé de secours',
session_restore_key: 'Restaurer depuis une clé',
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
open_menu: 'Ouvrir le menu',
// Dashboard
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
step_driver_title: 'Importer le pilote',
step_driver_desc: 'Un ZIP contenant le .inf et ses fichiers. ImpTune lit le .inf et détecte les modèles.',
step_printer_title: 'Configurer limprimante',
step_printer_desc: 'Nom, adresse IP, pilote et réglages par défaut du poste.',
step_package_title: 'Exporter le paquet',
step_package_desc: '.intunewin pour Intune, ZIP pour NinjaRMM. Scripts inclus.',
step_done: 'Fait',
go_upload_driver: 'Importer un pilote',
go_add_printer: 'Ajouter une imprimante',
go_export: 'Voir les paquets',
view_all: 'Tout voir',
recent_activity: 'Activité récente',
recent_printers: 'Imprimantes récentes',
recent_packages: 'Prêtes à exporter',
no_packages: 'Aucune imprimante prête à exporter.',
ready_to_export: 'Prête',
needs_driver: 'Pilote manquant',
// Printers new
add_printer_title: 'Ajouter une imprimante',
back_to_printer_library: '\u2190 Retour \u00e0 la biblioth\u00e8que',
save_printer: 'Enregistrer l\u2019imprimante',
upload_new_driver: 'T\u00e9l\u00e9charger un nouveau pilote',
no_driver_option: '-- Aucun pilote --',
unassigned_option: '-- Non assign\u00e9 --',
// Printer list table headers
back_to_printer_library: 'Imprimantes',
save_printer: 'Enregistrer limprimante',
upload_new_driver: 'Importer un nouveau pilote',
no_driver_option: 'Aucun pilote',
unassigned_option: 'Non assigné',
section_identity: 'Identification',
section_connection: 'Connexion réseau',
section_driver: 'Pilote',
section_defaults: 'Réglages par défaut',
hint_port: 'Dérivé automatiquement de ladresse IP. Modifiable.',
hint_name: 'Le nom vu par lutilisateur sur son poste.',
hint_driver: 'Sans pilote, limprimante est enregistrée mais lexport reste bloqué.',
hint_ip: 'Adresse IPv4 de limprimante sur le réseau du client.',
// Printer list
th_name: 'Nom',
th_ip: 'Adresse IP',
th_port: 'Port',
@@ -98,37 +137,58 @@
th_paper: 'Format',
th_collate: 'Assemblage',
th_actions: 'Actions',
th_status: 'État',
th_config: 'Réglages',
th_export: 'Export',
th_printers: 'Imprimantes',
th_used_by: 'Utilisé par',
th_size: 'Taille',
yes: 'Oui',
no: 'Non',
no_driver_assigned: '\u2014',
// Clients page
no_driver_assigned: '',
filter_printers: 'Filtrer par nom, IP ou client',
no_match: 'Aucune imprimante ne correspond au filtre.',
printers_intro: 'Une imprimante prête a un pilote assigné : elle peut être exportée.',
// Clients
client_name_label: 'Nom du client',
add_client_section: 'Ajouter un client',
client_list_section: 'Liste des clients',
// Client detail page
back_to_clients: '\u2190 Tous les clients',
clients_intro: 'Les clients regroupent les imprimantes par site ou par organisation.',
open_client: 'Ouvrir',
// Client detail
back_to_clients: 'Clients',
printers_section: 'Imprimantes',
// Drivers page
// Drivers
drivers_title: 'Pilotes',
upload_driver_section: 'T\u00e9l\u00e9charger un package de pilote',
driver_library: 'Biblioth\u00e8que de pilotes',
uploading: 'T\u00e9l\u00e9chargement...',
upload_btn: 'T\u00e9l\u00e9charger',
driver_filename: 'Nom du fichier',
upload_driver_section: 'Importer un package de pilote',
driver_library: 'Bibliothèque de pilotes',
uploading: 'Import en cours…',
upload_btn: 'Importer',
driver_filename: 'Fichier',
driver_names_col: 'Nom(s) du pilote',
architecture: 'Architecture',
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
uploaded_at: 'Importé le',
unknown: 'Inconnu',
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
// Packages page
no_drivers: 'Aucun pilote importé.',
drivers_intro: 'Les pilotes sont partagés : tout le monde voit la même bibliothèque.',
driver_package_label: 'Package de pilote (ZIP)',
hint_driver_zip: 'Le ZIP doit contenir le fichier .inf et tous les fichiers auxquels il renvoie.',
unused_files: 'fichier(s) du ZIP ne sont pas référencés par le .inf.',
show_unused: 'Voir ces fichiers',
// Packages
packages_title: 'Paquets',
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
packages_description: 'Imprimantes avec un pilote assigné — prêtes pour lexport.',
printer_col: 'Imprimante',
client_col: 'Client',
driver_col: 'Pilote',
downloads_col: 'T\u00e9l\u00e9chargements',
no_packages_ready: 'Aucune imprimante pr\u00eate. Assignez un pilote pour activer l\u2019export.',
// Printer detail page
downloads_col: 'Téléchargements',
no_packages_ready: 'Aucune imprimante prête. Assignez un pilote pour activer lexport.',
format_intune: 'Intune',
format_ninja: 'NinjaRMM',
hint_intunewin: '.intunewin — à téléverser dans Intune comme application Win32.',
hint_ninja: 'ZIP — scripts en clair pour NinjaRMM ou exécution manuelle.',
pending_printers: 'imprimante(s) attendent encore un pilote',
// Printer detail
configuration: 'Configuration',
duplex_mode_label: 'Mode recto-verso',
color_mode_label: 'Mode couleur',
@@ -137,32 +197,41 @@
paper_size_label: 'Format papier',
collate_label: 'Assemblage',
client_label: 'Client',
unassigned: 'Non assign\u00e9',
unassigned: 'Non assigné',
driver_section: 'Pilote',
package_label: 'Package',
driver_names_label: 'Nom(s) du pilote',
architecture_label: 'Architecture',
no_driver_detail: 'Aucun pilote assign\u00e9',
no_driver_detail: 'Aucun pilote assigné.',
assign_driver_cta: 'Assigner un pilote',
intune_commands: 'Commandes Intune',
install_cmd_label: 'Commande d\u2019installation',
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
hint_intune_cmds: 'À coller dans les champs dinstallation et de désinstallation de lapplication Win32.',
install_cmd_label: 'Commande dinstallation',
uninstall_cmd_label: 'Commande de désinstallation',
copy: 'Copier',
copied: 'Copi\u00e9\u00a0!',
scripts_section: 'Scripts',
download_install: 'T\u00e9l\u00e9charger le script d\u2019installation',
download_uninstall: 'T\u00e9l\u00e9charger le script de d\u00e9sinstallation',
download_detect: 'T\u00e9l\u00e9charger le script de d\u00e9tection',
export_section: 'Export',
download_ninja: 'T\u00e9l\u00e9charger NinjaRMM ZIP',
download_intunewin: 'T\u00e9l\u00e9charger .intunewin',
icon_section: 'Ic\u00f4ne',
icon_uploaded: 'Ic\u00f4ne t\u00e9l\u00e9charg\u00e9e',
upload_icon: 'T\u00e9l\u00e9charger l\u2019ic\u00f4ne',
copied: 'Copié',
scripts_section: 'Scripts PowerShell',
download_install: 'install.ps1',
download_uninstall: 'uninstall.ps1',
download_detect: 'detect.ps1',
export_section: 'Déployer',
download_ninja: 'Télécharger le ZIP NinjaRMM',
download_intunewin: 'Télécharger le .intunewin',
icon_section: 'Icône',
icon_uploaded: 'Icône enregistrée',
upload_icon: 'Téléverser licône',
replace_icon: 'Remplacer licône',
no_icon: 'Aucune icône. Intune affichera licône par défaut.',
hint_icon: 'PNG, exactement 256 × 256, 750 Ko maximum.',
back_to_printers_btn: 'Retour aux imprimantes',
export_locked: 'Assignez un pilote à cette imprimante pour débloquer les scripts et lexport.',
// Edit modal
edit_printer_title: 'Modifier l\u2019imprimante',
// Driver upload section
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
edit_printer_title: 'Modifier limprimante',
// Session restore
restore_title: 'Restaurer une clé de secours',
restore_desc: 'Collez la clé du fichier imptune-backup-key.txt pour retrouver vos imprimantes, réglages et clients dans ce navigateur.',
backup_key_label: 'Clé de secours',
restore_btn: 'Restaurer'
},
en: {
dashboard: 'Dashboard',
@@ -170,27 +239,27 @@
printers: 'Printers',
clients: 'Clients',
packages: 'Packages',
add_printer: 'Add Printer',
add_client: 'Add Client',
add_printer: 'Add printer',
add_client: 'Add client',
printer_library: 'Printer Library',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
upload_driver: 'Upload Driver',
printer_name: 'Printer Name',
ip_address: 'IP Address',
port_name: 'Port Name',
upload_driver: 'Add driver',
printer_name: 'Printer name',
ip_address: 'IP address',
port_name: 'Port name',
driver: 'Driver',
duplex_mode: 'Duplex Mode',
one_sided: 'One-Sided',
long_edge: 'Long Edge',
short_edge: 'Short Edge',
color_mode: 'Color Mode',
paper_size: 'Paper Size',
duplex_mode: 'Duplex mode',
one_sided: 'One-sided',
long_edge: 'Long edge',
short_edge: 'Short edge',
color_mode: 'Color',
paper_size: 'Paper size',
collate: 'Collate',
client: 'Client',
edit_printer: 'Edit Printer',
edit_printer: 'Edit printer',
no_printers: 'No printers configured yet.',
no_clients: 'No clients configured yet.',
client_list: 'Client List',
@@ -199,24 +268,53 @@
back_to_printers: 'Back to Printers',
theme_label: 'Theme',
lang_label: 'EN',
// Dashboard page
new_printer: 'New Printer',
upload_driver_btn: 'Upload Driver',
export_package: 'Export Package',
recent_activity: 'Recent Activity',
recent_printers: 'Recent Printers',
recent_packages: 'Recent Packages',
no_packages: 'No packages exported yet.',
// Printers new page
add_printer_title: 'Add Printer',
back_to_printer_library: '\u2190 Back to Printer Library',
save_printer: 'Save Printer',
upload_new_driver: 'Upload New Driver',
no_driver_option: '-- No driver --',
unassigned_option: '-- Unassigned --',
// Printer list table headers
// Shell
brand_sub: 'Print packaging',
nav_library: 'Library',
nav_deploy: 'Deploy',
session_menu: 'This session',
session_download_key: 'Download my backup key',
session_restore_key: 'Restore from a key',
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
open_menu: 'Open menu',
// Dashboard
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
step_driver_title: 'Add the driver',
step_driver_desc: 'A ZIP holding the .inf and its files. ImpTune reads the .inf and finds the models.',
step_printer_title: 'Configure the printer',
step_printer_desc: 'Name, IP address, driver, and the defaults the workstation gets.',
step_package_title: 'Export the package',
step_package_desc: '.intunewin for Intune, ZIP for NinjaRMM. Scripts included.',
step_done: 'Done',
go_upload_driver: 'Add a driver',
go_add_printer: 'Add a printer',
go_export: 'View packages',
view_all: 'View all',
recent_activity: 'Recent activity',
recent_printers: 'Recent printers',
recent_packages: 'Ready to export',
no_packages: 'No printers are ready to export yet.',
ready_to_export: 'Ready',
needs_driver: 'Driver needed',
// Printers new
add_printer_title: 'Add printer',
back_to_printer_library: 'Printers',
save_printer: 'Save printer',
upload_new_driver: 'Add a new driver',
no_driver_option: 'No driver',
unassigned_option: 'Unassigned',
section_identity: 'Identity',
section_connection: 'Network connection',
section_driver: 'Driver',
section_defaults: 'Print defaults',
hint_port: 'Derived from the IP address. Edit it if your naming differs.',
hint_name: 'The name the user sees on their workstation.',
hint_driver: 'Without a driver the printer is saved, but export stays locked.',
hint_ip: 'The printer IPv4 address on the client network.',
// Printer list
th_name: 'Name',
th_ip: 'IP Address',
th_ip: 'IP address',
th_port: 'Port',
th_driver: 'Driver',
th_duplex: 'Duplex',
@@ -224,71 +322,101 @@
th_paper: 'Paper',
th_collate: 'Collate',
th_actions: 'Actions',
th_status: 'Status',
th_config: 'Defaults',
th_export: 'Export',
th_printers: 'Printers',
th_used_by: 'Used by',
th_size: 'Size',
yes: 'Yes',
no: 'No',
no_driver_assigned: '\u2014',
// Clients page
client_name_label: 'Client Name',
add_client_section: 'Add Client',
client_list_section: 'Client List',
// Client detail page
back_to_clients: '\u2190 All Clients',
no_driver_assigned: '',
filter_printers: 'Filter by name, IP, or client',
no_match: 'No printer matches this filter.',
printers_intro: 'A printer with a driver assigned is ready to export.',
// Clients
client_name_label: 'Client name',
add_client_section: 'Add client',
client_list_section: 'Client list',
clients_intro: 'Clients group printers by site or organization.',
open_client: 'Open',
// Client detail
back_to_clients: 'Clients',
printers_section: 'Printers',
// Drivers page
// Drivers
drivers_title: 'Drivers',
upload_driver_section: 'Upload Driver Package',
driver_library: 'Driver Library',
uploading: 'Uploading...',
upload_driver_section: 'Add a driver package',
driver_library: 'Driver library',
uploading: 'Uploading',
upload_btn: 'Upload',
driver_filename: 'Filename',
driver_names_col: 'Driver Name(s)',
driver_filename: 'File',
driver_names_col: 'Driver name(s)',
architecture: 'Architecture',
uploaded_at: 'Uploaded',
uploaded_at: 'Added',
unknown: 'Unknown',
no_drivers: 'No drivers uploaded yet.',
// Packages page
drivers_intro: 'Drivers are shared — everyone sees the same library.',
driver_package_label: 'Driver package (ZIP)',
hint_driver_zip: 'The ZIP must contain the .inf file and every file it references.',
unused_files: 'file(s) in the ZIP are unused — the .inf never references them.',
show_unused: 'Show these files',
// Packages
packages_title: 'Packages',
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package export.',
packages_description: 'Printers with a driver assigned ready for export.',
printer_col: 'Printer',
client_col: 'Client',
driver_col: 'Driver',
downloads_col: 'Downloads',
no_packages_ready: 'No package-ready printers yet. Assign a driver to a printer to enable package export.',
// Printer detail page
no_packages_ready: 'No printer is ready yet. Assign a driver to enable export.',
format_intune: 'Intune',
format_ninja: 'NinjaRMM',
hint_intunewin: '.intunewin — upload to Intune as a Win32 app.',
hint_ninja: 'ZIP — plain scripts for NinjaRMM or a manual run.',
pending_printers: 'printer(s) still need a driver',
// Printer detail
configuration: 'Configuration',
duplex_mode_label: 'Duplex Mode',
color_mode_label: 'Color Mode',
duplex_mode_label: 'Duplex mode',
color_mode_label: 'Color mode',
color_value: 'Color',
grayscale_value: 'Grayscale',
paper_size_label: 'Paper Size',
paper_size_label: 'Paper size',
collate_label: 'Collate',
client_label: 'Client',
unassigned: 'Unassigned',
driver_section: 'Driver',
package_label: 'Package',
driver_names_label: 'Driver Name(s)',
driver_names_label: 'Driver name(s)',
architecture_label: 'Architecture',
no_driver_detail: 'No driver assigned',
intune_commands: 'Intune Commands',
no_driver_detail: 'No driver assigned.',
assign_driver_cta: 'Assign a driver',
intune_commands: 'Intune commands',
hint_intune_cmds: 'Paste these into the Win32 app install and uninstall fields.',
install_cmd_label: 'Install command',
uninstall_cmd_label: 'Uninstall command',
copy: 'Copy',
copied: 'Copied!',
scripts_section: 'Scripts',
download_install: 'Download Install Script',
download_uninstall: 'Download Uninstall Script',
download_detect: 'Download Detect Script',
export_section: 'Export',
copied: 'Copied',
scripts_section: 'PowerShell scripts',
download_install: 'install.ps1',
download_uninstall: 'uninstall.ps1',
download_detect: 'detect.ps1',
export_section: 'Deploy',
download_ninja: 'Download NinjaRMM ZIP',
download_intunewin: 'Download .intunewin',
icon_section: 'Icon',
icon_uploaded: 'Icon uploaded',
upload_icon: 'Upload Icon',
back_to_printers_btn: 'Back to Printers',
icon_uploaded: 'Icon saved',
upload_icon: 'Upload icon',
replace_icon: 'Replace icon',
no_icon: 'No icon. Intune will show its default.',
hint_icon: 'PNG, exactly 256 × 256, 750 KB max.',
back_to_printers_btn: 'Back to printers',
export_locked: 'Assign a driver to this printer to unlock scripts and export.',
// Edit modal
edit_printer_title: 'Edit Printer',
// Driver upload section
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
edit_printer_title: 'Edit printer',
// Session restore
restore_title: 'Restore a backup key',
restore_desc: 'Paste the key from imptune-backup-key.txt to get your printers, settings, and clients back in this browser.',
backup_key_label: 'Backup key',
restore_btn: 'Restore'
}
}
});
@@ -297,57 +425,107 @@
<script defer src="/static/alpine.min.js"></script>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<body x-data="{ nav: false }" :class="nav && 'nav-open'">
<div class="layout">
<nav class="sidebar">
<div class="sidebar-brand">
<strong>ImpTune</strong>
<div class="nav-scrim" x-show="nav" @click="nav = false" style="display:none"></div>
<nav class="sidebar" @click="nav = false">
<a class="brand" href="/">
<span class="brand-mark">iT</span>
<span>
<span class="brand-name">ImpTune</span>
<span class="brand-sub" x-data x-text="$store.i18n.t('brand_sub')">Print packaging</span>
</span>
</a>
<div class="nav-group">
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>{{ ico.i('gauge') }}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span></a></li>
</ul>
</div>
<div class="nav-group">
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_library')">Library</div>
<ul class="sidebar-nav">
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>{{ ico.i('driver') }}<span x-data x-text="$store.i18n.t('drivers')">Drivers</span></a></li>
<li><a href="/printers" {% if request.url.path.startswith("/printers") %}class="active"{% endif %}>{{ ico.i('printer') }}<span x-data x-text="$store.i18n.t('printers')">Printers</span></a></li>
<li><a href="/clients" {% if request.url.path.startswith("/clients") %}class="active"{% endif %}>{{ ico.i('group') }}<span x-data x-text="$store.i18n.t('clients')">Clients</span></a></li>
</ul>
</div>
<div class="nav-group">
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_deploy')">Deploy</div>
<ul class="sidebar-nav">
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>{{ ico.i('package') }}<span x-data x-text="$store.i18n.t('packages')">Packages</span></a></li>
</ul>
</div>
<div class="sidebar-foot">
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
<div class="foot-links">
<a href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
<a href="/session/restore" x-data x-text="$store.i18n.t('session_restore_key')">Restore from a key</a>
<p class="foot-note" x-data x-text="$store.i18n.t('session_note')">Your printers live in this browser.</p>
</div>
</details>
</div>
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('drivers')">Drivers</a></li>
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('printers')">Printers</a></li>
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('clients')">Clients</a></li>
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
</ul>
<ul class="sidebar-nav sidebar-nav-footer">
<li><a href="/session/key/download">Download backup key</a></li>
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
</ul>
</nav>
<div class="main-wrapper">
<header class="topbar">
<div class="topbar-controls" x-data>
<button class="icon-btn sidebar-toggle" @click="nav = !nav"
x-data :aria-label="$store.i18n.t('open_menu')">{{ ico.i('menu', 18) }}</button>
<div class="topbar-title">
{% block crumb %}{% endblock %}
<h1>{% block page_title %}ImpTune{% endblock %}</h1>
</div>
<div class="topbar-actions" x-data>
{% block page_actions %}{% endblock %}
<span class="topbar-sep"></span>
<!-- Theme toggle button: cycles Light -> Dark -> System -->
<button class="secondary outline"
<button class="icon-btn"
x-html="$store.theme.icons[$store.theme.current]"
:aria-label="$store.theme.current"
@click="$store.theme.cycle()"
title="Toggle theme">&#9681;</button>
<!-- Language toggle button -->
<button class="secondary outline"
<button class="icon-btn"
x-text="$store.i18n.t('lang_label')"
@click="$store.i18n.toggle()"
title="Toggle language">FR</button>
</div>
</header>
<main class="main-content">
{% if request.state.ephemeral_session %}
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
dies with the browser window. Say it before work is lost. #}
<div class="notice session-ephemeral" id="ephemeral-session-warning">
{{ ico.i('key', 16) }}
<p x-data x-text="$store.i18n.t('ephemeral_warning')">This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.</p>
<a class="btn ghost" href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
</div>
{% endif %}
{% if request.state.is_new_owner %}
<dialog open id="session-choice-modal">
<article>
<h3>Keep your printers?</h3>
<p>Your printers, configs, and groups are private to this browser
(drivers stay shared with everyone). If you clear your cookies
without a backup key, they're gone for good.</p>
<footer>
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
</footer>
<div class="dialog-head">
<h3>Keep your printers?</h3>
</div>
<div class="dialog-body">
<p>Your printers, configs, and clients are private to this browser —
drivers stay shared with everyone. Clear your cookies without a backup
key and they are gone for good.</p>
{% if request.state.ephemeral_session %}
<p><strong>This server runs without HTTPS</strong>, so this session is
kept in memory only: closing the browser ends it. The backup key is the
only way back in.</p>
{% endif %}
</div>
<div class="dialog-foot">
<button class="btn ghost" onclick="this.closest('dialog').close()">Keep in this browser</button>
<a role="button" class="btn" href="/session/key/download">Download backup key</a>
</div>
</article>
</dialog>
{% endif %}
+24 -6
View File
@@ -1,11 +1,29 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}{{ client.name }} &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/clients">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_clients')">Clients</span></a>
</span>
{% endblock %}
{% block page_title %}{{ client.name }}{% endblock %}
{% block page_actions %}
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1>{{ client.name }}</h1>
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">&larr; All Clients</a></p>
<div class="toolbar">
<h2 class="eyebrow" x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
<span class="spacer"></span>
{% if printer_count %}
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }}&nbsp;<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% if printer_count - ready_count %}
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }}&nbsp;<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endif %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
{% include "partials/printer_list.html" %}
</section>
{% include "partials/printer_list.html" %}
{% endblock %}
+30 -15
View File
@@ -1,21 +1,36 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Clients &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('clients')">Clients</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
<section>
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add Client</h2>
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML">
<label>
<span x-data x-text="$store.i18n.t('client_name_label')">Client Name</span>
<input type="text" name="name" placeholder="e.g. Contoso" required>
</label>
<button type="submit" x-data x-text="$store.i18n.t('add_client')">Add Client</button>
</form>
</section>
<div class="split">
<div>
{% include "partials/client_list.html" %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
{% include "partials/client_list.html" %}
</section>
<section class="card">
<div class="card-head">
{{ ico.i('plus') }}
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add client</h2>
</div>
<div class="card-body">
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()">
<label>
<span class="label-text" x-data x-text="$store.i18n.t('client_name_label')">Client name</span>
<input type="text" name="name" placeholder="Contoso" required>
</label>
<div class="btn-row" style="margin-top:.8rem">
<button type="submit" class="btn" x-data>
{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_client')">Add client</span>
</button>
</div>
</form>
</div>
</section>
</div>
{% endblock %}
+139 -33
View File
@@ -1,41 +1,147 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span>{% endblock %}
{% block page_actions %}
<a href="/drivers" class="btn ghost sm">{{ ico.i('upload') }}<span x-text="$store.i18n.t('upload_driver')">Add driver</span></a>
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('dashboard_intro')">Three steps from driver ZIP to deployable package.</p>
<div class="quick-actions">
<a href="/printers" class="btn-action" x-data x-text="$store.i18n.t('new_printer')">New Printer</a>
<a href="/drivers" class="btn-action" x-data x-text="$store.i18n.t('upload_driver_btn')">Upload Driver</a>
<a href="/packages" class="btn-action" x-data x-text="$store.i18n.t('export_package')">Export Package</a>
{#
The rail is the spine of the tool: a printer cannot be exported before it has
a driver, so the order is real and the state of each stage is worth showing.
#}
{% set s1 = 'done' if driver_count else 'next' %}
{% set s2 = 'done' if printer_count else ('next' if driver_count else '') %}
{% set s3 = 'done' if ready_count else ('next' if printer_count else '') %}
<div class="rail">
<a class="rail-step {{ s1 }}" href="/drivers">
<div class="rail-top">
<span class="rail-num">{% if s1 == 'done' %}{{ ico.i('check', 13) }}{% else %}1{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_driver_title')">Add the driver</span>
<span class="rail-count">{{ driver_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_driver_desc')">A ZIP holding the .inf and its files.</p>
<span class="rail-cta" x-data>
{% if s1 == 'done' %}<span x-text="$store.i18n.t('drivers')">Drivers</span>{{ ico.i('chevron', 13) }}
{% else %}<span x-text="$store.i18n.t('go_upload_driver')">Add a driver</span>{{ ico.i('chevron', 13) }}{% endif %}
</span>
</a>
<a class="rail-step {{ s2 }}" href="{% if printer_count %}/printers{% else %}/printers/new{% endif %}">
<div class="rail-top">
<span class="rail-num">{% if s2 == 'done' %}{{ ico.i('check', 13) }}{% else %}2{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_printer_title')">Configure the printer</span>
<span class="rail-count">{{ printer_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
<span class="rail-cta" x-data>
{% if s2 == 'done' %}<span x-text="$store.i18n.t('printers')">Printers</span>{{ ico.i('chevron', 13) }}
{% else %}<span x-text="$store.i18n.t('go_add_printer')">Add a printer</span>{{ ico.i('chevron', 13) }}{% endif %}
</span>
</a>
<a class="rail-step {{ s3 }}" href="/packages">
<div class="rail-top">
<span class="rail-num">{% if s3 == 'done' %}{{ ico.i('check', 13) }}{% else %}3{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_package_title')">Export the package</span>
<span class="rail-count">{{ ready_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM.</p>
<span class="rail-cta" x-data>
<span x-text="$store.i18n.t('go_export')">View packages</span>{{ ico.i('chevron', 13) }}
</span>
</a>
</div>
<section class="recent-activity">
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
<div class="split">
<section class="card card-flush">
<div class="card-head">
<h2 x-data x-text="$store.i18n.t('recent_printers')">Recent printers</h2>
{% if recent_printers %}
<span class="head-actions">
<a href="/printers" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
</span>
{% endif %}
</div>
<div class="card-body">
{% if recent_printers %}
<div class="table-scroll">
<table class="data-table">
<tbody>
{% for printer in recent_printers %}
<tr>
<td>
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
<span class="cell-sub">{{ printer.ip_address }}{% if printer.client_id %} &middot; {{ printer.client.name }}{% endif %}</span>
</td>
<td class="actions">
{% if printer.driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
</div>
{% endif %}
</div>
</section>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_printers')">Recent Printers</h3>
{% if recent_printers %}
<ul>
{% for printer in recent_printers %}
<li><a href="/printers/{{ printer.id }}">{{ printer.name }}</a> — {{ printer.ip_address }}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
{% endif %}
</div>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_packages')">Recent Packages</h3>
{% if recent_packages %}
<ul>
{% for package in recent_packages %}
<li><a href="/printers/{{ package.id }}">{{ package.name }}</a>{% if package.client_id %}{{ package.client.name }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages')">No packages exported yet.</p>
{% endif %}
</div>
</section>
<section class="card card-flush">
<div class="card-head">
<h2 x-data x-text="$store.i18n.t('recent_packages')">Ready to export</h2>
{% if recent_packages %}
<span class="head-actions">
<a href="/packages" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
</span>
{% endif %}
</div>
<div class="card-body">
{% if recent_packages %}
<div class="table-scroll">
<table class="data-table">
<tbody>
{% for package in recent_packages %}
<tr>
<td>
<a class="cell-name" href="/printers/{{ package.id }}">{{ package.name }}</a>
<span class="cell-sub">{% if package.client_id %}{{ package.client.name }}{% else %}&mdash;{% endif %}</span>
</td>
<td class="actions">
<span class="btn-row">
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_packages')">No printers are ready to export yet.</strong>
<p x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</p>
</div>
{% endif %}
</div>
</section>
</div>
{% endblock %}
+37 -19
View File
@@ -1,25 +1,43 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Drivers &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('drivers_title')">Drivers</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('drivers_intro')">Drivers are shared — everyone sees the same library.</p>
<section>
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Upload Driver Package</h2>
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file" x-data x-text="$store.i18n.t('driver_package_label')">Driver Package (ZIP containing .inf + driver files)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_btn')">Upload</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true" x-data x-text="$store.i18n.t('uploading')">Uploading...</span>
</form>
<section class="card">
<div class="card-head">
{{ ico.i('upload') }}
<div>
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Add a driver package</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
<div class="card-body">
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<div class="uploader">
<label for="driver-file" class="label-text" x-data x-text="$store.i18n.t('driver_package_label')">Driver package (ZIP)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<div class="btn-row">
<button type="submit" class="btn" x-data>
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true"
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
</div>
</div>
</form>
</div>
</section>
<section>
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
{% include "partials/driver_list.html" %}
</section>
<h2 class="eyebrow" style="margin-bottom:.6rem" x-data x-text="$store.i18n.t('driver_library')">Driver library</h2>
{% include "partials/driver_list.html" %}
{% endblock %}
+71 -17
View File
@@ -1,37 +1,91 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Packages &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('packages_title')">Packages</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('packages_title')">Packages</h1>
<p x-data x-text="$store.i18n.t('packages_description')">Printers with drivers assigned — ready for deployment package export.</p>
<p class="page-intro" x-data x-text="$store.i18n.t('packages_description')">Printers with a driver assigned — ready for export.</p>
{% if printers %}
<figure>
<table role="grid">
<div class="card-grid">
<div class="card">
<div class="card-head">
{{ ico.i('package') }}
<h2 class="mono">.intunewin</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</p>
</div>
</div>
<div class="card">
<div class="card-head">
{{ ico.i('script') }}
<h2 class="mono">.zip</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</p>
</div>
</div>
</div>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col" x-data x-text="$store.i18n.t('printer_col')">Printer</th>
<th scope="col" x-data x-text="$store.i18n.t('client_col')">Client</th>
<th scope="col" x-data x-text="$store.i18n.t('driver_col')">Driver</th>
<th scope="col" x-data x-text="$store.i18n.t('icon_section')">Icon</th>
<th scope="col" x-data x-text="$store.i18n.t('downloads_col')">Downloads</th>
</tr>
</thead>
<tbody>
{% for printer in printers %}
<tr>
<td><a href="/printers/{{ printer.id }}">{{ printer.name }}</a></td>
<td>{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</td>
<td>{{ printer.driver.original_filename }}</td>
<td>
<a href="/printers/{{ printer.id }}/packages/intunewin">.intunewin</a>
&nbsp;|&nbsp;
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
</td>
</tr>
<tr>
<td>
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
<span class="cell-sub">{% if printer.client_id %}{{ printer.client.name }}{% else %}&mdash;{% endif %}</span>
</td>
<td>
<span class="mono">{{ printer.driver.original_filename }}</span>
{% if printer.driver.architecture %}<span class="cell-sub">{{ printer.driver.architecture }}</span>{% endif %}
</td>
<td>
{% if printer.id in icon_printer_ids %}
<img src="/printers/{{ printer.id }}/icon" width="28" height="28" alt=""
style="border-radius:5px;vertical-align:middle">
{% else %}
<span class="faint">&mdash;</span>
{% endif %}
</td>
<td class="actions">
<span class="btn-row">
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
</div>
</div>
{% if pending_count %}
<p class="hint" style="margin-top:.9rem" x-data>
<a href="/printers">{{ pending_count }} <span x-text="$store.i18n.t('pending_printers')">printers still need a driver</span></a>
</p>
{% endif %}
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages_ready')">No package-ready printers yet. Assign a driver to a printer to enable package export.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</strong>
<p x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM. Scripts included.</p>
<a href="/printers" class="btn sm" x-data>{{ ico.i('printer', 14) }}<span x-text="$store.i18n.t('printers')">Printers</span></a>
</div>
</div>
{% endif %}
{% endblock %}
+40 -17
View File
@@ -1,22 +1,45 @@
{% import "partials/icons.html" as ico %}
{% set counts = counts | default({}, true) %}
<div id="client-list">
{% if not clients %}
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('group', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</strong>
<p x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
</div>
</div>
{% else %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('created')">Created</th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_printers')">Printers</th>
<th x-data x-text="$store.i18n.t('created')">Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a class="cell-name" href="/clients/{{ c.id }}">{{ c.name }}</a></td>
<td class="num">
{% set n = counts.get(c.id, 0) %}
{% if n %}{{ n }}{% else %}<span class="faint">0</span>{% endif %}
</td>
<td class="num">{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
<td class="actions">
<a class="btn quiet sm" href="/clients/{{ c.id }}" x-data>
<span x-text="$store.i18n.t('open_client')">Open</span>{{ ico.i('chevron', 13) }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
+80 -35
View File
@@ -1,50 +1,95 @@
{% import "partials/icons.html" as ico %}
{% set usage = usage | default({}, true) %}
{% set new_driver_id = new_driver_id | default(None, true) %}
<div id="driver-list">
{% if parsed is defined and parsed.unused_files %}
<p class="notice">
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
<div class="notice">
<p>
{{ parsed.unused_files | length }}
<span x-data x-text="$store.i18n.t('unused_files')">file(s) in the ZIP are unused — the .inf never references them.</span>
</p>
<details>
<summary>Show unused files</summary>
<summary x-data x-text="$store.i18n.t('show_unused')">Show unused files</summary>
<ul>
{% for f in parsed.unused_files %}
<li>{{ f }}</li>
{% endfor %}
</ul>
</details>
</p>
</div>
{% endif %}
{% if driver_data %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
</tr>
</thead>
<tbody>
{% for item in driver_data %}
<tr>
<td>{{ item.driver.original_filename }}</td>
<td>
{% if item.names %}
<select aria-label="Driver names">
{% for name in item.names %}
<option>{{ name }}</option>
{% endfor %}
</select>
{% else %}
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
{% endif %}
</td>
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
<td>{{ item.driver.uploaded_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('driver_filename')">File</th>
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver name(s)</th>
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
</tr>
</thead>
<tbody>
{% for item in driver_data %}
<tr>
<td>
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
<span class="cell-sub">
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
{% if item.driver.inf_filename %} &middot; {{ item.driver.inf_filename }}{% endif %}
</span>
</td>
<td>
{% if item.names %}
{# Multi-model INFs can carry a dozen names — show three, fold the rest. #}
<span class="pill-row">
{% for name in item.names[:3] %}<span class="pill">{{ name }}</span>{% endfor %}
{% if item.names | length > 3 %}
<details class="more-pills">
<summary class="pill">+{{ item.names | length - 3 }}</summary>
<span class="pill-row">
{% for name in item.names[3:] %}<span class="pill">{{ name }}</span>{% endfor %}
</span>
</details>
{% endif %}
</span>
{% else %}
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
{% endif %}
</td>
<td class="col-arch">
{% if item.driver.architecture %}
<span class="badge">{{ item.driver.architecture }}</span>
{% else %}
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
{% endif %}
</td>
<td class="num col-used">
{% set n = usage.get(item.driver.id, 0) %}
{% if n %}<a href="/printers">{{ n }}</a>{% else %}<span class="faint">0</span>{% endif %}
</td>
<td class="num col-added">
{{ item.driver.uploaded_at.strftime('%Y-%m-%d %H:%M') if item.driver.uploaded_at is not string else item.driver.uploaded_at }}
{% if new_driver_id and item.driver.id == new_driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</strong>
<p x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
{% endif %}
</div>
@@ -1,11 +1,12 @@
{% include "partials/driver_list.html" %}
{# Out-of-band: refresh the printer form's driver picker with the new driver chosen. #}
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
<option value="">-- No driver --</option>
<option value="" x-data x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if item.driver.id == new_driver_id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
+62
View File
@@ -0,0 +1,62 @@
{#
Inline SVG icon set — no external files, no text nodes.
Text-node-free on purpose: E2E tests read `textContent` of nav links to assert
the translated label, so icons must contribute no characters.
Usage: {% import "partials/icons.html" as ico %} → {{ ico.i('printer') }}
#}
{% macro i(name, size=16) -%}
{%- set s = size -%}
<svg class="ico" width="{{ s }}" height="{{ s }}" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true" focusable="false">
{%- if name == 'gauge' -%}
<path d="M12 21a9 9 0 1 0-9-9"/><path d="M3 12h2"/><path d="M19 12h2"/><path d="M12 3v2"/><path d="m12 12 4.5-3.5"/><circle cx="12" cy="12" r="1.6"/>
{%- elif name == 'driver' -%}
<path d="M3 8.5 12 4l9 4.5-9 4.5-9-4.5Z"/><path d="M3 8.5v7L12 20l9-4.5v-7"/><path d="M12 13v7"/>
{%- elif name == 'printer' -%}
<path d="M6 9V4h12v5"/><rect x="3" y="9" width="18" height="7" rx="1.5"/><path d="M7 16h10v4H7z"/><circle cx="17.5" cy="12" r=".8" fill="currentColor" stroke="none"/>
{%- elif name == 'group' -%}
<path d="M4 20v-1.5A3.5 3.5 0 0 1 7.5 15h3A3.5 3.5 0 0 1 14 18.5V20"/><circle cx="9" cy="8.5" r="3"/><path d="M16 15.5h.5A3.5 3.5 0 0 1 20 19v1"/><circle cx="17" cy="9.5" r="2.2"/>
{%- elif name == 'package' -%}
<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5v-9Z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/><path d="M8 5.7 16 9.2"/>
{%- elif name == 'key' -%}
<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8"/><path d="m15.5 7.5 2 2"/><path d="m18 5 2 2"/>
{%- elif name == 'upload' -%}
<path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
{%- elif name == 'download' -%}
<path d="M12 4v12"/><path d="m7.5 11.5 4.5 4.5 4.5-4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
{%- elif name == 'plus' -%}
<path d="M12 5v14"/><path d="M5 12h14"/>
{%- elif name == 'search' -%}
<circle cx="11" cy="11" r="6"/><path d="m15.5 15.5 4 4"/>
{%- elif name == 'pencil' -%}
<path d="M4 20h4l10-10-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>
{%- elif name == 'trash' -%}
<path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7v11.5A1.5 1.5 0 0 0 7.5 20h9a1.5 1.5 0 0 0 1.5-1.5V7"/><path d="M10 11v5"/><path d="M14 11v5"/>
{%- elif name == 'check' -%}
<path d="m5 12.5 4.5 4.5L19 7.5"/>
{%- elif name == 'alert' -%}
<path d="M12 4.5 21 19.5H3L12 4.5Z"/><path d="M12 10v4"/><circle cx="12" cy="16.8" r=".9" fill="currentColor" stroke="none"/>
{%- elif name == 'copy' -%}
<rect x="9" y="9" width="11" height="11" rx="1.5"/><path d="M15 6.5V5.5A1.5 1.5 0 0 0 13.5 4h-8A1.5 1.5 0 0 0 4 5.5v8A1.5 1.5 0 0 0 5.5 15h1"/>
{%- elif name == 'terminal' -%}
<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7.5 10 2.5 2.5-2.5 2.5"/><path d="M12.5 15h4"/>
{%- elif name == 'script' -%}
<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/><path d="M10 12h5"/><path d="M10 16h5"/>
{%- elif name == 'image' -%}
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4.5 18 4.5-4.5 3.5 3.5 3-2.5 4 3.5"/>
{%- elif name == 'menu' -%}
<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>
{%- elif name == 'chevron' -%}
<path d="m8.5 5.5 6 6.5-6 6.5"/>
{%- elif name == 'back' -%}
<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>
{%- elif name == 'link' -%}
<path d="M10 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/><path d="M14 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
{%- elif name == 'network' -%}
<rect x="3.5" y="14.5" width="6" height="5" rx="1"/><rect x="14.5" y="14.5" width="6" height="5" rx="1"/><rect x="9" y="4.5" width="6" height="5" rx="1"/><path d="M12 9.5v3H6.5v2"/><path d="M12 12.5h5.5v2"/>
{%- endif -%}
</svg>
{%- endmacro %}
@@ -1,102 +1,122 @@
<!-- Edit trigger button — placed in Actions column -->
<button class="secondary outline"
{% import "partials/icons.html" as ico %}
<!-- Edit trigger — sits in the row's Actions cell -->
<button class="btn ghost sm"
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
x-data x-text="$store.i18n.t('edit')">
Edit
x-data>
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
</button>
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
<!-- Edit dialog — native <dialog>, no extra library -->
<dialog id="edit-modal-{{ p.id }}">
<article>
<header>
<button aria-label="Close" rel="prev"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
</header>
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
<form hx-patch="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" value="{{ p.name }}" required>
</label>
<div class="dialog-head">
<h3 x-text="$store.i18n.t('edit_printer_title')">Edit printer</h3>
{# No aria-label="Close": Pico hooks that attribute to draw its own
floated X icon, which fights this button's glyph and placement. #}
<button type="button" class="icon-btn close"
:aria-label="$store.i18n.t('cancel')"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">&times;</button>
</div>
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
</label>
<div class="dialog-body">
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
<label>
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
<input type="text" name="name" value="{{ p.name }}" required>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if p.client_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
<input type="text" name="ip_address" class="mono"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
<input type="text" name="port_name" class="mono"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</label>
</div>
</fieldset>
<label><span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
<label>
<select name="driver_id">
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
</label>
</fieldset>
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
<select name="duplex_mode">
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-sided</option>
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long edge</option>
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short edge</option>
</select>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
<select name="paper_size">
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
</div>
<div class="check-row">
<label>
<input type="checkbox" name="color_mode" value="on" {% if p.color_mode %}checked{% endif %}>
<span x-text="$store.i18n.t('color_mode')">Color</span>
</label>
<label>
<input type="checkbox" name="collate" value="on" {% if p.collate %}checked{% endif %}>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
</div>
</fieldset>
</div>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if p.color_mode %}checked{% endif %}>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if p.collate %}checked{% endif %}>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label><span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if p.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<footer>
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
<button type="button" class="secondary"
<div class="dialog-foot">
<button type="button" class="btn ghost"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
x-text="$store.i18n.t('cancel')">
Cancel
</button>
</footer>
x-text="$store.i18n.t('cancel')">Cancel</button>
<button type="submit" class="btn" x-text="$store.i18n.t('save')">Save</button>
</div>
</form>
</div>
</article>
@@ -1,99 +0,0 @@
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
<label>
Printer Name
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
value="{{ printer.name if printer else '' }}">
</label>
<label>
IP Address
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<label>
Port Name
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<label>
Driver
<select name="driver_id" id="printer-form-driver-select">
<option value="">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
Duplex Mode
<select name="duplex_mode">
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if not printer or printer.color_mode %}checked{% endif %}>
Color Mode
</label>
<label>
Paper Size
<select name="paper_size">
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if not printer or printer.collate %}checked{% endif %}>
Collate
</label>
<label>
Client
<select name="client_id">
<option value="">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if printer and printer.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<button type="submit">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
Upload New Driver
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary">Upload Driver</button>
</form>
<div id="driver-list" style="display:none"></div>
</div>
+91 -50
View File
@@ -1,57 +1,98 @@
{% import "partials/icons.html" as ico %}
<div id="printer-list">
{% if not grouped %}
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and the defaults the workstation gets.</p>
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
</div>
</div>
{% else %}
{% for client_name, printers in grouped.items() %}
<section>
{% set group_client_id = printers[0].client_id if printers else None %}
{% if group_client_id %}
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
{% else %}
<h3>{{ client_name }}</h3>
{% endif %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr>
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ p.ip_address }}</td>
<td>{{ p.port_name }}</td>
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
<td>{{ p.duplex_mode }}</td>
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
<td>{{ p.paper_size }}</td>
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
<td>
{% include "partials/printer_edit_modal.html" %}
<button
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?"
x-data x-text="$store.i18n.t('delete')">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% set group_client_id = printers[0].client_id if printers else None %}
{# Group search text must cover everything its rows match on, or a row can
match while its group stays hidden. #}
{% set gsearch = (printers | map(attribute='name') | join(' ')) ~ ' '
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
~ client_name %}
<section class="card card-flush"
data-search="{{ gsearch | lower }}"
x-data
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
<div class="group-head">
{{ ico.i('group') }}
{% if group_client_id %}
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
{% else %}
<h3 x-data x-text="$store.i18n.t('unassigned')">{{ client_name }}</h3>
{% endif %}
<span class="count">{{ printers | length }}</span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_status')">Status</th>
<th class="col-defaults" x-data x-text="$store.i18n.t('th_config')">Defaults</th>
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}"
x-data
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
<td>
<a class="cell-name" href="/printers/{{ p.id }}">{{ p.name }}</a>
<span class="cell-sub">{{ p.ip_address }} &middot; {{ p.port_name }}</span>
</td>
<td>
{% if p.driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
<span class="cell-sub">{{ p.driver.original_filename }}</span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
</td>
<td class="col-defaults">
<span class="pill-row">
<span class="pill" x-data
x-text="$store.i18n.t('{{ 'one_sided' if p.duplex_mode == 'OneSided' else ('long_edge' if p.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ p.duplex_mode }}</span>
<span class="pill" x-data
x-text="$store.i18n.t('{{ 'color_value' if p.color_mode else 'grayscale_value' }}')">{{ 'Color' if p.color_mode else 'Grayscale' }}</span>
<span class="pill">{{ p.paper_size }}</span>
{% if p.collate %}
<span class="pill" x-data x-text="$store.i18n.t('collate')">Collate</span>
{% endif %}
</span>
</td>
<td class="actions">
<span class="btn-row">
{% if p.driver_id %}
<a class="btn quiet sm" href="/printers/{{ p.id }}/packages/intunewin"
title=".intunewin">{{ ico.i('download', 14) }}</a>
{% endif %}
{% include "partials/printer_edit_modal.html" %}
<button class="btn danger sm"
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?"
x-data :title="$store.i18n.t('delete')">
{{ ico.i('trash', 14) }}
</button>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
{% endif %}
+204 -77
View File
@@ -1,82 +1,209 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ printer.name }}</h1>
<article>
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
<dl>
<dt x-data x-text="$store.i18n.t('ip_address')">IP Address</dt><dd>{{ printer.ip_address }}</dd>
<dt x-data x-text="$store.i18n.t('port_name')">Port Name</dt><dd>{{ printer.port_name }}</dd>
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex Mode</dt><dd>{{ printer.duplex_mode }}</dd>
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color Mode</dt>
{% if printer.color_mode %}<dd x-data x-text="$store.i18n.t('color_value')">Color</dd>{% else %}<dd x-data x-text="$store.i18n.t('grayscale_value')">Grayscale</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper Size</dt><dd>{{ printer.paper_size }}</dd>
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
{% if printer.collate %}<dd x-data x-text="$store.i18n.t('yes')">Yes</dd>{% else %}<dd x-data x-text="$store.i18n.t('no')">No</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
{% if printer.client_id %}<dd>{{ printer.client.name }}</dd>{% else %}<dd x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>{% endif %}
</dl>
{% import "partials/icons.html" as ico %}
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
{% if printer.driver_id %}
<dl>
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt><dd>{{ printer.driver.original_filename }}</dd>
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver Name(s)</dt><dd>{{ driver_names | join(", ") }}</dd>
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
<dd>{% if printer.driver.architecture %}{{ printer.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
</dl>
{% else %}
<p x-data x-text="$store.i18n.t('no_driver_detail')">No driver assigned</p>
{% endif %}
{% if has_driver %}
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune Commands</h2>
<div x-data="{ copiedInstall: false }">
<label x-text="$store.i18n.t('install_cmd_label')">Install command</label>
<code id="install-cmd">{{ install_cmd }}</code>
<button @click="
const text = document.getElementById('install-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
{% block head_title %}{{ printer.name }} &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
{% if printer.client_id %}<span>/</span><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a>{% endif %}
</span>
{% endblock %}
{% block page_title %}{{ printer.name }}{% endblock %}
{% block page_actions %}
{% if has_driver %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endblock %}
{% block content %}
<div class="split">
<!-- Left: what this printer is -->
<div>
<section class="card">
<div class="card-head">
{{ ico.i('network') }}
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
</div>
<div class="card-body">
<dl class="kv">
<dt x-data x-text="$store.i18n.t('ip_address')">IP address</dt>
<dd class="mono-val">{{ printer.ip_address }}</dd>
<dt x-data x-text="$store.i18n.t('port_name')">Port name</dt>
<dd class="mono-val">{{ printer.port_name }}</dd>
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
{% if printer.client_id %}
<dd><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a></dd>
{% else %}
<dd class="faint" x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>
{% endif %}
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex mode</dt>
<dd x-data x-text="$store.i18n.t('{{ 'one_sided' if printer.duplex_mode == 'OneSided' else ('long_edge' if printer.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ printer.duplex_mode }}</dd>
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color mode</dt>
<dd x-data x-text="$store.i18n.t('{{ 'color_value' if printer.color_mode else 'grayscale_value' }}')">{{ 'Color' if printer.color_mode else 'Grayscale' }}</dd>
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper size</dt>
<dd>{{ printer.paper_size }}</dd>
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
<dd x-data x-text="$store.i18n.t('{{ 'yes' if printer.collate else 'no' }}')">{{ 'Yes' if printer.collate else 'No' }}</dd>
</dl>
</div>
</section>
<section class="card">
<div class="card-head">
{{ ico.i('driver') }}
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
</div>
<div class="card-body">
{% if printer.driver_id %}
<dl class="kv">
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt>
<dd class="mono-val">{{ printer.driver.original_filename }}</dd>
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver name(s)</dt>
<dd>
<span class="pill-row">
{% for name in driver_names %}<span class="pill">{{ name }}</span>{% endfor %}
</span>
</dd>
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
<dd>{% if printer.driver.architecture %}<span class="badge">{{ printer.driver.architecture }}</span>{% else %}<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
</dl>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
<strong>No driver assigned</strong>
<p x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
<a href="/printers" class="btn sm" x-data>{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('assign_driver_cta')">Assign a driver</span></a>
</div>
{% endif %}
</div>
</section>
{% if has_driver %}
<section class="card">
<div class="card-head">
{{ ico.i('terminal') }}
<div>
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune commands</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_intune_cmds')">Paste these into the Win32 app's install and uninstall fields.</p>
</div>
</div>
<div class="card-body field-stack">
<div x-data="{ copiedInstall: false }">
<span class="label-text" x-text="$store.i18n.t('install_cmd_label')">Install command</span>
<div class="cmd">
<code id="install-cmd">{{ install_cmd }}</code>
<button type="button" @click="
navigator.clipboard.writeText(document.getElementById('install-cmd').innerText)
.then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
.catch(() => { /* command stays visible for manual copy */ })
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
</div>
</div>
<div x-data="{ copiedUninstall: false }">
<span class="label-text" x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</span>
<div class="cmd">
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
<button type="button" @click="
navigator.clipboard.writeText(document.getElementById('uninstall-cmd').innerText)
.then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
.catch(() => { /* command stays visible for manual copy */ })
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
</div>
</div>
</div>
</section>
{% endif %}
</div>
<div x-data="{ copiedUninstall: false }">
<label x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</label>
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
<button @click="
const text = document.getElementById('uninstall-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
<!-- Right: what you can do with it -->
<div>
<section class="card">
<div class="card-head">
{{ ico.i('package') }}
<h2 x-data x-text="$store.i18n.t('export_section')">Deploy</h2>
</div>
<div class="card-body">
{% if has_driver %}
<div class="field-stack">
<div>
<a class="btn" href="/printers/{{ printer.id }}/packages/intunewin" x-data>
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_intunewin')">Download .intunewin</span>
</a>
<span class="hint" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</span>
</div>
<div>
<a class="btn ghost" href="/printers/{{ printer.id }}/packages/ninja" x-data>
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</span>
</a>
<span class="hint" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</span>
</div>
</div>
{% else %}
<p class="dim" x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
{% endif %}
</div>
</section>
{% if has_driver %}
<section class="card">
<div class="card-head">
{{ ico.i('script') }}
<h2 x-data x-text="$store.i18n.t('scripts_section')">PowerShell scripts</h2>
</div>
<div class="card-body">
<div class="btn-row">
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/install.ps1">{{ ico.i('download', 13) }}install.ps1</a>
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/uninstall.ps1">{{ ico.i('download', 13) }}uninstall.ps1</a>
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/detect.ps1">{{ ico.i('download', 13) }}detect.ps1</a>
</div>
</div>
</section>
{% endif %}
<section class="card">
<div class="card-head">
{{ ico.i('image') }}
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
</div>
<div class="card-body">
<div id="icon-status">
{% if has_icon %}
<p class="ok-note">{{ ico.i('check', 14) }}<span x-data x-text="$store.i18n.t('icon_uploaded')">Icon saved</span></p>
<div class="icon-preview">
<img src="/printers/{{ printer.id }}/icon" width="56" height="56" alt="">
<span class="meta">256&times;256 PNG</span>
</div>
{% else %}
<p class="dim" x-data x-text="$store.i18n.t('no_icon')">No icon. Intune will show its default.</p>
{% endif %}
</div>
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<div class="uploader">
<input type="file" name="file" accept="image/png" required
x-data :aria-label="$store.i18n.t('upload_icon')">
<div class="btn-row">
<button type="submit" class="btn ghost sm" x-data>
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('{{ 'replace_icon' if has_icon else 'upload_icon' }}')">Upload icon</span>
</button>
</div>
<p class="hint" x-data x-text="$store.i18n.t('hint_icon')">PNG, exactly 256 × 256, 750 KB max.</p>
</div>
</form>
</div>
</section>
</div>
<h2 x-data x-text="$store.i18n.t('scripts_section')">Scripts</h2>
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_install')">
Download Install Script
</a>
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_uninstall')">
Download Uninstall Script
</a>
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_detect')">
Download Detect Script
</a>
<h2 x-data x-text="$store.i18n.t('export_section')">Export</h2>
<a href="/printers/{{ printer.id }}/packages/ninja" role="button" x-data x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</a>
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button" x-data x-text="$store.i18n.t('download_intunewin')">Download .intunewin</a>
{% endif %}
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
{% if has_icon %}
<p x-data x-text="$store.i18n.t('icon_uploaded')">Icon uploaded</p>
{% endif %}
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
enctype="multipart/form-data">
<input type="file" name="file" accept="image/png" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_icon')">Upload Icon</button>
</form>
<div id="icon-status"></div>
<a href="/printers" role="button" class="secondary" x-data x-text="$store.i18n.t('back_to_printers_btn')">Back to Printers</a>
</article>
</div>
{% endblock %}
+42 -5
View File
@@ -1,12 +1,49 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Printers &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('printers')">Printers</span>{% endblock %}
{% block page_actions %}
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('printers_intro')">A printer with a driver assigned is ready to export.</p>
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
<div x-data="{
shown: 0,
total: 0,
recount() {
const rows = Array.from(document.querySelectorAll('#printer-list tr[data-search]'));
this.total = rows.length;
const q = this.$store.filter.q.trim().toLowerCase();
this.shown = q ? rows.filter(r => r.dataset.search.includes(q)).length : rows.length;
}
}"
x-init="recount()"
@htmx:after-swap.window="recount()">
<div class="toolbar">
<label class="search">
{{ ico.i('search') }}
<input type="search" x-model="$store.filter.q" @input="recount()"
:placeholder="$store.i18n.t('filter_printers')"
:aria-label="$store.i18n.t('filter_printers')">
</label>
<span class="filter-count" x-show="$store.filter.q.trim()" x-text="shown + ' / ' + total"></span>
<span class="spacer"></span>
{% if printer_count %}
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }}&nbsp;<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% if printer_count - ready_count %}
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }}&nbsp;<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endif %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
{% include "partials/printer_list.html" %}
</section>
<p class="empty-state" x-show="$store.filter.q.trim() && shown === 0" style="display:none"
x-text="$store.i18n.t('no_match')">No printer matches this filter.</p>
</div>
{% endblock %}
+140 -89
View File
@@ -1,100 +1,151 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Add printer &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
</span>
{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('add_printer_title')">Add printer</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
<div class="split" x-data="{ ip: '', port: '', portEdited: false }">
<section class="card">
<div class="card-body">
<form action="/printers" method="post">
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">&larr; Back to Printer Library</a></p>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
<label>
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
<input type="text" name="name" placeholder="HP LaserJet 4050 — Accounting" required>
<span class="hint" x-text="$store.i18n.t('hint_name')">The name the user sees on their workstation.</span>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<div x-data="{ ip: '', port: '', portEdited: false }">
<form action="/printers" method="post">
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
<input type="text" name="ip_address" class="mono"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="192.168.1.100"
inputmode="decimal"
required>
<span class="hint" x-text="$store.i18n.t('hint_ip')">The printer IPv4 address on the client network.</span>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
<input type="text" name="port_name" class="mono"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="IP_192_168_1_100">
<span class="hint" x-text="$store.i18n.t('hint_port')">Derived from the IP address. Edit it if your naming differs.</span>
</label>
</div>
</fieldset>
<label>
<span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
<label>
<select name="driver_id" id="printer-form-driver-select">
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
</label>
</fieldset>
<label>
<span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
<select name="duplex_mode">
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-sided</option>
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long edge</option>
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short edge</option>
</select>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
</div>
<div class="check-row">
<label>
<input type="checkbox" name="color_mode" value="on" checked>
<span x-text="$store.i18n.t('color_mode')">Color</span>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
</div>
</fieldset>
<label>
<span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<div class="form-foot">
<button type="submit" class="btn" x-text="$store.i18n.t('save_printer')">Save printer</button>
<a href="/printers" class="btn ghost" x-text="$store.i18n.t('cancel')">Cancel</a>
</div>
</form>
</div>
</section>
<label>
<span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id" id="printer-form-driver-select">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
<span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on" checked>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label>
<span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label>
<span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit" x-text="$store.i18n.t('save_printer')">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
<span x-text="$store.i18n.t('upload_new_driver')">Upload New Driver</span>
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary" x-text="$store.i18n.t('upload_driver')">Upload Driver</button>
</form>
<div id="driver-list"></div>
{#
Separate form on purpose: HTML forbids nesting, and the upload answers a
question the technician has *while* filling the form ("my driver isn't in
the list"). The response OOB-swaps the select above, new driver selected.
#}
<section class="card form-aside">
<div class="card-head">
<div>
<h2 x-data x-text="$store.i18n.t('upload_new_driver')">Add a new driver</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
<div class="card-body">
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML"
hx-indicator="#form-upload-spinner">
<input type="hidden" name="caller" value="printer_form">
<div class="uploader">
<input type="file" name="file" accept=".zip" required
x-data :aria-label="$store.i18n.t('driver_package_label')">
<div class="btn-row">
<button type="submit" class="btn ghost" x-data>
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
</button>
<span id="form-upload-spinner" class="htmx-indicator" aria-busy="true"
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
</div>
</div>
</form>
<div id="driver-list"></div>
</div>
</section>
</div>
{% endblock %}
+29 -14
View File
@@ -1,20 +1,35 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Restore backup key &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</span>{% endblock %}
{% block content %}
<h1>Restore backup key</h1>
<section class="card" style="max-width:34rem">
<div class="card-head">
{{ ico.i('key') }}
<h2 x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('restore_desc')">
Paste the key from <code>imptune-backup-key.txt</code> to get your printers,
settings, and clients back in this browser.
</p>
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
recover your printers, configs, and groups on this browser.</p>
{% if error %}
<div class="error"><p>{{ error }}</p></div>
{% endif %}
{% if error %}
<div class="error"><p>{{ error }}</p></div>
{% endif %}
<form method="post" action="/session/restore">
<label>
Backup key
<input type="text" name="key" placeholder="paste your key here" required autofocus>
</label>
<button type="submit">Restore</button>
</form>
<form method="post" action="/session/restore">
<label>
<span class="label-text" x-data x-text="$store.i18n.t('backup_key_label')">Backup key</span>
<input type="text" name="key" class="mono" placeholder="paste your key here" required autofocus>
</label>
<div class="btn-row" style="margin-top:.9rem">
<button type="submit" class="btn" x-data x-text="$store.i18n.t('restore_btn')">Restore</button>
<a href="/" class="btn ghost" x-data x-text="$store.i18n.t('cancel')">Cancel</a>
</div>
</form>
</div>
</section>
{% endblock %}