diff --git a/vmm/rpc/proto/vmm_rpc.proto b/vmm/rpc/proto/vmm_rpc.proto index c3ecba72..fb22bdb9 100644 --- a/vmm/rpc/proto/vmm_rpc.proto +++ b/vmm/rpc/proto/vmm_rpc.proto @@ -343,6 +343,13 @@ service Vmm { // Report a DHCP lease event (called by the DHCP server, e.g. dnsmasq --dhcp-script). // The VMM resolves the MAC address to a VM and reconfigures port forwarding. rpc ReportDhcpLease(DhcpLeaseRequest) returns (google.protobuf.Empty); + + // List all supervisor processes. + rpc SvList(google.protobuf.Empty) returns (SvListResponse); + // Stop a supervisor process by ID. + rpc SvStop(Id) returns (google.protobuf.Empty); + // Remove a stopped supervisor process by ID. + rpc SvRemove(Id) returns (google.protobuf.Empty); } // DHCP lease event reported by the host DHCP server. @@ -352,3 +359,19 @@ message DhcpLeaseRequest { // IPv4 address assigned by DHCP (e.g. "192.168.122.100") string ip = 2; } + +// Response containing a list of supervisor processes. +message SvListResponse { + repeated SvProcessInfo processes = 1; +} + +// Information about a single supervisor process. +message SvProcessInfo { + string id = 1; + string name = 2; + // Status: "running", "stopped", "exited()", "error()" + string status = 3; + optional uint32 pid = 4; + string command = 5; + string note = 6; +} diff --git a/vmm/src/console_v1.html b/vmm/src/console_v1.html index 66f9370b..da99e705 100644 --- a/vmm/src/console_v1.html +++ b/vmm/src/console_v1.html @@ -1564,6 +1564,194 @@ border-radius: var(--radius-sm); } +/* Process Manager Overlay */ +.process-manager-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background: var(--color-bg-primary); + display: flex; + flex-direction: column; + animation: fadeIn 0.15s ease; +} + +.process-manager-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +.process-manager-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.process-manager-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.process-manager-body { + flex: 1; + overflow: auto; + padding: 16px 24px; +} + +.process-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.process-table th { + text-align: left; + padding: 10px 12px; + font-weight: 600; + color: var(--color-text-secondary); + border-bottom: 2px solid var(--color-border); + white-space: nowrap; + position: sticky; + top: 0; + background: var(--color-bg-primary); +} + +.process-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.process-table tr:hover td { + background: var(--color-bg-tertiary); +} + +.process-table .col-id { + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + font-size: 12px; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.process-table .col-name { + font-weight: 500; +} + +.process-table .col-actions { + white-space: nowrap; +} + +.process-table .col-actions button { + padding: 4px 10px; + font-size: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: transparent; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s ease; +} + +.process-table .col-actions button:hover { + background: var(--color-bg-tertiary); +} + +.process-table .col-actions button.btn-danger { + color: var(--color-danger); + border-color: var(--color-danger); +} + +.process-table .col-actions button.btn-danger:hover { + background: var(--color-danger); + color: #fff; +} + +.sv-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.sv-status-running { + background: rgba(52, 211, 153, 0.1); + color: #34d399; +} + +.sv-status-stopped { + background: rgba(156, 163, 175, 0.1); + color: #9ca3af; +} + +.sv-status-exited { + background: rgba(251, 191, 36, 0.1); + color: #fbbf24; +} + +.sv-status-error { + background: rgba(248, 113, 113, 0.1); + color: #f87171; +} + +.sv-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.btn-clear-stopped { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-clear-stopped:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.btn-close-overlay { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-close-overlay:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.process-empty { + text-align: center; + padding: 48px; + color: var(--color-text-secondary); +} + @@ -3543,6 +3731,78 @@

Derive VM

features.push("tcbinfo"); return features.length > 0 ? features.join(', ') : 'None'; } + // ── Process Manager ───────────────────────────────────────────── + const showProcessManager = ref(false); + const supervisorProcesses = ref([]); + function svStatusClass(p) { + const s = p.status || ''; + if (s === 'running') + return 'sv-status-running'; + if (s === 'stopped') + return 'sv-status-stopped'; + if (s.startsWith('exited')) + return 'sv-status-exited'; + return 'sv-status-error'; + } + function svIsRunning(p) { + return p.status === 'running'; + } + function svIsStopped(p) { + return p.status !== 'running'; + } + async function loadSupervisorProcesses() { + try { + const resp = await baseRpcCall('/prpc/SvList'); + const json = await resp.json(); + supervisorProcesses.value = json.processes || []; + } + catch (error) { + recordError('Failed to load processes', error); + } + } + let svRefreshTimer = null; + async function openProcessManager() { + showProcessManager.value = true; + await loadSupervisorProcesses(); + svRefreshTimer = setInterval(loadSupervisorProcesses, 3000); + } + watch(showProcessManager, (open) => { + if (!open && svRefreshTimer) { + clearInterval(svRefreshTimer); + svRefreshTimer = null; + } + }); + async function svStop(id) { + try { + await baseRpcCall('/prpc/SvStop', { id }); + await new Promise(r => setTimeout(r, 500)); + await loadSupervisorProcesses(); + } + catch (error) { + recordError('Failed to stop process', error); + } + } + async function svRemove(id) { + try { + await baseRpcCall('/prpc/SvRemove', { id }); + await loadSupervisorProcesses(); + } + catch (error) { + recordError('Failed to remove process', error); + } + } + async function svClear() { + try { + const stopped = supervisorProcesses.value.filter(p => svIsStopped(p)); + for (const p of stopped) { + await baseRpcCall('/prpc/SvRemove', { id: p.id }); + } + await loadSupervisorProcesses(); + } + catch (error) { + recordError('Failed to clear stopped processes', error); + } + } onMounted(() => { watchVmList(); loadImages(); @@ -3617,6 +3877,16 @@

Derive VM

devMode, toggleDevMode, shortUptime, + showProcessManager, + supervisorProcesses, + openProcessManager, + loadSupervisorProcesses, + svStop, + svRemove, + svClear, + svStatusClass, + svIsRunning, + svIsStopped, }; } @@ -13392,6 +13662,96 @@

Derive VM

* @returns {Promise} Promise * @variation 2 */ + /** + * Callback as used by {@link vmm.Vmm#svList}. + * @memberof vmm.Vmm + * @typedef SvListCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {vmm.SvListResponse} [response] SvListResponse + */ + /** + * Calls SvList. + * @function svList + * @memberof vmm.Vmm + * @instance + * @param {google.protobuf.IEmpty} request Empty message or plain object + * @param {vmm.Vmm.SvListCallback} callback Node-style callback called with the error, if any, and SvListResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(Vmm.prototype.svList = function svList(request, callback) { + return this.rpcCall(svList, $root.google.protobuf.Empty, $root.vmm.SvListResponse, request, callback); + }, "name", { value: "SvList" }); + /** + * Calls SvList. + * @function svList + * @memberof vmm.Vmm + * @instance + * @param {google.protobuf.IEmpty} request Empty message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + /** + * Callback as used by {@link vmm.Vmm#svStop}. + * @memberof vmm.Vmm + * @typedef SvStopCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {google.protobuf.Empty} [response] Empty + */ + /** + * Calls SvStop. + * @function svStop + * @memberof vmm.Vmm + * @instance + * @param {vmm.IId} request Id message or plain object + * @param {vmm.Vmm.SvStopCallback} callback Node-style callback called with the error, if any, and Empty + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(Vmm.prototype.svStop = function svStop(request, callback) { + return this.rpcCall(svStop, $root.vmm.Id, $root.google.protobuf.Empty, request, callback); + }, "name", { value: "SvStop" }); + /** + * Calls SvStop. + * @function svStop + * @memberof vmm.Vmm + * @instance + * @param {vmm.IId} request Id message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + /** + * Callback as used by {@link vmm.Vmm#svRemove}. + * @memberof vmm.Vmm + * @typedef SvRemoveCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {google.protobuf.Empty} [response] Empty + */ + /** + * Calls SvRemove. + * @function svRemove + * @memberof vmm.Vmm + * @instance + * @param {vmm.IId} request Id message or plain object + * @param {vmm.Vmm.SvRemoveCallback} callback Node-style callback called with the error, if any, and Empty + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(Vmm.prototype.svRemove = function svRemove(request, callback) { + return this.rpcCall(svRemove, $root.vmm.Id, $root.google.protobuf.Empty, request, callback); + }, "name", { value: "SvRemove" }); + /** + * Calls SvRemove. + * @function svRemove + * @memberof vmm.Vmm + * @instance + * @param {vmm.IId} request Id message or plain object + * @returns {Promise} Promise + * @variation 2 + */ return Vmm; })(); vmm.DhcpLeaseRequest = (function () { @@ -13607,6 +13967,535 @@

Derive VM

}; return DhcpLeaseRequest; })(); + vmm.SvListResponse = (function () { + /** + * Properties of a SvListResponse. + * @memberof vmm + * @interface ISvListResponse + * @property {Array.|null} [processes] SvListResponse processes + */ + /** + * Constructs a new SvListResponse. + * @memberof vmm + * @classdesc Represents a SvListResponse. + * @implements ISvListResponse + * @constructor + * @param {vmm.ISvListResponse=} [properties] Properties to set + */ + function SvListResponse(properties) { + this.processes = []; + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + /** + * SvListResponse processes. + * @member {Array.} processes + * @memberof vmm.SvListResponse + * @instance + */ + SvListResponse.prototype.processes = $util.emptyArray; + /** + * Creates a new SvListResponse instance using the specified properties. + * @function create + * @memberof vmm.SvListResponse + * @static + * @param {vmm.ISvListResponse=} [properties] Properties to set + * @returns {vmm.SvListResponse} SvListResponse instance + */ + SvListResponse.create = function create(properties) { + return new SvListResponse(properties); + }; + /** + * Encodes the specified SvListResponse message. Does not implicitly {@link vmm.SvListResponse.verify|verify} messages. + * @function encode + * @memberof vmm.SvListResponse + * @static + * @param {vmm.ISvListResponse} message SvListResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SvListResponse.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.processes != null && message.processes.length) + for (var i = 0; i < message.processes.length; ++i) + $root.vmm.SvProcessInfo.encode(message.processes[i], writer.uint32(/* id 1, wireType 2 =*/ 10).fork()).ldelim(); + return writer; + }; + /** + * Encodes the specified SvListResponse message, length delimited. Does not implicitly {@link vmm.SvListResponse.verify|verify} messages. + * @function encodeDelimited + * @memberof vmm.SvListResponse + * @static + * @param {vmm.ISvListResponse} message SvListResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SvListResponse.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + /** + * Decodes a SvListResponse message from the specified reader or buffer. + * @function decode + * @memberof vmm.SvListResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {vmm.SvListResponse} SvListResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SvListResponse.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.vmm.SvListResponse(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.processes && message.processes.length)) + message.processes = []; + message.processes.push($root.vmm.SvProcessInfo.decode(reader, reader.uint32())); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + /** + * Decodes a SvListResponse message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof vmm.SvListResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {vmm.SvListResponse} SvListResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SvListResponse.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + /** + * Verifies a SvListResponse message. + * @function verify + * @memberof vmm.SvListResponse + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SvListResponse.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.processes != null && message.hasOwnProperty("processes")) { + if (!Array.isArray(message.processes)) + return "processes: array expected"; + for (var i = 0; i < message.processes.length; ++i) { + var error = $root.vmm.SvProcessInfo.verify(message.processes[i]); + if (error) + return "processes." + error; + } + } + return null; + }; + /** + * Creates a SvListResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof vmm.SvListResponse + * @static + * @param {Object.} object Plain object + * @returns {vmm.SvListResponse} SvListResponse + */ + SvListResponse.fromObject = function fromObject(object) { + if (object instanceof $root.vmm.SvListResponse) + return object; + var message = new $root.vmm.SvListResponse(); + if (object.processes) { + if (!Array.isArray(object.processes)) + throw TypeError(".vmm.SvListResponse.processes: array expected"); + message.processes = []; + for (var i = 0; i < object.processes.length; ++i) { + if (typeof object.processes[i] !== "object") + throw TypeError(".vmm.SvListResponse.processes: object expected"); + message.processes[i] = $root.vmm.SvProcessInfo.fromObject(object.processes[i]); + } + } + return message; + }; + /** + * Creates a plain object from a SvListResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof vmm.SvListResponse + * @static + * @param {vmm.SvListResponse} message SvListResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SvListResponse.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.arrays || options.defaults) + object.processes = []; + if (message.processes && message.processes.length) { + object.processes = []; + for (var j = 0; j < message.processes.length; ++j) + object.processes[j] = $root.vmm.SvProcessInfo.toObject(message.processes[j], options); + } + return object; + }; + /** + * Converts this SvListResponse to JSON. + * @function toJSON + * @memberof vmm.SvListResponse + * @instance + * @returns {Object.} JSON object + */ + SvListResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + /** + * Gets the default type url for SvListResponse + * @function getTypeUrl + * @memberof vmm.SvListResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SvListResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/vmm.SvListResponse"; + }; + return SvListResponse; + })(); + vmm.SvProcessInfo = (function () { + /** + * Properties of a SvProcessInfo. + * @memberof vmm + * @interface ISvProcessInfo + * @property {string|null} [id] SvProcessInfo id + * @property {string|null} [name] SvProcessInfo name + * @property {string|null} [status] SvProcessInfo status + * @property {number|null} [pid] SvProcessInfo pid + * @property {string|null} [command] SvProcessInfo command + * @property {string|null} [note] SvProcessInfo note + */ + /** + * Constructs a new SvProcessInfo. + * @memberof vmm + * @classdesc Represents a SvProcessInfo. + * @implements ISvProcessInfo + * @constructor + * @param {vmm.ISvProcessInfo=} [properties] Properties to set + */ + function SvProcessInfo(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + /** + * SvProcessInfo id. + * @member {string} id + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.id = ""; + /** + * SvProcessInfo name. + * @member {string} name + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.name = ""; + /** + * SvProcessInfo status. + * @member {string} status + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.status = ""; + /** + * SvProcessInfo pid. + * @member {number|null|undefined} pid + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.pid = null; + /** + * SvProcessInfo command. + * @member {string} command + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.command = ""; + /** + * SvProcessInfo note. + * @member {string} note + * @memberof vmm.SvProcessInfo + * @instance + */ + SvProcessInfo.prototype.note = ""; + // OneOf field names bound to virtual getters and setters + var $oneOfFields; + /** + * SvProcessInfo _pid. + * @member {"pid"|undefined} _pid + * @memberof vmm.SvProcessInfo + * @instance + */ + Object.defineProperty(SvProcessInfo.prototype, "_pid", { + get: $util.oneOfGetter($oneOfFields = ["pid"]), + set: $util.oneOfSetter($oneOfFields) + }); + /** + * Creates a new SvProcessInfo instance using the specified properties. + * @function create + * @memberof vmm.SvProcessInfo + * @static + * @param {vmm.ISvProcessInfo=} [properties] Properties to set + * @returns {vmm.SvProcessInfo} SvProcessInfo instance + */ + SvProcessInfo.create = function create(properties) { + return new SvProcessInfo(properties); + }; + /** + * Encodes the specified SvProcessInfo message. Does not implicitly {@link vmm.SvProcessInfo.verify|verify} messages. + * @function encode + * @memberof vmm.SvProcessInfo + * @static + * @param {vmm.ISvProcessInfo} message SvProcessInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SvProcessInfo.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.id != null && Object.hasOwnProperty.call(message, "id")) + writer.uint32(/* id 1, wireType 2 =*/ 10).string(message.id); + if (message.name != null && Object.hasOwnProperty.call(message, "name")) + writer.uint32(/* id 2, wireType 2 =*/ 18).string(message.name); + if (message.status != null && Object.hasOwnProperty.call(message, "status")) + writer.uint32(/* id 3, wireType 2 =*/ 26).string(message.status); + if (message.pid != null && Object.hasOwnProperty.call(message, "pid")) + writer.uint32(/* id 4, wireType 0 =*/ 32).uint32(message.pid); + if (message.command != null && Object.hasOwnProperty.call(message, "command")) + writer.uint32(/* id 5, wireType 2 =*/ 42).string(message.command); + if (message.note != null && Object.hasOwnProperty.call(message, "note")) + writer.uint32(/* id 6, wireType 2 =*/ 50).string(message.note); + return writer; + }; + /** + * Encodes the specified SvProcessInfo message, length delimited. Does not implicitly {@link vmm.SvProcessInfo.verify|verify} messages. + * @function encodeDelimited + * @memberof vmm.SvProcessInfo + * @static + * @param {vmm.ISvProcessInfo} message SvProcessInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SvProcessInfo.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + /** + * Decodes a SvProcessInfo message from the specified reader or buffer. + * @function decode + * @memberof vmm.SvProcessInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {vmm.SvProcessInfo} SvProcessInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SvProcessInfo.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.vmm.SvProcessInfo(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.id = reader.string(); + break; + } + case 2: { + message.name = reader.string(); + break; + } + case 3: { + message.status = reader.string(); + break; + } + case 4: { + message.pid = reader.uint32(); + break; + } + case 5: { + message.command = reader.string(); + break; + } + case 6: { + message.note = reader.string(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + /** + * Decodes a SvProcessInfo message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof vmm.SvProcessInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {vmm.SvProcessInfo} SvProcessInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SvProcessInfo.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + /** + * Verifies a SvProcessInfo message. + * @function verify + * @memberof vmm.SvProcessInfo + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SvProcessInfo.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + var properties = {}; + if (message.id != null && message.hasOwnProperty("id")) + if (!$util.isString(message.id)) + return "id: string expected"; + if (message.name != null && message.hasOwnProperty("name")) + if (!$util.isString(message.name)) + return "name: string expected"; + if (message.status != null && message.hasOwnProperty("status")) + if (!$util.isString(message.status)) + return "status: string expected"; + if (message.pid != null && message.hasOwnProperty("pid")) { + properties._pid = 1; + if (!$util.isInteger(message.pid)) + return "pid: integer expected"; + } + if (message.command != null && message.hasOwnProperty("command")) + if (!$util.isString(message.command)) + return "command: string expected"; + if (message.note != null && message.hasOwnProperty("note")) + if (!$util.isString(message.note)) + return "note: string expected"; + return null; + }; + /** + * Creates a SvProcessInfo message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof vmm.SvProcessInfo + * @static + * @param {Object.} object Plain object + * @returns {vmm.SvProcessInfo} SvProcessInfo + */ + SvProcessInfo.fromObject = function fromObject(object) { + if (object instanceof $root.vmm.SvProcessInfo) + return object; + var message = new $root.vmm.SvProcessInfo(); + if (object.id != null) + message.id = String(object.id); + if (object.name != null) + message.name = String(object.name); + if (object.status != null) + message.status = String(object.status); + if (object.pid != null) + message.pid = object.pid >>> 0; + if (object.command != null) + message.command = String(object.command); + if (object.note != null) + message.note = String(object.note); + return message; + }; + /** + * Creates a plain object from a SvProcessInfo message. Also converts values to other types if specified. + * @function toObject + * @memberof vmm.SvProcessInfo + * @static + * @param {vmm.SvProcessInfo} message SvProcessInfo + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SvProcessInfo.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + object.id = ""; + object.name = ""; + object.status = ""; + object.command = ""; + object.note = ""; + } + if (message.id != null && message.hasOwnProperty("id")) + object.id = message.id; + if (message.name != null && message.hasOwnProperty("name")) + object.name = message.name; + if (message.status != null && message.hasOwnProperty("status")) + object.status = message.status; + if (message.pid != null && message.hasOwnProperty("pid")) { + object.pid = message.pid; + if (options.oneofs) + object._pid = "pid"; + } + if (message.command != null && message.hasOwnProperty("command")) + object.command = message.command; + if (message.note != null && message.hasOwnProperty("note")) + object.note = message.note; + return object; + }; + /** + * Converts this SvProcessInfo to JSON. + * @function toJSON + * @memberof vmm.SvProcessInfo + * @instance + * @returns {Object.} JSON object + */ + SvProcessInfo.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + /** + * Gets the default type url for SvProcessInfo + * @function getTypeUrl + * @memberof vmm.SvProcessInfo + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SvProcessInfo.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/vmm.SvProcessInfo"; + }; + return SvProcessInfo; + })(); return vmm; })(); $root.google = (function () { @@ -16721,7 +17610,7 @@

Derive VM

}, map: {"protobufjs/minimal":"node_modules/protobufjs/minimal.js"} }, 'build/ts/templates/app.html': { factory: function(module, exports, require) { -module.exports = "\n\n
\n
\n
\n
\n

dstack-vmm

\n \n v{{ version.version }}\n \n \n
\n
\n \n
\n \n
\n \n \n \n \n
\n
\n
\n
\n
\n\n \n\n \n\n \n\n
\n
\n
\n \n \n \n \n \n \n
\n
\n Total Instances:\n {{ totalVMs }}\n
\n
\n
\n
\n \n
\n \n /\n {{ maxPage || 1 }}\n
\n \n \n
\n
\n
\n\n
\n
\n
\n
Name
\n
Status
\n
Uptime
\n
View
\n
Actions
\n
\n\n
\n
\n
\n \n
\n
\n {{ vm.name }}\n
\n
\n \n \n {{ vmStatus(vm) }}\n \n
\n
{{ vm.status !== 'stopped' ? shortUptime(vm.uptime) : '-' }}
\n
\n Logs\n Stderr\n Board\n
\n
\n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n
\n\n
\n
\n
\n VM ID\n
\n {{ vm.id }}\n \n
\n
\n
\n Instance ID\n
\n {{ vm.instance_id }}\n \n
\n -\n
\n
\n App ID\n
\n {{ vm.app_id }}\n \n
\n -\n
\n
\n Image\n {{ vm.configuration?.image }}\n
\n
\n vCPUs\n {{ vm.configuration?.vcpu }}\n
\n
\n Memory\n {{ formatMemory(vm.configuration?.memory) }}\n
\n
\n Swap\n {{ formatMemory(bytesToMB(vm.configuration.swap_size)) }}\n
\n
\n Disk Size\n {{ vm.configuration?.disk_size }} GB\n
\n
\n Disk Type\n {{ vm.configuration?.disk_type || 'virtio-pci' }}\n
\n
\n TEE\n {{ vm.configuration?.no_tee ? 'Disabled' : 'Enabled' }}\n
\n
\n GPUs\n
\n \n All GPUs\n \n
\n
\n \n {{ gpu.slot || gpu.product_id || ('GPU #' + (index + 1)) }}\n \n
\n
\n None\n
\n
\n
\n\n
\n

Port Mappings

\n
\n {{\n port.host_address === '127.0.0.1'\n ? 'Local'\n : (port.host_address === '0.0.0.0' ? 'Public' : port.host_address)\n }}\n {{ port.protocol.toUpperCase() }}: {{ port.host_port }} → {{ port.vm_port }}\n
\n
\n\n
\n

Features

\n {{ getVmFeatures(vm) }}\n
\n\n
\n

Network Interfaces

\n
\n
\n
\n
\n \n \n \n \n {{ iface.name }}\n
\n
\n
\n
\n MAC Address\n {{ iface.mac || '-' }}\n
\n
\n IP Address\n {{ iface.addresses.map(addr => addr.address + '/' + addr.prefix).join(', ') || '-' }}\n
\n
\n
\n
\n \n \n \n
\n
\n RX\n {{ iface.rx_bytes }} bytes\n 0\">({{ iface.rx_errors }} errors)\n
\n
\n
\n
\n \n \n \n
\n
\n TX\n {{ iface.tx_bytes }} bytes\n 0\">({{ iface.tx_errors }} errors)\n
\n
\n
\n
\n
\n
\n
\n

\n \n \n \n \n WireGuard Info\n

\n
{{ networkInfo[vm.id].wg_info }}
\n
\n
\n\n
\n
\n

App Compose

\n
\n \n \n
\n
\n
\n
{{ vm.appCompose?.docker_compose_file || 'Docker Compose content not available' }}
\n
\n
\n\n
\n
\n

User Config

\n \n
\n
{{ vm.configuration.user_config }}
\n
\n\n
\n \n \n \n
\n
\n
\n
\n\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n {{ errorMessage }}\n \n
\n
\n
\n"; +module.exports = "\n\n
\n
\n
\n
\n

dstack-vmm

\n \n v{{ version.version }}\n \n \n
\n
\n \n
\n \n
\n \n \n \n \n \n
\n
\n
\n
\n
\n\n \n\n \n\n \n\n
\n
\n
\n \n \n \n \n \n \n
\n
\n Total Instances:\n {{ totalVMs }}\n
\n
\n
\n
\n \n
\n \n /\n {{ maxPage || 1 }}\n
\n \n \n
\n
\n
\n\n
\n
\n
\n
Name
\n
Status
\n
Uptime
\n
View
\n
Actions
\n
\n\n
\n
\n
\n \n
\n
\n {{ vm.name }}\n
\n
\n \n \n {{ vmStatus(vm) }}\n \n
\n
{{ vm.status !== 'stopped' ? shortUptime(vm.uptime) : '-' }}
\n
\n Logs\n Stderr\n Board\n
\n
\n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n
\n\n
\n
\n
\n VM ID\n
\n {{ vm.id }}\n \n
\n
\n
\n Instance ID\n
\n {{ vm.instance_id }}\n \n
\n -\n
\n
\n App ID\n
\n {{ vm.app_id }}\n \n
\n -\n
\n
\n Image\n {{ vm.configuration?.image }}\n
\n
\n vCPUs\n {{ vm.configuration?.vcpu }}\n
\n
\n Memory\n {{ formatMemory(vm.configuration?.memory) }}\n
\n
\n Swap\n {{ formatMemory(bytesToMB(vm.configuration.swap_size)) }}\n
\n
\n Disk Size\n {{ vm.configuration?.disk_size }} GB\n
\n
\n Disk Type\n {{ vm.configuration?.disk_type || 'virtio-pci' }}\n
\n
\n TEE\n {{ vm.configuration?.no_tee ? 'Disabled' : 'Enabled' }}\n
\n
\n GPUs\n
\n \n All GPUs\n \n
\n
\n \n {{ gpu.slot || gpu.product_id || ('GPU #' + (index + 1)) }}\n \n
\n
\n None\n
\n
\n
\n\n
\n

Port Mappings

\n
\n {{\n port.host_address === '127.0.0.1'\n ? 'Local'\n : (port.host_address === '0.0.0.0' ? 'Public' : port.host_address)\n }}\n {{ port.protocol.toUpperCase() }}: {{ port.host_port }} → {{ port.vm_port }}\n
\n
\n\n
\n

Features

\n {{ getVmFeatures(vm) }}\n
\n\n
\n

Network Interfaces

\n
\n
\n
\n
\n \n \n \n \n {{ iface.name }}\n
\n
\n
\n
\n MAC Address\n {{ iface.mac || '-' }}\n
\n
\n IP Address\n {{ iface.addresses.map(addr => addr.address + '/' + addr.prefix).join(', ') || '-' }}\n
\n
\n
\n
\n \n \n \n
\n
\n RX\n {{ iface.rx_bytes }} bytes\n 0\">({{ iface.rx_errors }} errors)\n
\n
\n
\n
\n \n \n \n
\n
\n TX\n {{ iface.tx_bytes }} bytes\n 0\">({{ iface.tx_errors }} errors)\n
\n
\n
\n
\n
\n
\n
\n

\n \n \n \n \n WireGuard Info\n

\n
{{ networkInfo[vm.id].wg_info }}
\n
\n
\n\n
\n
\n

App Compose

\n
\n \n \n
\n
\n
\n
{{ vm.appCompose?.docker_compose_file || 'Docker Compose content not available' }}
\n
\n
\n\n
\n
\n

User Config

\n \n
\n
{{ vm.configuration.user_config }}
\n
\n\n
\n \n \n \n
\n
\n
\n
\n\n
\n
\n

Supervisor Processes

\n
\n \n \n \n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
NameIDStatusPIDActions
{{ p.name }}{{ p.id }}\n \n \n {{ p.status }}\n \n {{ p.pid || '-' }}\n \n \n
\n
No processes found
\n
\n
\n\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n {{ errorMessage }}\n \n
\n
\n
\n"; }, map: {} } }; const cache = {}; diff --git a/vmm/src/main_routes.rs b/vmm/src/main_routes.rs index f4ef9a45..500480db 100644 --- a/vmm/src/main_routes.rs +++ b/vmm/src/main_routes.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: Apache-2.0 use crate::app::App; -use anyhow::Result; use fs_err as fs; use rocket::{ get, diff --git a/vmm/src/main_service.rs b/vmm/src/main_service.rs index 644e0c96..1fb98b43 100644 --- a/vmm/src/main_service.rs +++ b/vmm/src/main_service.rs @@ -13,7 +13,8 @@ use dstack_vmm_rpc::{ AppId, ComposeHash as RpcComposeHash, DhcpLeaseRequest, GatewaySettings, GetInfoResponse, GetMetaResponse, Id, ImageInfo as RpcImageInfo, ImageListResponse, KmsSettings, ListGpusResponse, PublicKeyResponse, ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, - StatusRequest, StatusResponse, UpdateVmRequest, VersionResponse, VmConfiguration, + StatusRequest, StatusResponse, SvListResponse, SvProcessInfo, UpdateVmRequest, VersionResponse, + VmConfiguration, }; use fs_err as fs; use ra_rpc::{CallContext, RpcCall}; @@ -577,6 +578,41 @@ impl VmmRpc for RpcHandler { self.app.report_dhcp_lease(&request.mac, &request.ip).await; Ok(()) } + + async fn sv_list(self) -> Result { + use supervisor_client::supervisor::ProcessStatus; + let list = self.app.supervisor.list().await?; + let processes = list + .into_iter() + .map(|p| { + let status = match &p.state.status { + ProcessStatus::Running => "running".into(), + ProcessStatus::Stopped => "stopped".into(), + ProcessStatus::Exited(code) => format!("exited({code})"), + ProcessStatus::Error(msg) => format!("error({msg})"), + }; + SvProcessInfo { + id: p.config.id, + name: p.config.name, + status, + pid: p.state.pid, + command: p.config.command, + note: p.config.note, + } + }) + .collect(); + Ok(SvListResponse { processes }) + } + + async fn sv_stop(self, request: Id) -> Result<()> { + self.app.supervisor.stop(&request.id).await?; + Ok(()) + } + + async fn sv_remove(self, request: Id) -> Result<()> { + self.app.supervisor.remove(&request.id).await?; + Ok(()) + } } impl RpcCall for RpcHandler { diff --git a/vmm/ui/src/composables/useVmManager.ts b/vmm/ui/src/composables/useVmManager.ts index a0d7b935..b4b41abe 100644 --- a/vmm/ui/src/composables/useVmManager.ts +++ b/vmm/ui/src/composables/useVmManager.ts @@ -1475,6 +1475,82 @@ type CreateVmPayloadSource = { return features.length > 0 ? features.join(', ') : 'None'; } + // ── Process Manager ───────────────────────────────────────────── + const showProcessManager = ref(false); + const supervisorProcesses = ref([] as any[]); + + function svStatusClass(p: any): string { + const s = p.status || ''; + if (s === 'running') return 'sv-status-running'; + if (s === 'stopped') return 'sv-status-stopped'; + if (s.startsWith('exited')) return 'sv-status-exited'; + return 'sv-status-error'; + } + + function svIsRunning(p: any): boolean { + return p.status === 'running'; + } + + function svIsStopped(p: any): boolean { + return p.status !== 'running'; + } + + async function loadSupervisorProcesses() { + try { + const resp = await baseRpcCall('/prpc/SvList'); + const json = await resp.json(); + supervisorProcesses.value = json.processes || []; + } catch (error) { + recordError('Failed to load processes', error); + } + } + + let svRefreshTimer: ReturnType | null = null; + + async function openProcessManager() { + showProcessManager.value = true; + await loadSupervisorProcesses(); + svRefreshTimer = setInterval(loadSupervisorProcesses, 3000); + } + + watch(showProcessManager, (open) => { + if (!open && svRefreshTimer) { + clearInterval(svRefreshTimer); + svRefreshTimer = null; + } + }); + + async function svStop(id: string) { + try { + await baseRpcCall('/prpc/SvStop', { id }); + await new Promise(r => setTimeout(r, 500)); + await loadSupervisorProcesses(); + } catch (error) { + recordError('Failed to stop process', error); + } + } + + async function svRemove(id: string) { + try { + await baseRpcCall('/prpc/SvRemove', { id }); + await loadSupervisorProcesses(); + } catch (error) { + recordError('Failed to remove process', error); + } + } + + async function svClear() { + try { + const stopped = supervisorProcesses.value.filter(p => svIsStopped(p)); + for (const p of stopped) { + await baseRpcCall('/prpc/SvRemove', { id: p.id }); + } + await loadSupervisorProcesses(); + } catch (error) { + recordError('Failed to clear stopped processes', error); + } + } + onMounted(() => { watchVmList(); loadImages(); @@ -1550,6 +1626,16 @@ type CreateVmPayloadSource = { devMode, toggleDevMode, shortUptime, + showProcessManager, + supervisorProcesses, + openProcessManager, + loadSupervisorProcesses, + svStop, + svRemove, + svClear, + svStatusClass, + svIsRunning, + svIsStopped, }; } diff --git a/vmm/ui/src/styles/main.css b/vmm/ui/src/styles/main.css index 54def8a5..048c6a8d 100644 --- a/vmm/ui/src/styles/main.css +++ b/vmm/ui/src/styles/main.css @@ -1551,3 +1551,191 @@ h1, h2, h3, h4, h5, h6 { padding: 8px 12px; border-radius: var(--radius-sm); } + +/* Process Manager Overlay */ +.process-manager-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background: var(--color-bg-primary); + display: flex; + flex-direction: column; + animation: fadeIn 0.15s ease; +} + +.process-manager-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +.process-manager-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.process-manager-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.process-manager-body { + flex: 1; + overflow: auto; + padding: 16px 24px; +} + +.process-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.process-table th { + text-align: left; + padding: 10px 12px; + font-weight: 600; + color: var(--color-text-secondary); + border-bottom: 2px solid var(--color-border); + white-space: nowrap; + position: sticky; + top: 0; + background: var(--color-bg-primary); +} + +.process-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.process-table tr:hover td { + background: var(--color-bg-tertiary); +} + +.process-table .col-id { + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + font-size: 12px; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.process-table .col-name { + font-weight: 500; +} + +.process-table .col-actions { + white-space: nowrap; +} + +.process-table .col-actions button { + padding: 4px 10px; + font-size: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: transparent; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s ease; +} + +.process-table .col-actions button:hover { + background: var(--color-bg-tertiary); +} + +.process-table .col-actions button.btn-danger { + color: var(--color-danger); + border-color: var(--color-danger); +} + +.process-table .col-actions button.btn-danger:hover { + background: var(--color-danger); + color: #fff; +} + +.sv-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.sv-status-running { + background: rgba(52, 211, 153, 0.1); + color: #34d399; +} + +.sv-status-stopped { + background: rgba(156, 163, 175, 0.1); + color: #9ca3af; +} + +.sv-status-exited { + background: rgba(251, 191, 36, 0.1); + color: #fbbf24; +} + +.sv-status-error { + background: rgba(248, 113, 113, 0.1); + color: #f87171; +} + +.sv-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.btn-clear-stopped { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-clear-stopped:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.btn-close-overlay { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-close-overlay:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.process-empty { + text-align: center; + padding: 48px; + color: var(--color-text-secondary); +} diff --git a/vmm/ui/src/templates/app.html b/vmm/ui/src/templates/app.html index a9660454..21be8689 100644 --- a/vmm/ui/src/templates/app.html +++ b/vmm/ui/src/templates/app.html @@ -35,6 +35,15 @@

dstack-vmm

Reload VMs + + + + + +
+ + + + + + + + + + + + + + + + + + + +
NameIDStatusPIDActions
{{ p.name }}{{ p.id }} + + + {{ p.status }} + + {{ p.pid || '-' }} + + +
+
No processes found
+
+ +