Files
arrr-erp/app/modules/documents/templates/documents/storage_nodes.html
T

271 lines
18 KiB
HTML

{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">ERP Local Agent</h1>
<p class="text-sm text-slate-500">Branch-local ERP runtime. Existing storage remains unchanged; future Tally/GST/local tools will use the same agent.</p>
<p class="text-xs text-slate-400 mt-1">Scope: {{ storage_scope_title or '-' }}</p>
<p class="text-xs text-emerald-700 mt-1">ERP Local Agent release: {{ erp_local_agent_version or '-' }} · automatic updates enabled</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/documents" class="px-3 py-2 rounded-xl bg-slate-100 text-slate-700 text-sm font-semibold">Back to Documents</a>
<a href="/documents/storage-jobs" class="px-3 py-2 rounded-xl bg-slate-100 text-slate-700 text-sm font-semibold">Storage Jobs</a>
<a href="/documents/download-requests" class="px-3 py-2 rounded-xl bg-slate-100 text-slate-700 text-sm font-semibold">Download Requests</a>
</div>
</div>
{% if request.query_params.get('error') == 'branch_not_linked' %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
Your user is not linked to a branch. Please update the Partner/Branch Manager user profile with branch_id before enabling local storage.
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-xs text-slate-500">Storage Nodes</div>
<div class="mt-1 text-2xl font-bold text-slate-900">{{ nodes|length }}</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-xs text-slate-500">Online / Active</div>
<div class="mt-1 text-2xl font-bold text-emerald-700">{{ nodes|selectattr('is_active')|list|length }}</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-xs text-slate-500">Recent Storage Jobs</div>
<div class="mt-1 text-2xl font-bold text-slate-900">{{ recent_jobs|length if recent_jobs is defined else 0 }}</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-xs text-slate-500">Recent Download Requests</div>
<div class="mt-1 text-2xl font-bold text-slate-900">{{ recent_download_requests|length if recent_download_requests is defined else 0 }}</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-xs text-slate-500">Workstations Seen</div>
<div class="mt-1 text-2xl font-bold text-slate-900">{{ workstation_agents|length if workstation_agents is defined else 0 }}</div>
</div>
</div>
{% if request.query_params.get('created') == '1' %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
Storage node created. Use the <strong>Download ERP Local Agent</strong> button in the node row to generate the branch package. Secret and standalone .env are not displayed.
</div>
{% endif %}
{% if request.query_params.get('error') == 'env_download_disabled' %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Standalone .env download is disabled for security. Download the pre-configured ERP Local Agent ZIP instead.
</div>
{% endif %}
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="space-y-6">
{% if can_manage_branch_storage %}
<form method="post" action="/documents/storage-nodes/auto-setup" class="rounded-2xl border bg-white p-5 space-y-4 shadow-sm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<h2 class="font-bold text-slate-900">One-click branch setup</h2>
<p class="text-xs text-slate-500 mt-1">Recommended. ERP will create or reuse the branch node and immediately download the pre-configured LSA2 Windows Service ZIP. Secret is not shown on screen. If a branch already has a storage root, the existing root will be reused and will not be silently changed.</p>
</div>
<div>
<label class="text-sm font-medium">Branch</label>
{% if forced_branch_id %}
<input type="hidden" name="branch_id" value="{{ forced_branch_id }}">
<div class="mt-1 rounded-xl border bg-slate-50 p-2 text-sm text-slate-700">
{% set fb = branch_map.get(forced_branch_id) if branch_map else None %}
{{ fb.name if fb else ('Branch ID ' ~ forced_branch_id) }}{% if fb and fb.code %} ({{ fb.code }}){% endif %}
</div>
{% else %}
<select name="branch_id" class="mt-1 w-full rounded-xl border p-2">
<option value="">Firm-level / Head office storage</option>
{% for branch in branches or [] %}
<option value="{{ branch.id }}">{{ branch.name }}{% if branch.code %} ({{ branch.code }}){% endif %}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div><label class="text-sm font-medium">Local Storage Root</label><input name="storage_root_path" class="mt-1 w-full rounded-xl border p-2" value="D:\AuditFirmStorage"><p class="text-xs text-amber-700 mt-1">For a new branch setup only. Existing branch nodes keep their already configured root.</p></div>
<div><label class="text-sm font-medium">Quota GB</label><input name="quota_limit_gb" class="mt-1 w-full rounded-xl border p-2" placeholder="Optional"></div>
<button class="w-full rounded-xl bg-emerald-700 text-white py-2 font-semibold">Download / Regenerate ERP Local Agent</button>
</form>
<form method="post" action="/documents/storage-nodes" class="rounded-2xl border bg-white p-5 space-y-4 shadow-sm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<h2 class="font-bold text-slate-900">Manual node creation</h2>
<p class="text-xs text-slate-500 mt-1">Use only when you want a custom node code/name.</p>
</div>
<div><label class="text-sm font-medium">Node Code</label><input name="node_code" required class="mt-1 w-full rounded-xl border p-2" placeholder="CHN-STORAGE-01"></div>
<div><label class="text-sm font-medium">Node Name</label><input name="node_name" required class="mt-1 w-full rounded-xl border p-2" placeholder="Chennai Branch Storage"></div>
<div>
<label class="text-sm font-medium">Branch</label>
{% if forced_branch_id %}
<input type="hidden" name="branch_id" value="{{ forced_branch_id }}">
<div class="mt-1 rounded-xl border bg-slate-50 p-2 text-sm text-slate-700">
{% set fb = branch_map.get(forced_branch_id) if branch_map else None %}
{{ fb.name if fb else ('Branch ID ' ~ forced_branch_id) }}{% if fb and fb.code %} ({{ fb.code }}){% endif %}
</div>
{% else %}
<select name="branch_id" class="mt-1 w-full rounded-xl border p-2">
<option value="">Firm-level / Head office storage</option>
{% for branch in branches or [] %}
<option value="{{ branch.id }}">{{ branch.name }}{% if branch.code %} ({{ branch.code }}){% endif %}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div><label class="text-sm font-medium">Connector URL</label><input name="connector_url" class="mt-1 w-full rounded-xl border p-2" placeholder="Optional zero-trust tunnel URL"></div>
<div><label class="text-sm font-medium">Local Storage Root</label><input name="storage_root_path" class="mt-1 w-full rounded-xl border p-2" placeholder="D:\AuditFirmStorage"></div>
<div><label class="text-sm font-medium">Quota GB</label><input name="quota_limit_gb" class="mt-1 w-full rounded-xl border p-2" placeholder="Optional"></div>
<button class="w-full rounded-xl bg-slate-900 text-white py-2 font-semibold">Create Node</button>
</form>
{% else %}
<div class="rounded-2xl border bg-slate-50 p-5 text-sm text-slate-600">
Storage setup package generation is available to Firm Admin, Partner and Branch Manager. This screen is monitoring-only for your current role.
</div>
{% endif %}
</div>
<div class="xl:col-span-2 space-y-6">
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b flex items-center justify-between">
<div>
<h2 class="font-bold text-slate-900">Configured ERP Local Agents</h2>
<p class="text-xs text-slate-500">Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.</p>
</div>
</div>
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600"><tr><th class="p-3 text-left">Node</th><th class="p-3 text-left">Branch</th><th class="p-3 text-left">Status</th><th class="p-3 text-left">Last Seen</th><th class="p-3 text-left">Root</th><th class="p-3 text-right">Action</th></tr></thead>
<tbody>
{% for node in nodes %}
{% set b = branch_map.get(node.branch_id) if branch_map and node.branch_id else None %}
<tr class="border-t align-top">
<td class="p-3"><div class="font-semibold">{{ node.node_name }}</div><div class="text-xs text-slate-500">{{ node.node_code }}</div></td>
<td class="p-3">{{ b.name if b else (node.branch_id or 'Firm-level') }}</td>
<td class="p-3"><span class="px-2 py-1 rounded-full text-xs {{ 'bg-emerald-100 text-emerald-700' if node.is_active else 'bg-slate-100 text-slate-600' }}">{{ node.status }}</span></td>
<td class="p-3 text-xs">{{ node.last_seen_at_utc or '-' }}</td>
<td class="p-3 text-xs font-mono break-all">{{ node.storage_root_path or '-' }}</td>
<td class="p-3 text-right">
{% if can_manage_branch_storage %}
<div class="flex flex-col items-end gap-2">
{% if node.status == 'disabled_duplicate' %}
<span class="text-xs text-slate-500">Duplicate disabled</span>
{% else %}
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-new-workstation">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">+ New Workstation</button>
</form>
<div class="max-w-48 text-right text-[11px] text-slate-500">Installs on another office PC without changing credentials used by existing workstations.</div>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-agent-package">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="text-xs font-semibold text-amber-700">Reissue branch package</button>
</form>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="text-sm text-blue-700 font-semibold">{{ 'Disable' if node.is_active else 'Enable' }}</button>
</form>
{% endif %}
</div>
{% else %}-{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="p-6 text-center text-slate-500">No storage nodes registered yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b">
<h2 class="font-bold text-slate-900">Registered Workstations</h2>
<p class="text-xs text-slate-500 mt-1">Each PC keeps a persistent agent identity. Use <strong>+ New Workstation</strong> on the branch node above for another office PC; it receives its own workstation credential and existing agents keep working.</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600"><tr><th class="p-3 text-left">Workstation</th><th class="p-3 text-left">Agent</th><th class="p-3 text-left">Tally</th><th class="p-3 text-left">Open Companies</th><th class="p-3 text-left">Last Seen</th><th class="p-3 text-left">Phase 2</th></tr></thead>
<tbody>
{% for ws in workstation_agents or [] %}
<tr class="border-t align-top">
<td class="p-3"><div class="font-semibold">{{ ws.machine_name }}</div><div class="text-xs text-slate-500">{{ ws.platform_name or '-' }}</div><div class="text-[11px] text-slate-400 font-mono">{{ ws.agent_instance_id[:12] }}...</div></td>
<td class="p-3"><div>{{ ws.agent_version or '-' }}</div><div class="text-xs {{ 'text-emerald-700' if ws.is_active else 'text-slate-500' }}">{{ ws.status }}</div></td>
<td class="p-3"><span class="px-2 py-1 rounded-full text-xs {{ 'bg-emerald-100 text-emerald-700' if ws.tally_connected else 'bg-slate-100 text-slate-600' }}">{{ 'Connected' if ws.tally_connected else 'Not connected' }}</span></td>
<td class="p-3">{{ ws.tally_company_count or 0 }}</td>
<td class="p-3 text-xs">{{ ws.last_seen_at_utc or '-' }}</td>
<td class="p-3">
{% if can_manage_branch_storage %}
<form method="post" action="/documents/storage-workstations/{{ ws.id }}/test-route">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="text-xs font-semibold text-blue-700">Test route</button>
</form>
{% else %}-{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="p-6 text-center text-slate-500">No workstation agent has connected since the Phase 1 upgrade.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b">
<h2 class="font-bold text-slate-900">Recent Workstation Agent Jobs</h2>
<p class="text-xs text-slate-500 mt-1">Phase 2 jobs are durable, routed to one workstation, leased for safe retry, and protected by an idempotency key.</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="bg-slate-50 text-slate-600"><tr><th class="p-2 text-left">Job</th><th class="p-2 text-left">Workstation</th><th class="p-2 text-left">Action</th><th class="p-2 text-left">Status</th><th class="p-2 text-left">Attempts</th><th class="p-2 text-left">Created</th></tr></thead>
<tbody>
{% for job in recent_agent_jobs or [] %}
<tr class="border-t">
<td class="p-2 font-mono">{{ job.job_uuid[:10] }}...</td>
<td class="p-2">{{ job.workstation.machine_name if job.workstation else ('#' ~ job.workstation_agent_id) }}</td>
<td class="p-2">{{ job.action }}</td>
<td class="p-2"><span class="font-semibold">{{ job.status }}</span>{% if job.last_error %}<div class="text-red-600 mt-1">{{ job.last_error }}</div>{% endif %}</td>
<td class="p-2">{{ job.attempts }}/{{ job.max_attempts }}</td>
<td class="p-2">{{ job.created_at_utc or '-' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="p-4 text-center text-slate-500">No Phase 2 workstation jobs yet. Use Test route on a connected workstation to verify routing.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b"><h2 class="font-bold text-slate-900">Recent Storage Jobs</h2></div>
<table class="w-full text-xs">
<thead class="bg-slate-50 text-slate-600"><tr><th class="p-2 text-left">Job</th><th class="p-2 text-left">Status</th><th class="p-2 text-left">Created</th></tr></thead>
<tbody>
{% for job in recent_jobs or [] %}
<tr class="border-t"><td class="p-2">#{{ job.id }}</td><td class="p-2">{{ job.status }}</td><td class="p-2">{{ job.created_at_utc or '-' }}</td></tr>
{% else %}
<tr><td colspan="3" class="p-4 text-center text-slate-500">No recent storage jobs.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b"><h2 class="font-bold text-slate-900">Recent Download Requests</h2></div>
<table class="w-full text-xs">
<thead class="bg-slate-50 text-slate-600"><tr><th class="p-2 text-left">Request</th><th class="p-2 text-left">Status</th><th class="p-2 text-left">Created</th></tr></thead>
<tbody>
{% for dr in recent_download_requests or [] %}
<tr class="border-t"><td class="p-2">#{{ dr.id }}</td><td class="p-2">{{ dr.request_status }}</td><td class="p-2">{{ dr.created_at_utc or '-' }}</td></tr>
{% else %}
<tr><td colspan="3" class="p-4 text-center text-slate-500">No recent download requests.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}