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:
1b-agent committed 2026-10-08 08:48:46 +00:00
commit 93a5a0a221
32 files changed
+2497

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Compositor backend models."""
from . import compositor_app
from . import compositor_session
from . import compositor_audit_log
+229
View File
@@ -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."
)
+112
View File
@@ -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>
+115
View File
@@ -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,
})
+100
View File
@@ -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>
+258
View File
@@ -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",
})
+140
View File
@@ -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>