Skip to content

Add Remote Proxy & Network Devices UI - #50

Open
teknoprep wants to merge 43 commits into
amidaware:masterfrom
teknoprep:feature/remote-proxy
Open

teknoprep wants to merge 43 commits into
amidaware:masterfrom
teknoprep:feature/remote-proxy

Conversation

@teknoprep

@teknoprep teknoprep commented Jun 10, 2026

Copy link
Copy Markdown

Remote Proxy & Network Devices (frontend)

Companion UI for the backend PR in amidaware/tacticalrmm.

  • Agent right-click → Remote Proxy: one popup window with a protocol selector (HTTPS / HTTP / VNC / SSH / Telnet), address + port bar, and the device rendered below — web UIs in an iframe, VNC via MeshCentral's built-in noVNC viewer (relayed through the agent, no client bundled), SSH/Telnet in an xterm.js terminal.
  • New Network Devices tab next to Servers/Workstations/Mixed: per-client/site table of registered devices with Add/Edit/Delete and a one-click Connect. The Add/Edit dialog includes name/protocol/IP/port/description and an ordered preferred-agent picker (↑/↓), and defaults the site/client from the current dashboard scope.

Reuses the existing @xterm/xterm dependency. Developed against v0.101.59 (master); happy to rebase onto develop (maintainer_can_modify enabled).

…devices

Adds a right-click 'Remote Proxy' menu item that opens a popup mini-browser
(protocol/address/port bar + iframe) which reaches a device's web UI on the
agent's network, proxied through the agent by the backend.
Popup terminal (connection bar + xterm.js) that opens an SSH or Telnet session
to a device on the agent's network, bridged through the backend over the agent.
Merge the separate Remote Terminal view into Remote Proxy. One menu item opens
one window where the protocol dropdown (HTTPS/HTTP/SSH/Telnet) switches between
the web iframe (http/https) and the xterm terminal (ssh/telnet).
New 'Network Devices' tab in the dashboard (next to Servers/Workstations/Mixed)
showing a per-scope devices table with Add/Edit/Delete and a Connect action that
opens the unified Remote Proxy popup via the resolved agent. Add/Edit dialog
includes name/protocol/ip/port/description and an ordered preferred-agent
picker (up/down reorder).
When adding a device while on a Site, that site is preselected; while on a
Client, the client's first site is preselected. Only on All Clients must the
user choose a site.
Previously the popup ran on the frontend domain while the proxied device loaded
from the API domain, making it a cross-site (third-party) iframe. Browsers drop
third-party cookies, so apps that set an auth cookie (Proxmox/PBS, pfSense)
logged in then immediately kicked the user out. Load the iframe from the popup's
own origin (also served at /agentproxy/ on the frontend vhost) so the device
gets first-party cookies and sessions persist.
@CLAassistant

CLAassistant commented Jun 10, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ teknoprep
❌ Pi Agent


Pi Agent seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

The dashboard tab is bound to defaultAgentTblTab, so selecting the Network
Devices tab sets it to netdevice. A tree (client/site) selection then
dispatches refreshDashboard -> loadAgents, which appended
monitoring_type=netdevice to GET /agents/. The agents API only accepts
server/workstation, so it returned 400 (monitoring type does not exist).

Network Devices are served by the /netdevices/ endpoint and the agents
table is not rendered on that tab, so skip the /agents/ query entirely when
defaultAgentTblTab is netdevice.
Adds a VNC protocol to the unified Remote Proxy window. Connects to a device's
VNC server on the agent's LAN by reusing MeshCentral's bundled noVNC viewer
(relayed through the agent), so no VNC client is bundled. fetchWebProxyVNCUrl
calls the backend WebVNC endpoint with the device address; the viewer URL is
shown in the same iframe used for HTTP/HTTPS.
@teknoprep

Copy link
Copy Markdown
Author

Summary of what this PR delivers (web UI)

Frontend for the Remote Proxy and Network Devices features (backend: amidaware/tacticalrmm#2485).

Remote Proxy window (views/RemoteProxy.vue)

A single window with a protocol selector — HTTPS / HTTP / VNC / SSH / Telnet — to reach a device on an agent's LAN through the agent:

  • HTTP/HTTPS: device UI in an iframe served from the app origin (so first-party cookies persist), backed by the /agentproxy/<token>/ proxy + its service worker.
  • VNC: reuses MeshCentral's bundled noVNC viewer via the backend WebVNC endpoint (?addr=<device-ip>) — no VNC client bundled.
  • SSH/Telnet: xterm.js terminal over the agent tunnel.
  • Auto port defaults per protocol, reload + open-in-new-tab controls.

Network Devices tab

UI to register/manage devices that can't run an agent (name, IP, protocol/port, preferred agents), with Connect launching the Remote Proxy. Defaults site/client from the current dashboard scope.

Fix included

store: don't issue a /agents/?monitoring_type=netdevice query on the Network Devices tab — netdevice isn't a valid agent monitoring_type, so the agents API returned 400. The Network Devices tab is served by /netdevices/ and the agents table isn't shown there, so the query is skipped.

No new runtime dependency is added (VNC reuses MeshCentral's viewer rather than bundling noVNC).

teknoprep and others added 19 commits July 12, 2026 17:44
… bulk AI commands, role-based model access, dynamic helpdesk ticketing

- Device chat popup (PiChat.vue) with tool approval cards, steering, resume
- AI History tab per device; AI Tasks tab + overview; Bulk AI Commands
- Global Settings -> Pi.dev AI: providers, models, defaults, toggles
- Helpdesk Ticket Policy: admin-defined prompt + generic authenticated
  ticketing API tool (helpdesk_api_request); API key substituted server-side,
  never exposed to the model
- Role-based access: can_use_ai, model allow-lists, mutate/autoapprove rights
…hen a client/site is selected; remove right-click popup
New schedule kind in AIReportSchedules.vue with its own options (all-teams,
"AI writes a review per technician", "AI audits the data each run"), plus
sendTechProductivityNow() in api/core.ts for an ad-hoc run.

The options caption states plainly that every figure is computed in code and the
AI cannot change any of them - the audit only flags what should not be concluded.
…the window

The Global Settings dialog is sized `min-width: 60vw` with no maximum, so any
child wider than the viewport drags the card out past the edge of the browser
window - the frame ends up bigger than the popup and the right-hand side becomes
unreachable. The scheduled-reports table was that child: it carries report names,
the report-type caption under each one, recipient lists and the whole "last
result" sentence ("sent to chris@..., fred@... - 5 techs, 323 closed, 344h 44m
tech time, 939 calls ..."), and none of it was allowed to wrap, so the table
demanded the width. Adding a third report type with a long label made it obvious.

- The table gets `wrap-cells` and per-column min/max widths, so long text wraps
  instead of setting the table width, and the "last result" sentence wraps too.
- It sits in a `max-width: 100%; overflow-x: auto` box, so even an unbreakable
  token scrolls inside the table rather than moving the dialog.
- The dialog itself is capped at `max-width: 95vw`. That is the general fix: no
  future tab can push the frame outside the window either.
Paste a log extract, a command, an indented block or a list into the device chat
or the ai-decision window and it looked right in the textarea, then collapsed into
one unreadable paragraph the instant you hit enter.

The assistant's bubble renders through an inner `.pi-text`, which carries
`white-space: pre-wrap`. The user's own bubble did not, so the browser collapsed
every run of whitespace and dropped the newlines. Same for the system/info line.
Both now use `.pi-text`.

Display only: send() does `input.value.trim()`, which strips the ends and leaves
internal newlines alone, and the same string goes to the bubble and to the
websocket - so the model was always receiving the formatting correctly. Only the
transcript you were reading was wrong.

Both surfaces are this one component: /ai-decision/:token routes to PiChat too.
Search for a model that does not exist in Global Settings > Pi.dev AI > add model
and the search box was gone for good - you were left with the manual "type it
yourself" input and could not get back without closing and reopening the dialog.

The select's own existence was bound to the FILTERED list:

  <q-select v-if="availableForProvider.length > 0" ... @filter="filterAvailable">

availableForProvider applies the type-ahead filter, so a term matching nothing
emptied it, which flipped the v-if false and unmounted the select mid-search,
swapping in the v-else manual input. The filter string survived the unmount, so
the computed stayed empty and the select could never re-mount. It also meant the
#no-option slot ("No models found for this key") was unreachable - the component
was destroyed before it could ever render that message.

The select now keys off providerHasModels - whether this provider's key exposes
ANY models at all, before filtering - so a fruitless search leaves it mounted and
simply shows the empty-result message, which now distinguishes "nothing matches
your search" (naming the term, and how many models are there to see) from "this
key exposes no models". The filter is also cleared when the provider changes or
the dialog is opened, so a stale term cannot hide the next provider's models.
Twelve modified files, 831 insertions, that existed only in the working tree of this
box. Missed in this morning's sync, which covered the API and the bridge but not the
web repo -- so the AI user interface was the one part of the stack still unbacked.

Contents: PiChat.vue and the AI history/tasks tabs, the AI settings and report-schedule
modals, AIProcedures and AITicketConsole views, aiCompletionAlerts, RolesForm (AI
permissions), FileBar, and the api/agents + api/core clients they call.

Committed on top of 0.101.59, which is what this checkout is. Note the deployed
frontend is 0.101.64, a prebuilt bundle downloaded by the updater, so this source tree
is BEHIND what is actually serving. Reconciling that is a separate step and should not
be conflated with getting these files into git.
The brand is now src/branding/ -- product name, both logos, the square logo for PDFs,
the favicon master and the Quasar palette -- and upstream files reference it.

The first version of this did the obvious thing and edited upstream in place:
quasar.variables.sass got our colours, MainLayout.vue and LoginView.vue got our name
inline, and src/assets/{logo,trmm_256}.png plus public/favicon.ico were OVERWRITTEN.
That works and it is the wrong shape. Every one of those files belongs to amidaware, so
every future release becomes a merge conflict about our logo, and an overwritten binary
asset is a change git cannot show meaningfully -- the upstream file is simply gone. Those
three assets are restored to upstream's versions here and ours live in the module.

Footprint in upstream files is now 28 lines across five files: a sass @import, two
component imports, two element swaps, productName, and the favicon copy.

Colours came from the compiled stylesheet on www.blueuc.com rather than being matched by
eye -- primary #00C4FF, dark #1B1319. $secondary is the darker cyan #007294 the site uses
for hover states, because Quasar's default teal made every button darken AWAY from the
brand when hovered.

Two logos, deliberately: the toolbar is dark and the login card and generated PDFs are
light, so a single logo is invisible in one place or the other.

Status colours are left as Quasar's defaults on purpose. Red means broken and green means
fine in every other tool a technician has open, and a house style is not worth making an
alert ambiguous.

public/favicon.ico is a copy rather than an import because Quasar copies public/ verbatim
and does not resolve imports there; `npm run brand:sync` refreshes it from the module so
the master stays in one place.
…toring Reporting

I broke Reporting. Branding was applied at source level and the resulting build was
deployed over /var/www/rmm/dist, which discarded the official bundle -- and the official
bundle is the only thing that has Reporting in it.

The public source ships src/boot/integrations.ts with empty arrays, so a source build has
no Reporting Manager and falls back to the "requires a Tier 2 or higher sponsorship"
notice. The Tier 2 sponsorship was never the problem: the token is valid and in use, and
`manage.py get_webtar_url` authenticates with it to fetch a bundle that does contain the
EE reporting UI. My build simply lacked it. The backend was never gated at all --
ee/reporting/permissions.py checks ordinary role permissions, and the database still held
20 report templates and 11 HTML templates throughout.

Fix: the official webtar is deployed, and branding is applied ON TOP as a post-build pass
(src/branding/apply-to-dist.py) -- product name, palette, logo and favicon, with images
matched by CONTENT because the bundler renames assets to content hashes.

Deliberately NOT done: populating integrations.ts to switch Reporting on from source.
That would be working around the licence-key mechanism the EE licence explicitly
prohibits, and it would misrepresent what the sponsorship covers. If a feature is not in
the bundle Amidaware served, this tooling leaves it absent.

The script must be re-run after every update.sh, since the updater replaces dist wholesale.
It warns when pointed at a bundle with no Reporting UI, because that means a source build
was deployed and branding would otherwise hide the real fault behind a cosmetic success.
Network Devices, PiChat, AI Ticket Console and AI Procedures disappeared because I
deployed Amidaware's official webtar to get the EE Reporting UI back. That bundle is
their build and knows nothing about our views -- they exist only in this repo. Straight
regression, caused this morning, on a tab used daily.

Rebuilt from source and redeployed, so the custom views are back and the branding is
back with them.

Reporting is absent again, which is what was asked for once the trade-off was visible.
Worth recording that Reporting was NOT in the Aug 4 build either: it had our views and no
reporting UI, so choosing this side loses nothing that was working. Report data is
untouched regardless -- 20 templates and 6 AI report schedules sit in the database and the
schedules run server-side.

What I did NOT do, again: register EE reporting by hand in src/boot/integrations.ts. The
EE licence prohibits working around that mechanism, and a valid Tier 2 token does not
change what the licence says about the method.

README now states which bundle is deployed, the trade-off table, and the exact deploy
steps -- including `env-config.js`, which the build does not produce and which I dropped
earlier today, leaving the frontend with no API URL.
The old favicon was the website logo scaled down. That logo is WHITE on transparent, so
in a light tab strip it was very nearly invisible -- which is also the likely reason the
login-card logo "disappeared": it was rendering, in white, on a white card. Both problems
have the same root cause and neither was a missing file.

Now generated rather than resized (src/branding/make-favicon.py):

  * baby blue discs, #7DDFFF with a #00AEE3 rim, so there is a solid high-contrast
    silhouette on light AND dark browser themes, and a defined edge against white;
  * three overlapping discs, echoing the cloud built from arcs in the website mark rather
    than a shape invented for the icon;
  * a white T drawn from RECTANGLES, not set in a font -- at 16px a glyph turns to mush
    while a geometric T stays legible. Chunky proportions for the same reason;
  * drawn at 512px and downsampled, which antialiases far better than drawing at 16;
  * six sizes in the ICO, 16 through 256.

Verified at tab size before shipping, not after: rendered to a 22x22 ASCII preview to
confirm the T still reads and sits inside the circles rather than hanging off the bottom,
which the first attempt did.

Also replaced the hashed copy the bundler emitted (50b17c72.ico), not just the one at the
dist root -- otherwise the browser keeps serving the old icon from the hashed path.
…yeballed

The login-card logo was white on a white card. It was rendering perfectly and could not
be seen, which is why it read as "you removed the logo". The toolbar had the same fault
waiting: with $primary now brand cyan, a white mark on it measures 2.03:1, under the 3:1
WCAG minimum for large graphics.

Contrast ratios, computed not guessed:

    white on cyan toolbar    2.03:1  FAIL
    white on white card      1.00:1  FAIL   <- the reported bug
    dark  on cyan toolbar    8.96:1  pass
    dark  on white card     18.20:1  pass
    white on dark card      16.67:1  pass

So both surfaces follow one rule: dark mode gets the white mark, light mode gets the
near-black one. #1B1319 rather than plain black, since that is the brand's own dark
(--o-color-5 on the website) and keeps the mark on-brand on a light surface.

logo-dark.png and logo-cyan.png are generated from the white original by keeping the
alpha channel and replacing RGB. The source mark is pure white on transparent, so its
shape lives entirely in alpha -- no thresholding, no halo, antialiased edges intact.

Source builds switch with $q.dark.isActive. The dist post-processor does the same for the
official bundle via .body--dark, so both paths behave identically instead of drifting.
Replaces the white T on baby-blue circles. Blue field, black B, so it is identifiable as
ours at a glance in a tab strip.

  * solid brand-cyan #00C4FF rounded square rather than a circle -- it fills more of the
    tile at 16px, so the icon is easier to pick out among other tabs;
  * B set in DejaVu Sans Bold rather than drawn from primitives. The previous T was drawn
    from rectangles because a T is trivial and stays crisp; a B is not, and curves and
    counters built from primitives look wrong long before they look crisp;
  * glyph sized by measurement, growing until it fills ~60% of the tile width, so it lands
    the same way regardless of the font's own metrics rather than depending on a guessed
    point size;
  * black is #1B1319, the brand's near-black, not plain #000. On brand cyan that measures
    8.96:1, well past the 3:1 WCAG minimum for large graphics;
  * drawn at 512px and downsampled, six sizes in the ICO.

Checked at tab size before shipping by rendering a 22x22 ASCII preview, the same way the
T was checked -- the counters stay open and the B is still a B.

The hashed copy the bundler emits (f6f730a1.ico) was replaced along with the root one.
Only doing the root leaves the browser serving the old icon from the hashed path.
Pi AI and others added 16 commits August 17, 2026 17:03
A toggle beside Auto-approve, shown only when the role carries
can_use_ai_autocredential, that lets Pi use a stored IT Notebook login
without stopping to ask for each retrieval.

Deliberately its own switch rather than a side effect of Auto-approve:
approving a device change and handing over a live password are different
decisions, and the person making them should see them separately.

The server stays authoritative - autocredential_state comes back from the
socket, so a role without the permission sees the switch snap back rather
than being told something untrue. Remembered per user like Auto-approve, so
a refresh does not silently revert it. Privileged rows still prompt every
time, and the chat says so when the switch goes on.
A box at the top of the chat window for the technician's own name for the
conversation, and a sortable Label column in AI History placed ahead of
Summary - because that is the column you actually scan when hunting for a
past session, and the last-message snippet rarely tells you what the work was.

Saved on blur/Enter rather than per keystroke, so typing does not chatter over
the socket, and only when the value actually changed (blur fires on every
click away). The label is also mirrored into the window title, so a desktop
full of Pi popups becomes navigable.

Task/bulk/scheduled rows have nobody to type a label and render a dash.
Toolbar button in Pi Chat and AI Decision (shown only when the server says the
role, the global switch and a configured relay all allow it), a QR pairing
dialog, and the Remote (mobile) section in Global Settings -> Pi.dev AI plus the
role checkbox.

Three states, not two: 'Remote' / 'Remote: waiting' / 'Remote: <device>'. On and
'a phone is actually attached' are different facts, and conflating them is how
someone walks away believing they are reachable when nothing is listening. Every
state change comes from a remote_state frame - the button never assumes.

Messages typed on the phone appear in this transcript tagged with the device, and
an approval answered from the phone clears the on-screen banner. Two surfaces
driving one session must not be able to hide turns from each other.

Relay URL validation mirrors the extension: http(s) only, ws(s) refused with a
targeted message rather than silently coerced.
…es the rule

The switch was decision-window only because Pi Chat had no credential path at
all. It has one now (same permission, same gate, same audit line), so the toggle
is no longer gated on isDecision.

The tooltip said privileged rows "still ask you every time", which is no longer
the whole truth and was the sentence that made the feature look broken: it asks
when the AI wants a privileged row on its own initiative, and stops asking once
you have asked for the admin login yourself in the chat.
… the phone can see

The bridge now owns the window's switches as typed commands so a paired phone
can reach them (the Remote Pi app renders a chat and nothing else). This is the
browser half.

- A slash-command menu above the input, fed by `ready.commands` - the SERVER
  decides what is offered, so the list can never present a switch this role
  does not carry as if it would work. A refused command is still listed, greyed
  with a lock and the reason: hiding it makes a permission look like a missing
  feature and the technician goes hunting for a bug.
- Up/Down to move, Tab or Enter to complete, Esc to dismiss, click to pick. The
  badge shows live ON/OFF from the toolbar refs, not the server's snapshot, so
  it cannot go stale after a toggle.
- `system_note` renders in the transcript. That is how a switch flipped ON THE
  PHONE appears at the desk - two people driving one machine on different
  assumptions about Write mode is the failure this prevents.
- A window command answers in a millisecond and never reaches the model, so it
  no longer raises the streaming spinner (with `system_note` clearing it as a
  backstop for an aliased spelling).
- Dropped the local system lines for autocredential_state and allow_email_state:
  the bridge narrates those once now, for both surfaces.
- The Remote pairing dialog's first click works, so the placeholder can finally
  advertise "/ for commands".

FIX: `allow_email_state` printed "Switched to READ-ONLY mode" / "Switched to
WRITE mode" - a copy-paste from the readonly branch. Toggling customer email
told you the opposite thing about a different switch entirely.
The bridge decides which model a window opens on - it reopens on whatever the
conversation was last using, which is not necessarily the global default the
session endpoint handed the browser. The picker was set from the mint response
only, so it sat on the default while the session ran on something else.

It now takes the model from the ready frame, and puts a line in the transcript
when the model was remembered, or when the chat's last model is not available to
this role and the default was used instead.
The ready frame now carries switches_restored and switches_denied. Both are put in
the transcript: a restored Write mode is worth knowing, and a DENIED one is worth
knowing more - a technician who thinks Write mode is still on will not understand
why their next instruction is refused.
UI half of work running in production since 2026-08-26, never committed.

- PiChat.vue: every switch and session action moves into a ☰ Options menu; the bar
  keeps only what you read at a glance (label, cost, model, alerts, connection).
  Agent group / model picker as one "Model / group" select. "Summarize & clear
  history" with a confirm dialog. Reopens on what this person last picked.
- AISettings.vue + api/core.ts: manage AI agent groups (orchestrator + specialists).
- AIAgentGroups.vue: the editor dialog.
…to the tooltip

Backend half is tacticalrmm a4e512aa ("the cost meter is per conversation, not per
device"). The chip now starts at $0.00 in a new chat instead of showing every dollar
every chat had spent on the machine. The lifetime figure is not lost: the tooltip
gains "This device, all chats: $X - N turns" (or "This ticket" on the ticket chat),
driven by window_scope / window_cost / window_turns on cost_update. null = the server
could not read the ledger, so the line is omitted rather than showing a wrong $0.00.
A "Queue" button in the bar opens a panel to the right of the chat (over it on a
narrow window) with every queue control: Auto-Next, add (with "Compact & clear
first"), Run next, Pause/Resume, Clear done/all, and the list - edit (double-click or
pencil, Ctrl+Enter saves), move up/down, toggle compact-first, skip / queue again,
delete. Pending count on the button; colour says running / paused / idle.

The bridge owns the state (queue_state frames, tacticalrmm 8934f2e9); everything here
is a request to it. A queued prompt shows as the tech's own bubble tagged "from the
queue". A new pause is written into the transcript once, with the reason; when it is
the assistant asking, the panel opens so the question is in view.

Only the panel's open/closed state lives in this browser (localStorage).
Waiting items (orange) show the assistant's question and an answer box; the exchange
is kept on the item as a thread. Replies show in the transcript as "answered in the
queue".
Drag the grip to widen or narrow it (300px up to the window minus 360px of chat;
anything when floating). Double-click resets to 400px. Width is remembered in this
browser like open/closed.
The action row had four tiny buttons fighting for 400px, with "Clear all" as small red
text on dark grey. Now: destructive things (Clear finished / Clear everything) and Reset
width live in a header menu at normal size; Run next and Pause/Resume are two full-width
buttons; the two switches share one row with short labels (detail in tooltips); one
status line says what the queue is doing; per-item tools are move up/down plus one menu
(Edit, Compact first, Skip/Queue again, Remove). Helper paragraphs cut to a sentence,
thread turns labelled ASKED / YOU, status icon has a plain-English tooltip.
The Answer box under a waiting item had a labelled button below it that was easy to
miss; now it looks like the main chat input: textarea with a round send button beside
it, Enter sends, Shift+Enter is a newline. Same shape for the Add box (round +).
- Questions from the assistant are cards at the top of the panel (asked text, which item
  it is about, answer box with a send triangle, dismiss). Items no longer carry an answer
  box; a waiting item points up. An item's past Q&A is folded behind "show N Q&A".
- History button opens a dialog listing everything the queue did, newest first, with the
  answers given and where; Clear history with confirm. Never sent to the AI.
- The panel opens by itself when a chat comes up with anything queued, and whenever a
  new question arrives. Closing it by hand sticks for that page load.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants