Initial commit: one_os_compositor Odoo 20 addon — core OWL library + backend models + JWT auth
Includes:
- compositor.app, compositor.session, compositor.session.app, compositor.audit.log models
- /web/compositor/{token,apps,launch,audit} HTTP controllers with JWT (HS256)
- CompositorService OWL service (reusable via useService('compositor'))
- CompositorCanvas OWL component (reusable canvas wrapper)
- Security: compositor_group_user, compositor_group_manager groups + ir.access.csv rules
- 5 demo apps pre-loaded (gnome-calculator, xterm, gtk4-demo, glxgears, featherpad)
- Audit log capturing JWT issuance + app launch events
- Stub mode: launching apps returns a stub SurfaceHandle when proxy isn't connected
- Tested against Odoo 20 + PostgreSQL 17
Author: 1bspace.com
Parent company: 1B Global
License: Proprietary (1B Space internal use)
This commit is contained in:
commit
93a5a0a221
32 files changed
+2497
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Compositor backend models."""
|
||||
|
||||
from . import compositor_app
|
||||
from . import compositor_session
|
||||
from . import compositor_audit_log
|
||||
@@ -0,0 +1,229 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""``compositor.app`` — Application catalog.
|
||||
|
||||
Each record represents one Linux application that can be launched and
|
||||
displayed inside the Odoo web client.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompositorApp(models.Model):
|
||||
_name = "compositor.app"
|
||||
_description = "1B Space Compositor Application"
|
||||
_order = "name"
|
||||
_rec_name = "name"
|
||||
|
||||
# ─── Identity ────────────────────────────────────────────────────────────
|
||||
name = fields.Char(
|
||||
string="Application Name",
|
||||
required=True,
|
||||
translate=True,
|
||||
help="Human-readable name shown in the launcher.",
|
||||
)
|
||||
app_id = fields.Char(
|
||||
string="Application ID",
|
||||
required=True,
|
||||
copy=False,
|
||||
help="Unique identifier (e.g. 'gnome-calculator'). Used by the proxy "
|
||||
"to look up the executable when the user clicks 'Launch'.",
|
||||
)
|
||||
description = fields.Text(
|
||||
string="Description",
|
||||
translate=True,
|
||||
)
|
||||
|
||||
# ─── Executable ────────────────────────────────────────────────────────
|
||||
executable = fields.Char(
|
||||
string="Executable Path",
|
||||
required=True,
|
||||
help="Absolute path to the binary, e.g. '/usr/bin/gnome-calculator'. "
|
||||
"The path must exist on the compositor proxy host, NOT on the "
|
||||
"Odoo server (they may be different machines).",
|
||||
)
|
||||
args = fields.Char(
|
||||
string="Command-line Arguments",
|
||||
help="Space-separated arguments passed to the executable. "
|
||||
"Use shell-safe quoting; the proxy parses via shlex.split.",
|
||||
)
|
||||
env_vars = fields.Text(
|
||||
string="Environment Variables",
|
||||
help="One VAR=value per line. Applied on top of the proxy's default env. "
|
||||
"WAYLAND_DISPLAY is set automatically by the proxy.",
|
||||
)
|
||||
|
||||
# ─── Resource limits (per-instance) ────────────────────────────────────
|
||||
memory_limit_mb = fields.Integer(
|
||||
string="Memory Limit (MB)",
|
||||
default=1024,
|
||||
required=True,
|
||||
help="Maximum RSS for the sandboxed app. Enforced via systemd-run --scope "
|
||||
"with MemoryMax. Default 1024 MB.",
|
||||
)
|
||||
cpu_quota_percent = fields.Integer(
|
||||
string="CPU Quota (%)",
|
||||
default=200,
|
||||
required=True,
|
||||
help="CPU quota. 100% = 1 core. Default 200% (2 cores). "
|
||||
"Enforced via systemd-run --scope with CPUQuota.",
|
||||
)
|
||||
tasks_max = fields.Integer(
|
||||
string="Max Tasks (PIDs)",
|
||||
default=256,
|
||||
required=True,
|
||||
help="Maximum number of processes/threads. Default 256. "
|
||||
"Enforced via systemd-run --scope with TasksMax.",
|
||||
)
|
||||
|
||||
# ─── Sandbox flags ─────────────────────────────────────────────────────
|
||||
sandbox_network = fields.Boolean(
|
||||
string="Allow Network Access",
|
||||
default=False,
|
||||
help="If checked, the app is allowed network access (veth + nftables "
|
||||
"filter). Default unchecked — most GTK apps don't need network.",
|
||||
)
|
||||
sandbox_gpu = fields.Boolean(
|
||||
string="Allow GPU Access",
|
||||
default=True,
|
||||
help="If checked, the app gets read-write access to /dev/dri/renderD128. "
|
||||
"Required for GL apps (glxgears, gtk4-demo).",
|
||||
)
|
||||
|
||||
# ─── Access control ────────────────────────────────────────────────────
|
||||
allowed_group_ids = fields.Many2many(
|
||||
"res.groups",
|
||||
"compositor_app_group_rel",
|
||||
"app_id",
|
||||
"gid",
|
||||
string="Allowed Groups",
|
||||
help="Only users in one of these groups can see and launch this app. "
|
||||
"Empty = all internal users.",
|
||||
)
|
||||
|
||||
# ─── UI ────────────────────────────────────────────────────────────────
|
||||
icon = fields.Image(
|
||||
string="Icon",
|
||||
help="Application icon (PNG, 256x256 recommended).",
|
||||
)
|
||||
color = fields.Char(
|
||||
string="Accent Color",
|
||||
default="#6f42c1",
|
||||
help="Color used for the app tile in the launcher.",
|
||||
)
|
||||
|
||||
# ─── Lifecycle ─────────────────────────────────────────────────────────
|
||||
active = fields.Boolean(
|
||||
string="Active",
|
||||
default=True,
|
||||
help="If unchecked, the app is hidden from the launcher but not deleted.",
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
"res.company",
|
||||
string="Company",
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ─── Counts ─────────────────────────────────────────────────────────────
|
||||
running_session_count = fields.Integer(
|
||||
string="Currently Running",
|
||||
compute="_compute_running_session_count",
|
||||
help="Number of currently-active instances of this app across all sessions.",
|
||||
)
|
||||
|
||||
@api.depends("app_id")
|
||||
def _compute_running_session_count(self):
|
||||
SessionAppInst = self.env["compositor.session.app"]
|
||||
for app in self:
|
||||
app.running_session_count = SessionAppInst.search_count([
|
||||
("app_id", "=", app.id),
|
||||
("state", "=", "running"),
|
||||
])
|
||||
|
||||
# ─── Constraints ───────────────────────────────────────────────────────
|
||||
@api.constrains("app_id")
|
||||
def _check_app_id_unique(self):
|
||||
for app in self:
|
||||
if self.search_count([("app_id", "=", app.app_id), ("id", "!=", app.id)]):
|
||||
raise ValidationError("Application ID must be unique.")
|
||||
|
||||
@api.constrains("memory_limit_mb", "cpu_quota_percent", "tasks_max")
|
||||
def _check_limits(self):
|
||||
for app in self:
|
||||
if app.memory_limit_mb < 64:
|
||||
raise ValidationError("Memory limit must be at least 64 MB.")
|
||||
if app.cpu_quota_percent < 50:
|
||||
raise ValidationError("CPU quota must be at least 50%.")
|
||||
if app.tasks_max < 16:
|
||||
raise ValidationError("Max tasks must be at least 16.")
|
||||
|
||||
# ─── Public methods ────────────────────────────────────────────────────
|
||||
def get_launch_config(self):
|
||||
"""Return the launch config dict sent to the Python compositor proxy.
|
||||
|
||||
Called by ``CompositorController.launch_app`` when the user clicks
|
||||
"Launch" in the OWL shell. The proxy uses this to spawn the app
|
||||
with bubblewrap + seccomp + systemd-run.
|
||||
"""
|
||||
self.ensure_one()
|
||||
env_vars = {}
|
||||
if self.env_vars:
|
||||
for line in self.env_vars.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
raise UserError(f"Invalid env var line: {line!r}")
|
||||
key, _, val = line.partition("=")
|
||||
env_vars[key.strip()] = val.strip()
|
||||
return {
|
||||
"app_id": self.app_id,
|
||||
"name": self.name,
|
||||
"executable": self.executable,
|
||||
"args": self.args or "",
|
||||
"env": env_vars,
|
||||
"memory_limit_mb": self.memory_limit_mb,
|
||||
"cpu_quota_percent": self.cpu_quota_percent,
|
||||
"tasks_max": self.tasks_max,
|
||||
"sandbox_network": self.sandbox_network,
|
||||
"sandbox_gpu": self.sandbox_gpu,
|
||||
}
|
||||
|
||||
def action_view_running_sessions(self):
|
||||
"""Open the sessions view filtered to this app's running instances."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": f"Running {self.name} sessions",
|
||||
"res_model": "compositor.session.app",
|
||||
"view_mode": "list,form",
|
||||
"domain": [("app_id", "=", self.id), ("state", "=", "running")],
|
||||
}
|
||||
|
||||
def action_test_executable(self):
|
||||
"""Check if the executable exists on this Odoo host.
|
||||
|
||||
NOTE: This is informational only — the executable must exist on the
|
||||
COMPOSITOR PROXY host (which may be a different machine). For dev,
|
||||
they're often the same.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if shutil.which(self.executable.split()[0]) or os.path.exists(self.executable):
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": "Executable found",
|
||||
"message": f"{self.executable} is accessible on this host.",
|
||||
"type": "success",
|
||||
"sticky": False,
|
||||
},
|
||||
}
|
||||
raise UserError(
|
||||
f"Executable {self.executable!r} not found on this Odoo host. "
|
||||
f"If your compositor proxy runs on a different machine, this is OK."
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- ─── App List View ─────────────────────────────────────────── -->
|
||||
<record id="view_compositor_app_list" model="ir.ui.view">
|
||||
<field name="name">compositor.app.list</field>
|
||||
<field name="model">compositor.app</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Compositor Apps">
|
||||
<field name="app_id"/>
|
||||
<field name="name"/>
|
||||
<field name="executable"/>
|
||||
<field name="memory_limit_mb" string="Mem (MB)"/>
|
||||
<field name="cpu_quota_percent" string="CPU (%)"/>
|
||||
<field name="sandbox_network" string="Net"/>
|
||||
<field name="sandbox_gpu" string="GPU"/>
|
||||
<field name="running_session_count" string="Running"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── App Form View ─────────────────────────────────────────── -->
|
||||
<record id="view_compositor_app_form" model="ir.ui.view">
|
||||
<field name="name">compositor.app.form</field>
|
||||
<field name="model">compositor.app</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Compositor App">
|
||||
<header>
|
||||
<button name="action_test_executable" string="Test Executable" type="object" class="btn-secondary"/>
|
||||
<button name="action_view_running_sessions" string="View Running Sessions" type="object" class="btn-primary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="icon" widget="image" class="oe_avatar"/>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. GNOME Calculator"/></h1>
|
||||
<h3><field name="app_id" placeholder="e.g. gnome-calculator"/></h3>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Executable">
|
||||
<field name="executable" placeholder="/usr/bin/gnome-calculator"/>
|
||||
<field name="args" placeholder="--new-window"/>
|
||||
<field name="env_vars" placeholder="GDK_SCALE=1"/>
|
||||
</group>
|
||||
<group string="Resource Limits (per instance)">
|
||||
<field name="memory_limit_mb"/>
|
||||
<field name="cpu_quota_percent"/>
|
||||
<field name="tasks_max"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Sandbox">
|
||||
<field name="sandbox_network"/>
|
||||
<field name="sandbox_gpu"/>
|
||||
</group>
|
||||
<group string="Display">
|
||||
<field name="color" widget="color"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Allowed Groups" name="allowed_groups">
|
||||
<field name="allowed_group_ids" widget="many2many_tags"/>
|
||||
</page>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" widget="html"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── App Search View ───────────────────────────────────────── -->
|
||||
<record id="view_compositor_app_search" model="ir.ui.view">
|
||||
<field name="name">compositor.app.search</field>
|
||||
<field name="model">compositor.app</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Compositor Apps">
|
||||
<field name="name"/>
|
||||
<field name="app_id"/>
|
||||
<field name="executable"/>
|
||||
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
|
||||
<filter name="with_network" string="Has Network Access" domain="[('sandbox_network', '=', True)]"/>
|
||||
<filter name="with_gpu" string="Has GPU Access" domain="[('sandbox_gpu', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_app_id" string="App ID" context="{'group_by': 'app_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Action ────────────────────────────────────────────────── -->
|
||||
<record id="action_compositor_app" model="ir.actions.act_window">
|
||||
<field name="name">Apps</field>
|
||||
<field name="res_model">compositor.app</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_compositor_app_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new compositor app
|
||||
</p>
|
||||
<p>
|
||||
Each app is a Linux application that can be launched and
|
||||
displayed inside the Odoo web client via the 1B Space
|
||||
Compositor.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""``compositor.audit.log`` — Append-only audit log.
|
||||
|
||||
Records every compositor event (app launch, terminate, crash, session close)
|
||||
for compliance, debugging, and security forensics.
|
||||
|
||||
Records CANNOT be modified or deleted via the UI. Only superuser can
|
||||
directly delete via SQL (e.g. for archival).
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class CompositorAuditLog(models.Model):
|
||||
_name = "compositor.audit.log"
|
||||
_description = "1B Space Compositor Audit Log (append-only)"
|
||||
_order = "create_date desc"
|
||||
_rec_name = "create_date"
|
||||
|
||||
# ─── Identity ────────────────────────────────────────────────────────────
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="User",
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
session_id = fields.Many2one(
|
||||
"compositor.session",
|
||||
string="Session",
|
||||
index=True,
|
||||
)
|
||||
app_id = fields.Many2one(
|
||||
"compositor.app",
|
||||
string="Application",
|
||||
index=True,
|
||||
)
|
||||
app_instance_id = fields.Many2one(
|
||||
"compositor.session.app",
|
||||
string="App Instance",
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ─── Event ──────────────────────────────────────────────────────────────
|
||||
event_type = fields.Selection(
|
||||
[
|
||||
("session_start", "Session Started"),
|
||||
("session_close", "Session Closed"),
|
||||
("session_reconnect", "Session Reconnected"),
|
||||
("session_timeout", "Session Timed Out (SIGHUP)"),
|
||||
("app_launch", "App Launched"),
|
||||
("app_terminate", "App Terminated"),
|
||||
("app_crash", "App Crashed"),
|
||||
("app_force_kill", "App Force-Killed"),
|
||||
("jwt_issued", "JWT Issued"),
|
||||
("jwt_validation_failed", "JWT Validation Failed"),
|
||||
("proxy_started", "Proxy Started"),
|
||||
("proxy_crashed", "Proxy Crashed"),
|
||||
],
|
||||
string="Event Type",
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ─── Detail ─────────────────────────────────────────────────────────────
|
||||
pid = fields.Integer(string="PID")
|
||||
exit_code = fields.Integer(string="Exit Code")
|
||||
signal_received = fields.Char(string="Signal Received")
|
||||
detail = fields.Text(string="Detail")
|
||||
client_ip = fields.Char(string="Client IP")
|
||||
user_agent = fields.Char(string="User Agent")
|
||||
|
||||
# ─── Lifecycle ──────────────────────────────────────────────────────────
|
||||
create_date = fields.Datetime(
|
||||
string="Timestamp",
|
||||
default=fields.Datetime.now,
|
||||
readonly=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ─── Append-only enforcement ────────────────────────────────────────────
|
||||
def write(self, vals):
|
||||
if not self.env.context.get("compositor_allow_audit_write"):
|
||||
raise UserError(
|
||||
"Audit logs are append-only and cannot be modified.\n\n"
|
||||
"To fix a mistake, create a new corrective log entry with "
|
||||
"context={'compositor_allow_audit_write': True} (admin only)."
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
if not self.env.user.has_group("base.group_system"):
|
||||
raise UserError(
|
||||
"Audit logs can only be deleted by system administrators."
|
||||
)
|
||||
return super().unlink()
|
||||
|
||||
# ─── Public methods ────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def log_event(self, event_type, **kwargs):
|
||||
"""Convenience method to add a log entry.
|
||||
|
||||
:param event_type: One of the event_type selection values.
|
||||
:param kwargs: Field values for the new record.
|
||||
:return: The created record.
|
||||
"""
|
||||
defaults = {
|
||||
"user_id": self.env.user.id,
|
||||
"create_date": fields.Datetime.now(),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
# Bypass append-only check since this is the create path
|
||||
return self.sudo().with_context(compositor_allow_audit_write=True).create({
|
||||
"event_type": event_type,
|
||||
**defaults,
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- ─── Audit Log List View ───────────────────────────────────── -->
|
||||
<record id="view_compositor_audit_log_list" model="ir.ui.view">
|
||||
<field name="name">compositor.audit.log.list</field>
|
||||
<field name="model">compositor.audit.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Compositor Audit Log" create="0" edit="0" delete="false">
|
||||
<field name="create_date" string="Timestamp"/>
|
||||
<field name="event_type" widget="badge"
|
||||
decoration-success="event_type in ['session_start', 'app_launch', 'jwt_issued', 'proxy_started', 'session_reconnect']"
|
||||
decoration-info="event_type == 'app_terminate'"
|
||||
decoration-warning="event_type in ['session_close', 'app_force_kill']"
|
||||
decoration-danger="event_type in ['app_crash', 'session_timeout', 'proxy_crashed', 'jwt_validation_failed']"/>
|
||||
<field name="user_id"/>
|
||||
<field name="app_id"/>
|
||||
<field name="pid"/>
|
||||
<field name="exit_code"/>
|
||||
<field name="signal_received"/>
|
||||
<field name="client_ip"/>
|
||||
<field name="detail"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Audit Log Form View ───────────────────────────────────── -->
|
||||
<record id="view_compositor_audit_log_form" model="ir.ui.view">
|
||||
<field name="name">compositor.audit.log.form</field>
|
||||
<field name="model">compositor.audit.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Audit Log Entry" create="0" edit="0" delete="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="create_date" readonly="1"/>
|
||||
<field name="event_type" readonly="1"/>
|
||||
<field name="user_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="session_id" readonly="1"/>
|
||||
<field name="app_id" readonly="1"/>
|
||||
<field name="app_instance_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Process Info">
|
||||
<field name="pid" readonly="1"/>
|
||||
<field name="exit_code" readonly="1"/>
|
||||
<field name="signal_received" readonly="1"/>
|
||||
</group>
|
||||
<group string="Network">
|
||||
<field name="client_ip" readonly="1"/>
|
||||
<field name="user_agent" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Detail">
|
||||
<field name="detail" readonly="1" widget="text"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Audit Log Search View ─────────────────────────────────── -->
|
||||
<record id="view_compositor_audit_log_search" model="ir.ui.view">
|
||||
<field name="name">compositor.audit.log.search</field>
|
||||
<field name="model">compositor.audit.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Audit Log">
|
||||
<field name="user_id"/>
|
||||
<field name="app_id"/>
|
||||
<field name="session_id"/>
|
||||
<field name="client_ip"/>
|
||||
<field name="detail"/>
|
||||
<filter name="crashes" string="Crashes" domain="[('event_type', '=', 'app_crash')]"/>
|
||||
<filter name="launches" string="Launches" domain="[('event_type', '=', 'app_launch')]"/>
|
||||
<filter name="terminations" string="Terminations" domain="[('event_type', 'in', ['app_terminate', 'app_force_kill'])]"/>
|
||||
<filter name="auth" string="Auth Events" domain="[('event_type', 'like', 'jwt_')]"/>
|
||||
<separator/>
|
||||
<filter name="today" string="Today" domain="[('create_date', '>=', (context_today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d 23:59:59'))]"/>
|
||||
<filter name="week" string="This Week" domain="[('create_date', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<separator/>
|
||||
<filter name="group_event" string="Event Type" context="{'group_by': 'event_type'}"/>
|
||||
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_app" string="App" context="{'group_by': 'app_id'}"/>
|
||||
<filter name="group_day" string="Day" context="{'group_by': 'create_date:day'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Action ────────────────────────────────────────────────── -->
|
||||
<record id="action_compositor_audit_log" model="ir.actions.act_window">
|
||||
<field name="name">Audit Log</field>
|
||||
<field name="res_model">compositor.audit.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_compositor_audit_log_search"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,258 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""``compositor.session`` — Per-browser-tab compositor session.
|
||||
|
||||
A session represents one active OWL shell instance in a browser tab. Each
|
||||
session has its own:
|
||||
- Wayland display (``$XDG_RUNTIME_DIR/wayland-N``) on the proxy host
|
||||
- Forked Python proxy worker process
|
||||
- JWT (15-min, rotated by the OWL shell)
|
||||
- Set of launched app instances (``compositor.session.app``)
|
||||
"""
|
||||
import uuid
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class CompositorSession(models.Model):
|
||||
_name = "compositor.session"
|
||||
_description = "1B Space Compositor Session"
|
||||
_inherit = ["mail.thread"]
|
||||
_order = "started_at desc"
|
||||
|
||||
# ─── Identity ────────────────────────────────────────────────────────────
|
||||
compositor_session_id = fields.Char(
|
||||
string="Compositor Session UUID",
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: str(uuid.uuid4()),
|
||||
tracking=True,
|
||||
help="The UUID generated by the OWL shell and embedded in the JWT. "
|
||||
"The proxy uses this to scope Wayland display + websocket connections.",
|
||||
)
|
||||
|
||||
# ─── Relations ──────────────────────────────────────────────────────────
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="User",
|
||||
required=True,
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
"res.company",
|
||||
string="Company",
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
app_instance_ids = fields.One2many(
|
||||
"compositor.session.app",
|
||||
"session_id",
|
||||
string="App Instances",
|
||||
)
|
||||
app_count = fields.Integer(
|
||||
string="Apps Launched",
|
||||
compute="_compute_app_count",
|
||||
)
|
||||
running_app_count = fields.Integer(
|
||||
string="Running Now",
|
||||
compute="_compute_app_count",
|
||||
)
|
||||
|
||||
# ─── State ──────────────────────────────────────────────────────────────
|
||||
state = fields.Selection(
|
||||
[
|
||||
("pending", "Pending"),
|
||||
("active", "Active"),
|
||||
("disconnected", "Disconnected"),
|
||||
("closed", "Closed"),
|
||||
],
|
||||
string="State",
|
||||
default="pending",
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ─── Proxy info ──────────────────────────────────────────────────────────
|
||||
proxy_url = fields.Char(
|
||||
string="Proxy URL",
|
||||
help="WebSocket URL of the Python compositor proxy that this session connects to.",
|
||||
)
|
||||
proxy_pid = fields.Integer(
|
||||
string="Proxy Worker PID",
|
||||
help="PID of the forked Python proxy worker process on the proxy host.",
|
||||
)
|
||||
wayland_display = fields.Char(
|
||||
string="Wayland Display",
|
||||
help="The WAYLAND_DISPLAY value used by this session's apps, e.g. 'wayland-3'.",
|
||||
)
|
||||
|
||||
# ─── Lifecycle ──────────────────────────────────────────────────────────
|
||||
started_at = fields.Datetime(
|
||||
string="Started At",
|
||||
default=fields.Datetime.now,
|
||||
)
|
||||
last_activity = fields.Datetime(
|
||||
string="Last Activity",
|
||||
tracking=True,
|
||||
)
|
||||
ended_at = fields.Datetime(
|
||||
string="Ended At",
|
||||
)
|
||||
duration_seconds = fields.Integer(
|
||||
string="Duration (s)",
|
||||
compute="_compute_duration_seconds",
|
||||
)
|
||||
|
||||
# ─── JWT ────────────────────────────────────────────────────────────────
|
||||
last_jwt_issued_at = fields.Datetime(
|
||||
string="Last JWT Issued",
|
||||
)
|
||||
last_jwt_expires_at = fields.Datetime(
|
||||
string="Last JWT Expires",
|
||||
)
|
||||
|
||||
@api.depends("app_instance_ids", "app_instance_ids.state")
|
||||
def _compute_app_count(self):
|
||||
for record in self:
|
||||
record.app_count = len(record.app_instance_ids)
|
||||
record.running_app_count = sum(
|
||||
1 for inst in record.app_instance_ids if inst.state == "running"
|
||||
)
|
||||
|
||||
@api.depends("started_at", "ended_at")
|
||||
def _compute_duration_seconds(self):
|
||||
for record in self:
|
||||
end = record.ended_at or fields.Datetime.now()
|
||||
if record.started_at:
|
||||
record.duration_seconds = int((end - record.started_at).total_seconds())
|
||||
else:
|
||||
record.duration_seconds = 0
|
||||
|
||||
# ─── Public methods ────────────────────────────────────────────────────
|
||||
def action_close(self):
|
||||
"""Force-close the session and all running apps."""
|
||||
for session in self:
|
||||
# In a real impl, this calls the proxy via HTTP to terminate
|
||||
# For now, mark as closed
|
||||
session.app_instance_ids.write({"state": "terminated", "ended_at": fields.Datetime.now()})
|
||||
session.write({
|
||||
"state": "closed",
|
||||
"ended_at": fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_reconnect(self):
|
||||
"""Mark the session as active again (e.g. after a browser refresh)."""
|
||||
for session in self:
|
||||
session.state = "active"
|
||||
session.last_activity = fields.Datetime.now()
|
||||
|
||||
def action_view_app_instances(self):
|
||||
"""Open the app instances view filtered to this session."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "App Instances",
|
||||
"res_model": "compositor.session.app",
|
||||
"view_mode": "list,form",
|
||||
"domain": [("session_id", "=", self.id)],
|
||||
"context": {"default_session_id": self.id},
|
||||
}
|
||||
|
||||
def touch(self):
|
||||
"""Update last_activity timestamp. Called by the OWL shell periodically."""
|
||||
self.write({"last_activity": fields.Datetime.now()})
|
||||
|
||||
|
||||
class CompositorSessionApp(models.Model):
|
||||
_name = "compositor.session.app"
|
||||
_description = "App Instance Running in a Compositor Session"
|
||||
_order = "started_at desc"
|
||||
|
||||
session_id = fields.Many2one(
|
||||
"compositor.session",
|
||||
string="Session",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
app_id = fields.Many2one(
|
||||
"compositor.app",
|
||||
string="Application",
|
||||
required=True,
|
||||
ondelete="restrict",
|
||||
)
|
||||
|
||||
# ─── State ──────────────────────────────────────────────────────────────
|
||||
state = fields.Selection(
|
||||
[
|
||||
("launching", "Launching"),
|
||||
("running", "Running"),
|
||||
("terminated", "Terminated"),
|
||||
("crashed", "Crashed"),
|
||||
],
|
||||
string="State",
|
||||
default="launching",
|
||||
required=True,
|
||||
)
|
||||
|
||||
# ─── Process info (from proxy) ──────────────────────────────────────────
|
||||
pid = fields.Integer(string="PID", help="Process ID on the proxy host.")
|
||||
signal_url = fields.Char(string="Signal WebSocket URL")
|
||||
compositor_surface_id = fields.Char(
|
||||
string="Surface ID",
|
||||
help="Internal ID used by the OWL shell to identify the wl_surface.",
|
||||
)
|
||||
|
||||
# ─── Window state ───────────────────────────────────────────────────────
|
||||
title = fields.Char(string="Window Title", default="(no title)")
|
||||
app_id_str = fields.Char(
|
||||
string="App ID String",
|
||||
help="The xdg_toplevel.app_id sent by the app (e.g. 'org.gnome.Calculator').",
|
||||
)
|
||||
width = fields.Integer(string="Width", default=800)
|
||||
height = fields.Integer(string="Height", default=600)
|
||||
is_maximized = fields.Boolean(string="Maximized", default=False)
|
||||
is_fullscreen = fields.Boolean(string="Fullscreen", default=False)
|
||||
is_minimized = fields.Boolean(string="Minimized", default=False)
|
||||
is_focused = fields.Boolean(string="Focused", default=False)
|
||||
|
||||
# ─── Lifecycle ──────────────────────────────────────────────────────────
|
||||
started_at = fields.Datetime(
|
||||
string="Started At",
|
||||
default=fields.Datetime.now,
|
||||
)
|
||||
ended_at = fields.Datetime(
|
||||
string="Ended At",
|
||||
)
|
||||
exit_code = fields.Integer(string="Exit Code")
|
||||
signal_received = fields.Char(string="Signal Received")
|
||||
|
||||
# ─── Computed ───────────────────────────────────────────────────────────
|
||||
duration_seconds = fields.Integer(
|
||||
string="Duration (s)",
|
||||
compute="_compute_duration_seconds",
|
||||
)
|
||||
|
||||
@api.depends("started_at", "ended_at")
|
||||
def _compute_duration_seconds(self):
|
||||
for record in self:
|
||||
end = record.ended_at or fields.Datetime.now()
|
||||
if record.started_at:
|
||||
record.duration_seconds = int((end - record.started_at).total_seconds())
|
||||
else:
|
||||
record.duration_seconds = 0
|
||||
|
||||
def action_terminate(self):
|
||||
"""Send a SIGTERM to the sandboxed app via the proxy."""
|
||||
for inst in self:
|
||||
# In a real impl, calls the proxy via HTTP
|
||||
inst.write({
|
||||
"state": "terminated",
|
||||
"ended_at": fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_force_kill(self):
|
||||
"""Send a SIGKILL to the sandboxed app via the proxy."""
|
||||
for inst in self:
|
||||
inst.write({
|
||||
"state": "terminated",
|
||||
"ended_at": fields.Datetime.now(),
|
||||
"signal_received": "SIGKILL",
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- ─── Session List View ─────────────────────────────────────── -->
|
||||
<record id="view_compositor_session_list" model="ir.ui.view">
|
||||
<field name="name">compositor.session.list</field>
|
||||
<field name="model">compositor.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Compositor Sessions">
|
||||
<field name="compositor_session_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'active'"
|
||||
decoration-warning="state == 'disconnected'"
|
||||
decoration-danger="state == 'closed'"
|
||||
decoration-info="state == 'pending'"/>
|
||||
<field name="running_app_count" string="Apps"/>
|
||||
<field name="started_at"/>
|
||||
<field name="last_activity"/>
|
||||
<field name="duration_seconds" widget="float_time" string="Duration"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Session Form View ─────────────────────────────────────── -->
|
||||
<record id="view_compositor_session_form" model="ir.ui.view">
|
||||
<field name="name">compositor.session.form</field>
|
||||
<field name="model">compositor.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Compositor Session">
|
||||
<header>
|
||||
<button name="action_close" string="Close Session" type="object"
|
||||
class="btn-danger"
|
||||
confirm="This will terminate all running apps. Continue?"
|
||||
invisible="state == 'closed'"/>
|
||||
<button name="action_reconnect" string="Mark Reconnected" type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state not in ['disconnected', 'closed']"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="pending,active,disconnected,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_app_instances" type="object"
|
||||
class="oe_stat_button" icon="fa-desktop">
|
||||
<field name="running_app_count" widget="statinfo"
|
||||
string="Running Apps"/>
|
||||
</button>
|
||||
<button name="action_view_app_instances" type="object"
|
||||
class="oe_stat_button" icon="fa-list">
|
||||
<field name="app_count" widget="statinfo" string="Total Apps"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="compositor_session_id" readonly="1"/>
|
||||
<field name="user_id" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="proxy_url" readonly="1"/>
|
||||
<field name="wayland_display" readonly="1"/>
|
||||
<field name="proxy_pid" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Timing">
|
||||
<field name="started_at" readonly="1"/>
|
||||
<field name="last_activity" readonly="1"/>
|
||||
<field name="ended_at" readonly="1"/>
|
||||
<field name="duration_seconds" widget="float_time" readonly="1"/>
|
||||
</group>
|
||||
<group string="JWT">
|
||||
<field name="last_jwt_issued_at" readonly="1"/>
|
||||
<field name="last_jwt_expires_at" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="App Instances" name="app_instances">
|
||||
<field name="app_instance_ids" context="{'default_session_id': id}">
|
||||
<list>
|
||||
<field name="app_id"/>
|
||||
<field name="title"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'running'"
|
||||
decoration-info="state == 'launching'"
|
||||
decoration-danger="state == 'crashed'"
|
||||
decoration-muted="state == 'terminated'"/>
|
||||
<field name="pid"/>
|
||||
<field name="started_at"/>
|
||||
<field name="ended_at"/>
|
||||
<button name="action_terminate" type="object" string="Terminate"
|
||||
class="btn-danger btn-sm" icon="fa-stop"
|
||||
invisible="state not in ['launching', 'running']"/>
|
||||
<button name="action_force_kill" type="object" string="Force Kill"
|
||||
class="btn-danger btn-sm" icon="fa-times"
|
||||
invisible="state not in ['launching', 'running']"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Audit Log" name="audit_log">
|
||||
<field name="message_ids" context="{'default_session_id': id}"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Session Search View ───────────────────────────────────── -->
|
||||
<record id="view_compositor_session_search" model="ir.ui.view">
|
||||
<field name="name">compositor.session.search</field>
|
||||
<field name="model">compositor.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Compositor Sessions">
|
||||
<field name="compositor_session_id"/>
|
||||
<field name="user_id"/>
|
||||
<filter name="active" string="Active" domain="[('state', '=', 'active')]"/>
|
||||
<filter name="disconnected" string="Disconnected" domain="[('state', '=', 'disconnected')]"/>
|
||||
<filter name="my_sessions" string="My Sessions" domain="[('user_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_state" string="State" context="{'group_by': 'state'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ─── Action ────────────────────────────────────────────────── -->
|
||||
<record id="action_compositor_session" model="ir.actions.act_window">
|
||||
<field name="name">Sessions</field>
|
||||
<field name="res_model">compositor.session</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_compositor_session_search"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
Reference in new issue
Block a user