💋 Sync 2026-10-04 04:16:09
No files matched your search
@@ -0,0 +1,43 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# JavaScript
|
||||
[*.js]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
max_line_length = 110
|
||||
|
||||
# YAML
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 110
|
||||
|
||||
# Markdown
|
||||
[*.md]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = false
|
||||
max_line_length = 110
|
||||
|
||||
# POT translation templates
|
||||
[*.pot]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Makefile
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Shell scripts (.sh and .podman)
|
||||
[*.{sh,podman}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
max_line_length = 110
|
||||
@@ -0,0 +1,7 @@
|
||||
docs/swagger-latest.yaml
|
||||
.claude
|
||||
CLAUDE.md
|
||||
.DS_Store
|
||||
*.bak
|
||||
*-cleaned.pot
|
||||
.mcp.json
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"indent_with_tabs": true,
|
||||
"editorconfig": true,
|
||||
"space_after_anon_function": true,
|
||||
"jslint_happy": true,
|
||||
"wrap_line_length": 110
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
PO_FILE="$1"
|
||||
JS_DIR="$2"
|
||||
|
||||
if [[ -z "$PO_FILE" || -z "$JS_DIR" ]]; then
|
||||
echo -e "${BOLD}Usage:${NC} $0 <datei.po> <js_ordner>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DEFINED=$(mktemp)
|
||||
TEMP_USED=$(mktemp)
|
||||
|
||||
echo -e "${BLUE}Read PO-File: $PO_FILE${NC}"
|
||||
grep '^\s*msgid "' "$PO_FILE" | sed 's/^\s*msgid "//;s/"$//' | grep -v '^$' | sort -u > "$TEMP_DEFINED"
|
||||
|
||||
COUNT_DEFINED=$(wc -l < "$TEMP_DEFINED" | xargs)
|
||||
echo " -> $COUNT_DEFINED IDs found."
|
||||
|
||||
echo -e "${BLUE}Search JS-Files recursive in: $JS_DIR${NC}"
|
||||
|
||||
grep -r -h -o -E "_\(['\"][^'\"]+['\"]\)" "$JS_DIR" --include="*.js" > "$TEMP_USED"
|
||||
|
||||
sed -i '' -E "s/^_\(['\"]//;s/['\"]\)$//" "$TEMP_USED"
|
||||
|
||||
sort -u "$TEMP_USED" -o "$TEMP_USED"
|
||||
|
||||
COUNT_USED=$(wc -l < "$TEMP_USED" | xargs)
|
||||
echo " -> $COUNT_USED used Strings found."
|
||||
|
||||
echo "---------------------------------------------------"
|
||||
|
||||
echo -e "${BOLD}Not used Message IDs (in PO, not in Code):${NC}"
|
||||
comm -23 "$TEMP_DEFINED" "$TEMP_USED" > "unused_ids.txt"
|
||||
if [[ -s "unused_ids.txt" ]]; then
|
||||
cat "unused_ids.txt"
|
||||
else
|
||||
echo -e "${GREEN}None. all clean!${NC}"
|
||||
fi
|
||||
|
||||
echo "---------------------------------------------------"
|
||||
|
||||
echo -e "${BOLD}Not defined Message IDs (in Code, missing in PO):${NC}"
|
||||
comm -13 "$TEMP_DEFINED" "$TEMP_USED" > "missing_ids.txt"
|
||||
if [[ -s "missing_ids.txt" ]]; then
|
||||
cat "missing_ids.txt"
|
||||
else
|
||||
echo -e "${GREEN}None. All here!${NC}"
|
||||
fi
|
||||
|
||||
# Aufräumen
|
||||
rm "$TEMP_DEFINED" "$TEMP_USED" "unused_ids.txt" "missing_ids.txt" 2>/dev/null
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# POT File Updater for LuCI App Podman
|
||||
# Regenerates the POT file from JavaScript source code
|
||||
# Supports both singular _('...') and plural N_(n, '...', '...') forms
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
POT_FILE="$PROJECT_ROOT/po/templates/podman.pot"
|
||||
HTDOCS_DIR="$PROJECT_ROOT/htdocs"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Temp files
|
||||
PLURAL_PAIRS=$(mktemp)
|
||||
PLURAL_SKIP=$(mktemp)
|
||||
CODE_STRINGS=$(mktemp)
|
||||
FILTERED_STRINGS=$(mktemp)
|
||||
|
||||
# Cleanup on exit
|
||||
trap "rm -f $PLURAL_PAIRS $PLURAL_SKIP $CODE_STRINGS $FILTERED_STRINGS" EXIT
|
||||
|
||||
echo "POT File Updater for LuCI App Podman"
|
||||
echo "====================================="
|
||||
echo ""
|
||||
|
||||
# Step 1: Extract plural forms N_(count, 'singular', 'plural')
|
||||
echo "Extracting plural forms..."
|
||||
|
||||
find "$HTDOCS_DIR" -name "*.js" -exec cat {} \; | \
|
||||
perl -0777 -ne "while (/N_\s*\([^,]+,\s*'([^']+)'\s*,\s*'([^']+)'\s*\)/gs) { print \"\$1|\$2\n\"; }" | \
|
||||
sort -u > "$PLURAL_PAIRS"
|
||||
|
||||
PLURAL_COUNT=$(wc -l < "$PLURAL_PAIRS" | tr -d ' ')
|
||||
# Handle empty file (wc returns 1 for empty file with no newline)
|
||||
if [ ! -s "$PLURAL_PAIRS" ]; then
|
||||
PLURAL_COUNT=0
|
||||
fi
|
||||
echo "Found $PLURAL_COUNT plural form(s)"
|
||||
|
||||
# Step 2: Build skip list (both singular and plural strings from N_() calls)
|
||||
# These should not appear as standalone msgid entries
|
||||
cat "$PLURAL_PAIRS" | while IFS='|' read -r singular plural; do
|
||||
echo "$singular"
|
||||
echo "$plural"
|
||||
done | sort -u > "$PLURAL_SKIP"
|
||||
|
||||
# Step 3: Extract all singular strings _('...')
|
||||
echo "Extracting singular strings..."
|
||||
|
||||
find "$HTDOCS_DIR" -name "*.js" -exec cat {} \; | \
|
||||
perl -0777 -ne "while (/(?<!N)_\(\s*'([^']+)'\s*\)/gs) { print \"\$1\n\"; }" | \
|
||||
sort -u > "$CODE_STRINGS"
|
||||
|
||||
TOTAL_SINGULAR=$(wc -l < "$CODE_STRINGS" | tr -d ' ')
|
||||
|
||||
# Step 4: Filter out strings that are covered by plural forms
|
||||
if [ -s "$PLURAL_SKIP" ]; then
|
||||
grep -vxFf "$PLURAL_SKIP" "$CODE_STRINGS" > "$FILTERED_STRINGS" || true
|
||||
else
|
||||
cp "$CODE_STRINGS" "$FILTERED_STRINGS"
|
||||
fi
|
||||
|
||||
FILTERED_COUNT=$(wc -l < "$FILTERED_STRINGS" | tr -d ' ')
|
||||
SKIPPED=$((TOTAL_SINGULAR - FILTERED_COUNT))
|
||||
|
||||
if [ $SKIPPED -gt 0 ]; then
|
||||
echo -e "${YELLOW}Skipped $SKIPPED string(s) covered by plural forms${NC}"
|
||||
fi
|
||||
echo "Found $FILTERED_COUNT unique singular string(s)"
|
||||
|
||||
# Step 5: Generate POT file
|
||||
echo ""
|
||||
echo "Generating POT file..."
|
||||
|
||||
# Write header
|
||||
cat > "$POT_FILE" << 'EOF'
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
EOF
|
||||
|
||||
# Write plural entries
|
||||
if [ -s "$PLURAL_PAIRS" ]; then
|
||||
while IFS='|' read -r singular plural; do
|
||||
echo "msgid \"$singular\""
|
||||
echo "msgid_plural \"$plural\""
|
||||
echo "msgstr[0] \"\""
|
||||
echo "msgstr[1] \"\""
|
||||
echo ""
|
||||
done < "$PLURAL_PAIRS" >> "$POT_FILE"
|
||||
fi
|
||||
|
||||
# Write singular entries
|
||||
while IFS= read -r line; do
|
||||
# Skip empty lines
|
||||
[ -z "$line" ] && continue
|
||||
echo "msgid \"$line\""
|
||||
echo "msgstr \"\""
|
||||
echo ""
|
||||
done < "$FILTERED_STRINGS" >> "$POT_FILE"
|
||||
|
||||
TOTAL_ENTRIES=$((PLURAL_COUNT + FILTERED_COUNT))
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}POT file updated successfully!${NC}"
|
||||
echo "Location: $POT_FILE"
|
||||
echo "Entries: $TOTAL_ENTRIES ($PLURAL_COUNT plural, $FILTERED_COUNT singular)"
|
||||
echo ""
|
||||
echo "Note: Remember to update translation files (po/*/podman.po) with new strings."
|
||||
@@ -0,0 +1,176 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,20 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME := luci-app-podman
|
||||
PKG_VERSION := 2.3.4
|
||||
PKG_RELEASE := 1
|
||||
PKG_URL := https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman
|
||||
PKG_MAINTAINER := Christopher Söllinger <christopher.soellinger@gmail.com>
|
||||
PKG_LICENSE := Apache-2.0
|
||||
PKG_LICENSE_FILES := LICENSE
|
||||
|
||||
PKG_BUILD_DEPENDS := podman
|
||||
|
||||
LUCI_TITLE := LuCI Podman Application
|
||||
LUCI_DESCRIPTION := LuCI Support for podman
|
||||
LUCI_DEPENDS := +luci-base +rpcd +rpcd-mod-ucode +ucode-mod-socket +ucode-mod-struct +ucode-mod-uloop +ucode-mod-fs +ucode-mod-html +ucode-mod-uci +liblucihttp-ucode +coreutils-timeout
|
||||
LUCI_PKGARCH := all
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,161 @@
|
||||
[](https://openwrt.org/)
|
||||
[](https://openwrt.org/)
|
||||
[](https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman/releases)
|
||||
[](https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman/releases)
|
||||
[](https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman/issues)
|
||||
|
||||
# LuCI App Podman
|
||||
|
||||
Modern and feature rich LuCI web interface for managing Podman on OpenWrt. Some ideas which may come in one of the next version(s) can you find in the [todo's](./docs/TODO.md) file. Also feel free to open a ticket or send me a mail if you have suggestions, ideas or problems 🙂
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Navigation</summary>
|
||||
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Requirements](#requirements)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Container Auto-Update](#container-auto-update)
|
||||
- [Container Auto-Start](#container-auto-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Credits](#credits)
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
- **Container Management**: Start, stop, restart, create, remove containers
|
||||
- **Live Streaming**: Real-time logs, stats, and process list streamed directly via a dedicated ucode controller
|
||||
- **Container Auto-Update**: Check for image updates and recreate containers with latest images (see [Auto-Update](#container-auto-update))
|
||||
- **Auto-start Support**: Automatic init script generation for containers with restart policies
|
||||
- **Image Management**: Pull, remove, inspect images with streaming progress
|
||||
- **Volume Management**: Create, delete, export/import volumes with tar backups
|
||||
- **Network Management**: Bridge, macvlan, ipvlan with VLAN support and optional OpenWrt integration (auto-creates bridge devices, network interfaces, dnsmasq exclusion, and shared `podman` firewall zone with DNS access rules)
|
||||
- **Pod Management**: Create pods with shared namespaces, lifecycle actions (start/stop/restart/pause/unpause), and add containers to existing pods directly from the create form
|
||||
- **Secret Management**: Encrypted storage for sensitive data
|
||||
- **System Overview**: Resource usage, system-wide cleanup
|
||||
- **Multilingual**: de, es, fr, nl, ru, zh_Hans, zh_Hant
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Overview | Container List | Container Info |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
| Live Logs | Image List | Network Create |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt 25.12 / 24.10
|
||||
- Dependencies:
|
||||
- `podman`
|
||||
- `luci-base`
|
||||
- `rpcd`
|
||||
- `rpcd-mod-ucode`
|
||||
- `ucode-mod-socket / -struct / -uloop / -fs / -html / -uci`
|
||||
- `liblucihttp-ucode`
|
||||
- `coreutils-timeout`
|
||||
- Sufficient storage for images/containers
|
||||
|
||||
## Installation
|
||||
|
||||
### From Package Feed
|
||||
|
||||
You can setup this package feed to install and update it with apk/opkg:
|
||||
|
||||
[https://github.com/Zerogiven-OpenWRT-Packages/package-feed](https://github.com/Zerogiven-OpenWRT-Packages/package-feed)
|
||||
|
||||
### From Package
|
||||
|
||||
**OpenWrt 25.12** (apk):
|
||||
```bash
|
||||
wget https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman/releases/download/v2.3.4/luci-app-podman_2.3.4-r1_all.apk
|
||||
apk update && apk add --allow-untrusted luci-app-podman_2.3.4-r1_all.apk
|
||||
```
|
||||
|
||||
**OpenWrt 24.10** (opkg):
|
||||
```bash
|
||||
wget https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman/releases/download/v2.3.4/luci-app-podman_2.3.4-r1_all.ipk
|
||||
opkg update && opkg install luci-app-podman_2.3.4-r1_all.ipk
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Zerogiven-OpenWRT-Packages/luci-app-podman.git package/luci-app-podman
|
||||
make menuconfig # Navigate to: LuCI → Applications → luci-app-podman,Utilities → podman
|
||||
make package/luci-app-podman/compile V=s
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Access via **Podman** in LuCI, or directly at:
|
||||
|
||||
```
|
||||
http://your-router-ip/cgi-bin/luci/admin/podman
|
||||
```
|
||||
|
||||
If encountering socket errors:
|
||||
|
||||
```bash
|
||||
/etc/init.d/podman start
|
||||
/etc/init.d/podman enable
|
||||
```
|
||||
|
||||
## Container Auto-Start
|
||||
|
||||
You can generate a startup script which respects the restart policy set in the container.
|
||||
|
||||
> [!TIP]
|
||||
> If you want save startup scripts during an upgrade you have to config your `/etc/sysupgrade.conf` with `/etc/init.d/podman-container-*`.
|
||||
> ```bash
|
||||
> echo "/etc/init.d/podman-container-*" >> /etc/sysupgrade.conf
|
||||
> ```
|
||||
|
||||
## Container Auto-Update
|
||||
|
||||
The auto-update feature checks for newer container images and recreates containers with the updated images while preserving all configuration.
|
||||
|
||||
### Setup
|
||||
|
||||
To enable auto-update for a container, add the label when creating it:
|
||||
|
||||
```bash
|
||||
podman run -d --name mycontainer \
|
||||
--label io.containers.autoupdate=registry \
|
||||
nginx:latest
|
||||
```
|
||||
|
||||
Or add via the LuCI interface in the container creation form under "Labels".
|
||||
|
||||
### How to Update
|
||||
|
||||
1. Go to **Podman → Overview**
|
||||
2. Click **"Check for Updates"** in the System Maintenance section
|
||||
3. The system compares image digests without pulling (no bandwidth used until update)
|
||||
4. Select which containers to update
|
||||
5. Click **"Update Selected"** to pull new images and recreate containers
|
||||
|
||||
Container names and init scripts are preserved - no manual reconfiguration needed.
|
||||
|
||||
## Configuration
|
||||
|
||||
The app stores its settings in `/etc/config/luci-podman`:
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `init_start_priority` | `99` | procd start priority for container init scripts (max 99: procd sorts rc.d symlinks as strings, so 100+ breaks ordering) |
|
||||
| `socket_path` | `/run/podman/podman.sock` | Path to the Podman API socket |
|
||||
|
||||
## Credits
|
||||
|
||||
Inspired by:
|
||||
|
||||
- [openwrt-podman](https://github.com/breeze303/openwrt-podman/) - Podman on OpenWrt
|
||||
- [openwrt/luci-app-dockerman](https://github.com/openwrt/luci/tree/master/applications/luci-app-dockerman) - Docker LuCI design patterns
|
||||
- [lisaac/luci-app-dockerman](https://github.com/lisaac/luci-app-dockerman) - Docker LuCI design patterns
|
||||
- [OpenWrt Podman Guide](https://openwrt.org/docs/guide-user/virtualization/podman) - Official documentation
|
||||
@@ -0,0 +1,80 @@
|
||||
# ToDo's / Known issues
|
||||
|
||||
## Common
|
||||
|
||||
- Optimize Mobile View (4)
|
||||
- Checkout new table filters for 25.12 and backwards compatibility (4)
|
||||
- Sortable table (3)
|
||||
- Check for duplicated code (2)
|
||||
|
||||
## Overview
|
||||
|
||||
- Configuration Tab (2)
|
||||
|
||||
### Prune
|
||||
|
||||
- Long running prune fails after 50s (1)
|
||||
|
||||
### Auto Update
|
||||
|
||||
- If re-create container fails, it would be nice if user could re-create the container. Some Ideas: (2)
|
||||
1. Show cli command
|
||||
2. Show spec data and make it possible to import
|
||||
|
||||
## Container
|
||||
|
||||
- Checkpoints export/import (3)
|
||||
|
||||
### Add
|
||||
|
||||
- Missing restartRetries (3)
|
||||
- Missing health check settings (2)
|
||||
- Fill form from cli command (3)
|
||||
- Timezone field (3)
|
||||
|
||||
### List
|
||||
|
||||
### Details
|
||||
|
||||
- Re-create container with new image (4)
|
||||
- Add missing restartRetries (3)
|
||||
- Healthcheck tab with form and manual health check action (2)
|
||||
- Init.d priority editable. Maybe directly in the file. (3)
|
||||
- Add some more informations (3)
|
||||
- devices
|
||||
- Attach to a container (ttyd) (3)
|
||||
- Add devices show/add/remove (3)
|
||||
- Add advanced update tab. Pure JSON which gets sent to update endpoint (2)
|
||||
- Timezone field (3)
|
||||
|
||||
## Pods
|
||||
|
||||
- Think how to handle restart policy (2)
|
||||
|
||||
### List
|
||||
|
||||
- Replace table from container modal with one from jsapi (2)
|
||||
|
||||
### Details
|
||||
|
||||
- In containers tab add create/remove/refresh button (2)
|
||||
|
||||
## Volumes
|
||||
|
||||
### List
|
||||
|
||||
- Big exports which takes more than 50s are failing (2)
|
||||
|
||||
## Networks
|
||||
|
||||
### Details
|
||||
|
||||
- Create detail page (2)
|
||||
- Update network (2)
|
||||
- Disconnect container (2)
|
||||
|
||||
## Secrets
|
||||
|
||||
### Add
|
||||
|
||||
### List
|
||||
@@ -0,0 +1,27 @@
|
||||
# 🚫 Explicitly Excluded Features
|
||||
|
||||
The following features were considered but explicitly excluded:
|
||||
|
||||
## Prune Operations
|
||||
- ❌ Container Prune - Already available via System Prune in Overview
|
||||
- ❌ Image Prune - Already available via System Prune in Overview
|
||||
- ❌ Network Prune - Already available via System Prune in Overview
|
||||
- ❌ Volume Prune - Already available via System Prune in Overview
|
||||
- ❌ Pod Prune - Already available via System Prune in Overview
|
||||
|
||||
**Reason:** Global system prune in overview.js handles all cleanup operations
|
||||
|
||||
## Image Management
|
||||
- ❌ Image Tag - Not needed for typical use cases
|
||||
- ❌ Image History - Not needed for typical use cases
|
||||
|
||||
**Reason:** Low value for typical OpenWrt/embedded use cases
|
||||
|
||||
## Advanced Features
|
||||
- ❌ Container Exec - Too complex, SSH/CLI available
|
||||
- ❌ Container Archive (Copy Files) - Too complex UI
|
||||
- ❌ Container Checkpoint/Restore - May not work on OpenWrt (needs CRIU)
|
||||
- ❌ Image Build - Too complex, build elsewhere and pull
|
||||
- ❌ Generate Systemd Units - OpenWrt doesn't use systemd
|
||||
- ❌ Manifest Management - Too niche
|
||||
- ❌ System Events (Real-time Updates) - Events disabled on system (`events_logger = "none"`)
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,365 @@
|
||||
# Manual UI Testing Plan
|
||||
|
||||
1. xx
|
||||
|
||||
## 0. Authentication
|
||||
|
||||
- [x] **0.1** Navigate to `http://<router>/cgi-bin/luci` → Redirected to login page
|
||||
- [x] **0.2** Submit correct credentials → Redirected to LuCI dashboard
|
||||
- [x] **0.3** Navigate to `/admin/podman` → Overview page loads without re-auth
|
||||
- [x] **0.4** Open browser Console tab → No JS errors
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview (`/admin/podman`)
|
||||
|
||||
- [x] **1.1** Load overview → Page loads; all counters visible (0 is fine)
|
||||
- [x] **1.2** Podman version string visible → Not empty, not "undefined"
|
||||
- [ ] **1.3** System status indicator visible → Shows running (socket available)
|
||||
- [x] **1.4** Note all current counts → Baseline for comparison after later steps
|
||||
|
||||
### 1a. Auto-update modal - live progress
|
||||
|
||||
Prereq: at least one container with label `io.containers.autoupdate=registry`.
|
||||
|
||||
- [ ] **1.5** Click "Check for Updates" → Spinner per container → "Apply Updates" modal lists candidates
|
||||
- [ ] **1.6** Tick a container with an actually-newer image upstream → Click Update → Modal switches to log view with `Update image: 1/N - <name>` header, spinner running, Close button **disabled**
|
||||
- [ ] **1.7** Log shows `Pulling <ref>`, then live `Trying to pull...`, `Copying blob ...` lines - progress lines with CR overwrite the previous line (no flood of "Copying blob X 50%, 51%, 52%, ...")
|
||||
- [ ] **1.8** After pull, log shows stage markers: `→ Stopping container`, `→ Removing container`, `→ Creating container`, `→ Starting container`, `→ Reinstating init script`, `✓ Updated to <id-short>`
|
||||
- [ ] **1.9** Multi-container update: each container delimited by `━━ <name> ━━`; previous container's log remains visible above current one
|
||||
- [ ] **1.10** After loop ends, counter shows `Done.`, spinner stops, Close button **enabled**
|
||||
- [ ] **1.11** Click Close → modal dismisses → summary alert appears (`Containers updated successfully` for full success, list of failures otherwise)
|
||||
- [ ] **1.12** Pull failure (rename the container's image to a non-existent registry before running, or change tag to bogus): log shows `✗ <error>`, run continues to next container, summary marks failed
|
||||
- [ ] **1.13** Mid-flight failure (SSH `podman run -d --name <same-name> alpine sleep 3600` between Stop and Create, or simulate by other means): log shows `✗ Create failed: ...` for that container, summary marks failed
|
||||
- [ ] **1.14** Old-image cleanup: after successful update of one container, log shows `━━ Cleaning up old images... ━━` then either `✓ Removed old image <id>` or `! Old image <name> could not be removed - still in use by another container`
|
||||
- [ ] **1.15** Modal scroll: when log overflows visible area, content auto-scrolls to bottom on append; scroll up manually → new lines append at bottom but viewport stays where the user put it (sticky-scroll)
|
||||
- [ ] **1.16** Long pull crossing uhttpd `script_timeout` (~55s): server-side connection rotates transparently (auto-reconnect in `Model._stream`); log keeps growing, no spurious error
|
||||
|
||||
---
|
||||
|
||||
## 2. Images (`/admin/podman/images`)
|
||||
|
||||
- [x] **2.1** Load images list → Page loads (may be empty)
|
||||
- [x] **2.2** Click "Pull Image" → Form/modal appears with name input
|
||||
- [x] **2.3** Submit empty form → Validation error, no request sent
|
||||
- [x] **2.4** Pull nonexistent image (e.g. `this-does-not-exist:fake`) → Error notification shown, list unchanged
|
||||
- [x] **2.5** Pull `alpine:latest` → Progress indicator → success notification → `alpine` appears in list
|
||||
- [ ] **2.6** Pull `alpine:latest` again (already exists) → Graceful result (no crash, may show "already up to date")
|
||||
- [x] **2.7** Click inspect on `alpine` → Modal opens with JSON image data
|
||||
- [x] **2.8** Return to overview → Image count incremented by 1
|
||||
|
||||
---
|
||||
|
||||
## 3. Containers (`/admin/podman/containers`)
|
||||
|
||||
*Requires `alpine:latest` from step 2.5.*
|
||||
|
||||
### 3a. Create
|
||||
|
||||
- [x] **3.1** Load containers list → Page loads (may be empty)
|
||||
- [x] **3.2** Click "Create Container" → Form modal opens
|
||||
- [x] **3.3** Submit empty form → Validation errors on required fields
|
||||
- [x] **3.4** Fill name: `test-alpine`, image: `alpine:latest`, no other changes → Submit → Container created; `test-alpine` visible in list
|
||||
- [x] **3.5** Return to overview → Container count incremented
|
||||
|
||||
### 3b. List operations
|
||||
|
||||
- [x] **3.6** Verify `test-alpine` row shows correct Name, Image, State
|
||||
- [x] **3.7** Select `test-alpine` checkbox → Only that row selected
|
||||
- [x] **3.8** Click top "select all" checkbox → All rows selected
|
||||
- [x] **3.9** Click top checkbox again → All deselected
|
||||
|
||||
### 3c. Start / Stop / Restart
|
||||
|
||||
- [x] **3.10** If container is stopped: select → Start → State changes to `Running`; success notification
|
||||
- [x] **3.11** Select running container → Stop → State changes to `Exited`; success notification
|
||||
- [x] **3.12** Select stopped container → Restart → State changes to `Running`
|
||||
|
||||
### 3d. Detail view - Info tab
|
||||
|
||||
- [x] **3.13** Click `test-alpine` name link → Navigates to container detail page
|
||||
- [x] **3.14** Name, image, state displayed → Matches list view
|
||||
- [x] **3.15** Edit container name to `test-alpine-renamed` → Save → Name updated; notification shown
|
||||
- [x] **3.16** Change restart policy to `always` → Save → Policy updated; success notification
|
||||
- [x] **3.17** Init script auto-generated → Auto-start indicator shows enabled
|
||||
- [x] **3.18** Click "Show Init Script" → Modal with script content; script references `test-alpine-renamed`
|
||||
- [x] **3.19** Toggle init script disabled → Indicator changes
|
||||
- [x] **3.20** Toggle init script enabled again → Indicator reverts
|
||||
- [x] **3.21** Change restart policy to `no` → Save → Init script removed/disabled
|
||||
- [x] **3.22** Navigate back to list → Name shown as `test-alpine-renamed`
|
||||
|
||||
### 3e. Detail view - Resources tab
|
||||
|
||||
- [x] **3.23** Click Resources tab → Tab activates; current limits shown
|
||||
- [ ] **3.24** Set CPU limit to `0.5` → Save → After page reload, Resources tab is active (tab persistence)
|
||||
- [x] **3.25** Set Memory limit to `64m` → Save → Value persisted
|
||||
- [x] **3.26** Enter invalid CPU value (e.g. `abc`) → Save → Validation error; no save
|
||||
|
||||
### 3f. Detail view - Stats tab
|
||||
|
||||
- [x] **3.27** Start container (if stopped)
|
||||
- [x] **3.28** Click Stats tab → CPU%, Memory, Net I/O data appears
|
||||
- [x] **3.29** Stop container; click Stats tab → Graceful empty/zero state, no JS crash
|
||||
|
||||
### 3g. Detail view - Logs tab
|
||||
|
||||
- [x] **3.30** Click Logs tab → Log output area visible (may be empty for alpine)
|
||||
- [x] **3.31** Change line count to `50` → Apply → Fetches last 50 lines
|
||||
- [ ] **3.32** Enable live streaming toggle → Toggle activates
|
||||
- [x] **3.33** Disable live streaming → Streaming stops, toggle off
|
||||
- [x] **3.34** Click Clear → Log area clears
|
||||
|
||||
### 3h. Detail view - Processes tab
|
||||
|
||||
- [x] **3.35** Start container; click Processes tab → Process table appears
|
||||
- [x] **3.36** Stop container; click Processes tab → Graceful empty/error state, no JS crash
|
||||
|
||||
### 3i. Network management (on detail page)
|
||||
|
||||
- [x] **3.37** Note current networks attached → Baseline
|
||||
- [x] **3.38** Disconnect default network → Network removed from list
|
||||
- [x] **3.39** Reconnect the network → Network added back
|
||||
- [x] **3.40** Connect a network with valid static IP → Network added with IP shown
|
||||
- [x] **3.41** Connect a network with invalid IP → Validation error
|
||||
|
||||
### 3j. Pause / Unpause
|
||||
|
||||
- [ ] **3.42** List view: select a running container → click ⏸⏸ Pause → state changes to `paused`; success notification
|
||||
- [ ] **3.43** List view: same paused container selected → click ▶ Start → state changes back to `running` (Start polymorphically unpauses paused containers)
|
||||
- [ ] **3.44** Detail view: container is running → ▶ Start is highlighted as active, ⏸⏸ Pause is clickable
|
||||
- [ ] **3.45** Detail view: click Pause → reload → state is `paused`, ⏸⏸ Pause is now highlighted, ▶ Start is clickable
|
||||
- [ ] **3.46** Detail view: click Start while paused → reload → state is `running`, ▶ Start highlighted again
|
||||
- [ ] **3.47** Detail view: streams (stats / logs / processes) stop before pause fires (no reconnect storm in DevTools Network panel)
|
||||
- [ ] **3.48** Multi-select on list: mix of running + paused → click ▶ Start → running stays running, paused becomes running (per-container dispatch)
|
||||
|
||||
---
|
||||
|
||||
## 4. Volumes (`/admin/podman/volumes`)
|
||||
|
||||
- [x] **4.1** Load volumes list → Page loads (may be empty)
|
||||
- [x] **4.2** Click "Create Volume" → Form appears
|
||||
- [x] **4.3** Submit empty form → Name will be generated
|
||||
- [x] **4.4** Create volume named `test-vol` → Appears in list with name, driver, created date
|
||||
- [x] **4.5** Click inspect on `test-vol` → JSON modal shows volume data
|
||||
- [x] **4.6** Return to overview → Volume count incremented
|
||||
- [x] **4.7** Select `test-vol` → Delete → Confirmation modal → confirm → removed from list
|
||||
- [ ] **4.8** Create volume `test-vol-prune` and leave it → Used in prune test (step 8.2)
|
||||
|
||||
---
|
||||
|
||||
## 5. Networks (`/admin/podman/networks`)
|
||||
|
||||
- [x] **5.1** Load networks list → Default `podman` network visible
|
||||
- [x] **5.2** Note OpenWrt column for default network → Status icon shown
|
||||
- [x] **5.3** Click "Create Network" → Form opens
|
||||
- [x] **5.4** Submit empty form → Validation error
|
||||
- [x] **5.5** Create `test_net`, subnet `10.220.0.0/24`, gateway `10.220.0.1`, OpenWrt integration checked → Network created; OpenWrt column shows `✓`
|
||||
- [x] **5.6** Verify UCI config created → SSH: `uci show network.test_net` returns values
|
||||
- [x] **5.7** Create `test_net_b` with no subnet, integration unchecked → Network created; OpenWrt column shows `-`
|
||||
- [x] **5.8** If any network shows `X` (incomplete integration): click it → setup → icon changes to `✓`
|
||||
- [x] **5.9** Return to overview → Network count incremented
|
||||
- [x] **5.10** Select `test_net` → Delete → Confirmation mentions OpenWrt cleanup → confirm → network removed; UCI config removed
|
||||
- [x] **5.11** Select `test_net_bare` → Delete → Removed cleanly (no UCI to clean up)
|
||||
|
||||
---
|
||||
|
||||
## 6. Secrets (`/admin/podman/secrets`)
|
||||
|
||||
- [x] **6.1** Load secrets list → Page loads (may be empty)
|
||||
- [x] **6.2** Click "Create Secret" → Form with name + value fields
|
||||
- [x] **6.3** Submit empty form → Validation error
|
||||
- [x] **6.4** Create secret name `test-secret`, value `hunter2` → Appears in list; value NOT shown anywhere
|
||||
- [x] **6.5** Click inspect on `test-secret` → Modal shows metadata; `SecretData` field hidden
|
||||
- [x] **6.6** Delete `test-secret` → Removed from list
|
||||
|
||||
---
|
||||
|
||||
## 7. Pods (`/admin/podman/pods`)
|
||||
|
||||
*Requires `alpine:latest` from step 2.5. If a user-defined network exists from section 5, note its name as `<usernet>`.*
|
||||
|
||||
### 7a. List view
|
||||
|
||||
- [x] **7.1** Load pods list → Page loads with empty/populated table; no "Work in progress…" stub
|
||||
- [x] **7.2** Toolbar visible → Create / Delete / Reload / ▶ / ■ / ⟳ / ⏸ / ⏵ buttons present
|
||||
- [x] **7.3** Click Reload with empty list → No error, indicator appears briefly
|
||||
|
||||
### 7b. Create - minimal
|
||||
|
||||
- [x] **7.4** Click "Create" → Pod form modal opens with default fields populated
|
||||
- [x] **7.5** Submit empty form → No validation error (name auto-generates); pod appears with generated name
|
||||
- [x] **7.6** Create pod name `pod-min`, all other defaults → Modal closes, success notification, `pod-min` row appears
|
||||
- [x] **7.7** Verify SSH `podman pod inspect pod-min` → Pod exists with one infra container
|
||||
- [x] **7.8** Containers column shows `1 ▸` for `pod-min`
|
||||
|
||||
### 7c. Create - full options
|
||||
|
||||
- [x] **7.9** Open Create form → Name `pod-full`, Hostname `pod-full-host`, Shared Namespaces `cgroup,ipc,net,uts`, Infra ON, Network `<usernet>`, DNS Servers `1.1.1.1` and `9.9.9.9`, DNS Search `lan`, CPU Limit `1.0`, CPU Set `0-1`, Memory `256m`, Labels `app=demo\nenv=test` → Submit
|
||||
- [x] **7.10** SSH `podman pod inspect pod-full | jq '.Hostname'` → `pod-full-host`
|
||||
- [x] **7.11** SSH `podman pod inspect pod-full | jq '.InfraConfig.NetworkOptions'` → contains DNS server + search entries
|
||||
- [x] **7.12** SSH `podman pod inspect pod-full | jq '.Labels'` → `app: demo`, `env: test`
|
||||
- [x] **7.13** Networks column for `pod-full` → shows `<usernet>`
|
||||
|
||||
### 7d. Status badges
|
||||
|
||||
- [x] **7.14** New pod → Status badge `Created` (gray)
|
||||
- [x] **7.15** After Start → Badge `Running` (green)
|
||||
- [x] **7.16** After Pause → Badge `Paused` (orange)
|
||||
- [x] **7.17** After Stop → Badge `Exited` or `Stopped` (red)
|
||||
|
||||
### 7e. Lifecycle - single select
|
||||
|
||||
- [x] **7.18** Tick `pod-min`, click ▶ Start → Loading "Starting pod: 1/1", refresh shows `Running`
|
||||
- [x] **7.19** Click ■ Stop → Status `Exited`/`Stopped`
|
||||
- [x] **7.20** Click ⟳ Restart → Status `Running`
|
||||
- [x] **7.21** Click ⏸ Pause → Status `Paused`
|
||||
- [x] **7.22** Click ⏵ Unpause → Status `Running`
|
||||
|
||||
### 7f. Lifecycle - multi-select
|
||||
|
||||
- [ ] **7.23** Create `pod-a` and `pod-b` (defaults), select both → ▶ Start
|
||||
- [ ] **7.24** Loading modal shows "Starting pod: 1/2" then "2/2"
|
||||
- [ ] **7.25** Both pods end up `Running`
|
||||
- [ ] **7.26** No JS console errors during multi-action
|
||||
|
||||
### 7g. Detail page navigation (from list)
|
||||
|
||||
- [ ] **7.27** Click pod **Name** in list → Navigates to `/admin/podman/pod/<id>` (no more inspect modal)
|
||||
- [ ] **7.28** Browser back button → Returns to pods list with state preserved
|
||||
|
||||
### 7h. Containers expand modal
|
||||
|
||||
- [x] **7.29** SSH `podman run -d --pod pod-min --name pod-min-c1 alpine sleep 3600` → container created in pod
|
||||
- [x] **7.30** Reload pods list → `pod-min` Containers column shows `2 ▸`
|
||||
- [x] **7.31** Click `2 ▸` link → Modal "Containers in pod pod-min" opens with 2 rows (infra + new)
|
||||
- [x] **7.32** Each row shows Name, truncated ID, Status badge, Restarts count
|
||||
- [x] **7.33** Click container ID link in modal → Navigates to `/admin/podman/container/<id>` detail view
|
||||
|
||||
### 7i. Pod removal
|
||||
|
||||
- [ ] **7.34** Stop `pod-min` first → Status `Exited`
|
||||
- [ ] **7.35** Tick `pod-min` → Delete → Confirm
|
||||
- [ ] **7.36** SSH `podman pod ls` → `pod-min` gone
|
||||
- [ ] **7.37** SSH `podman ps -a` → `pod-min-c1` also gone (cascade delete via force)
|
||||
|
||||
### 7j. Container form integration
|
||||
|
||||
- [x] **7.38** Navigate **Podman → Containers → Create** → Form shows new **Pod** ListValue (after Image) with "(none)" + existing pod names
|
||||
- [x] **7.39** Select Pod = `pod-full` → Network, Hostname, Port Mappings, Expose Ports fields hide
|
||||
- [x] **7.40** Select Pod = "(none)" → Hidden fields reappear
|
||||
- [x] **7.41** Create container with Pod `pod-full`, Image `alpine:latest`, Command `sleep 3600`, Start ON → Success
|
||||
- [x] **7.42** SSH `podman ps --filter pod=pod-full` → New container listed
|
||||
- [x] **7.43** Pods list `pod-full` Containers column → count incremented
|
||||
|
||||
### 7k. Edge cases
|
||||
|
||||
- [ ] **7.44** Create pod with name `Bad Name!` → Validation error (uciname constraint)
|
||||
- [ ] **7.45** Toggle "Create Infra Container" OFF → Network/DNS/Infra Image fields hide
|
||||
- [ ] **7.46** Submit pod with Infra OFF → Pod created without infra container; SSH `podman pod inspect` confirms
|
||||
- [ ] **7.47** Try Stop on already-stopped pod → No JS error (graceful handling of API 304/409)
|
||||
- [ ] **7.48** Try Pause on stopped pod → Error notification, no JS crash
|
||||
|
||||
### 7l. Regression - non-pod container create
|
||||
|
||||
- [ ] **7.49** Create container with Pod = "(none)", port mapping `8080:80`, network `<usernet>`, hostname `freebee` → Success
|
||||
- [ ] **7.50** SSH inspect → port mapping, network and hostname present (confirms `if (!data.pod)` guards don't break the non-pod path)
|
||||
|
||||
### 7m. Cleanup
|
||||
|
||||
- [ ] **7.51** SSH `podman pod rm -f pod-full pod-a pod-b` → All test pods removed
|
||||
- [ ] **7.52** SSH `podman ps -a` → No leftover test containers
|
||||
- [ ] **7.53** Reload pods list → Empty/clean state
|
||||
|
||||
### 7n. Detail page - load & header (`/admin/podman/pod/<id>`)
|
||||
|
||||
- [ ] **7.54** Direct URL `/admin/podman/pod/<podId>` → Detail page loads, no console errors
|
||||
- [ ] **7.55** Header shows pod name and back-arrow button → back button returns to pods list
|
||||
- [ ] **7.56** Lifecycle buttons visible: ▶ Start, ■ Stop, ⟳ Restart, ⏸ Pause, ⏵ Unpause, 🗑 Delete
|
||||
- [ ] **7.57** "Active" highlight reflects current state: Running pod → ▶ highlighted, Paused pod → ⏸ highlighted, Exited pod → ■ highlighted (semantic: button describes current state, not "available action")
|
||||
- [ ] **7.58** Tabs visible: Info, Stats, Processes, Inspect
|
||||
|
||||
### 7o. Detail page - Info tab
|
||||
|
||||
- [ ] **7.59** Basic Information section: Name, ID (full 64-char), Status badge, Created date populated
|
||||
- [ ] **7.60** Configuration section: Hostname, Cgroup parent, Exit policy, Restart policy, Shared namespaces (comma-joined), Infra container (clickable link to `/admin/podman/container/<infraId>`)
|
||||
- [ ] **7.61** Resources section: CPU period (µs), CPU quota (µs or "Unlimited"), CPU shares, CPU set, Memory limit (formatted bytes or "Unlimited"). Description note "Resources are set at pod creation and cannot be edited here." present
|
||||
- [ ] **7.62** Network section: Networks, Static IPv4, Static MAC, DNS servers, DNS search, DNS options, Extra hosts, Host network (Yes/No)
|
||||
- [ ] **7.63** Containers section: table with Name, truncated ID (clickable → container detail), Status badge - one row per container including infra
|
||||
- [ ] **7.64** Labels section: only rendered if pod has labels; otherwise omitted
|
||||
- [ ] **7.65** All empty fields show `-` placeholder
|
||||
|
||||
### 7p. Detail page - Stats tab (pod running)
|
||||
|
||||
- [ ] **7.66** Switch to Stats tab → Table with headers: Container, CID, CPU %, Memory, Memory %, Net I/O, Block I/O, PIDs
|
||||
- [ ] **7.67** Rows update live (humanized strings from `PodStatsReport`: e.g. "75.5%", "12mb / 24mb")
|
||||
- [ ] **7.68** Container name column resolves from pod's containers map (not the pod-name value the API returns)
|
||||
- [ ] **7.69** Switch to another tab → Stream stops (verify in DevTools Network: stream connection closes)
|
||||
- [ ] **7.70** Switch back to Stats → Stream restarts
|
||||
- [ ] **7.71** Pod is stopped → Stats tab shows "Pod is not running" warning, no stream attempt
|
||||
|
||||
### 7q. Detail page - Processes tab (pod running)
|
||||
|
||||
- [ ] **7.72** Switch to Processes tab → Table with columns Podman's `pod top` returns (USER, PID, ..., CMD) plus a CONTAINER column (native to pod top)
|
||||
- [ ] **7.73** Rows update live as processes change
|
||||
- [ ] **7.74** Switch away → Stream stops
|
||||
- [ ] **7.75** Pod is stopped → Shows "Pod is not running" warning
|
||||
|
||||
### 7r. Detail page - Inspect tab
|
||||
|
||||
- [ ] **7.76** Switch to Inspect tab → Renders full raw JSON of `podman pod inspect`
|
||||
- [ ] **7.77** Same content as old list-view inspect modal (sanity check no fields are lost)
|
||||
|
||||
### 7s. Detail page - lifecycle actions
|
||||
|
||||
- [ ] **7.78** Running pod → click ⏸ Pause → confirmation/loading → reload → status `Paused`, ⏸ now highlighted as active
|
||||
- [ ] **7.79** Paused pod → click ⏵ Unpause → reload → status `Running`
|
||||
- [ ] **7.80** Running pod → click ■ Stop → reload → status `Exited`, ■ highlighted
|
||||
- [ ] **7.81** Stopped pod → click ▶ Start → reload → status `Running`
|
||||
- [ ] **7.82** Click ⟳ Restart → reload → status briefly cycles to Running
|
||||
- [ ] **7.83** Click 🗑 Delete → confirm dialog → confirm → redirects to `/admin/podman/pods`, pod gone
|
||||
- [ ] **7.84** Before Stop/Pause/Restart, verify in DevTools Network: stats/processes streams close BEFORE the action fires (no reconnect storm)
|
||||
|
||||
### 7t. Detail page - edge cases
|
||||
|
||||
- [ ] **7.85** Pod with no containers (rare - infra-less or just created): Containers section shows "No containers in this pod"
|
||||
- [ ] **7.86** Pod with no labels: Labels section is absent (not just empty)
|
||||
- [ ] **7.87** Pod with `RestartPolicy` unset: Configuration shows `-`
|
||||
- [ ] **7.88** Pod with `memory_limit: 0` or unset: Resources shows "Unlimited"
|
||||
- [ ] **7.89** Pod with `cpu_quota: -1`: Resources shows "Unlimited"
|
||||
- [ ] **7.90** Invalid pod ID in URL → Redirects to pods list (no crash)
|
||||
- [ ] **7.91** Refresh page while stats stream active → No duplicate streams (Network panel shows single connection)
|
||||
|
||||
---
|
||||
|
||||
## 8. Modals
|
||||
|
||||
### Prune Modal
|
||||
|
||||
- [x] **8.1** Open prune modal → Options shown: containers, images, volumes, networks
|
||||
- [ ] **8.2** Select only "Volumes" → Prune → Only unused volumes pruned (e.g. `test-vol-prune` from step 4.8); containers/images untouched
|
||||
- [ ] **8.3** Select "Containers" → Prune → Only stopped/exited containers removed
|
||||
|
||||
### Auto-Update Modal
|
||||
|
||||
- [x] **8.4** Open auto-update modal → Container list shown with current digest info
|
||||
- [x] **8.5** Click "Check for Updates" → Digest comparison runs; up-to-date containers marked
|
||||
- [x] **8.6** If outdated container detected → Update → Pulls new image, recreates container
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
- [ ] **9.1** Stop Podman socket (`/etc/init.d/podman stop`), load any list view → Clear RPC error message; no JS crash, no blank page
|
||||
- [x] **9.2** Navigate to `/admin/podman/container/nonexistent-id` → Graceful error, not a blank page
|
||||
- [ ] **9.3** Start Podman again (`/etc/init.d/podman start`); reload → Normal operation resumes
|
||||
- [x] **9.4** Trigger two operations quickly in succession → No race-condition crash or duplicate requests
|
||||
|
||||
---
|
||||
|
||||
## 10. Cleanup Verification
|
||||
|
||||
- [x] **10.1** Delete any remaining test containers (e.g. `test-alpine-renamed`)
|
||||
- [x] **10.2** Delete `alpine` image
|
||||
- [x] **10.3** Containers list → Empty (or back to pre-test state)
|
||||
- [x] **10.4** Images list → Empty (or back to pre-test state)
|
||||
- [x] **10.5** Overview counters → Match baseline from step 1.4
|
||||
@@ -0,0 +1,326 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require form';
|
||||
'require ui';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.utils as podmanUtil';
|
||||
|
||||
const FormTableSelectSection = form.TableSection.extend({
|
||||
__name__: 'Podman.Form.TableSelectSection',
|
||||
|
||||
checkboxColumn: null,
|
||||
view: null,
|
||||
toolbarExtraButtons: [],
|
||||
modalCreateBtnText: null,
|
||||
modalCreateTitle: null,
|
||||
handleRefreshCb: null,
|
||||
|
||||
render() {
|
||||
if (!this.checkboxColumn) {
|
||||
this.checkboxColumn = this.option(
|
||||
podmanForm.field.SelectDummyValue,
|
||||
this.selectKey,
|
||||
new ui.Checkbox(0, { hiddenname: 'all' }).render()
|
||||
);
|
||||
this.checkboxColumn.width = 30;
|
||||
|
||||
podmanUtil.moveArrayItem(this.children, this.children.length - 1, 0);
|
||||
}
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
renderContents(cfgsections, nodes) {
|
||||
const config_name = this.uciconfig ?? this.map.config;
|
||||
const max_cols = this.children.length;
|
||||
|
||||
if (this.footer === true) {
|
||||
this.footer = [
|
||||
`${_('Total')} ${nodes.length}`,
|
||||
];
|
||||
}
|
||||
|
||||
const sectionEl = E('div', {
|
||||
id: 'cbi-%s-%s'.format(config_name, this.sectiontype),
|
||||
class: 'cbi-section cbi-tblsection',
|
||||
});
|
||||
|
||||
const tableEl = E('table', { class: 'table cbi-section-table cbi-section-table-select' });
|
||||
const theadEl = E('thead', { class: 'thead cbi-section-thead' });
|
||||
const tbodyEl = E('tbody', { class: 'tbody cbi-section-tbody' });
|
||||
const tfootEl = E('tfoot', { class: 'tfoot cbi-section-tfoot' });
|
||||
|
||||
if (this.description != null && this.description !== '') {
|
||||
sectionEl.appendChild(E('div', { class: 'cbi-section-descr' }, this.description));
|
||||
}
|
||||
|
||||
sectionEl.appendChild(E('div', { class: 'cbi-section-actions' }, this.renderToolbar()));
|
||||
|
||||
theadEl.appendChild(this.renderHeaderRows(false));
|
||||
|
||||
if (theadEl.hasChildNodes())
|
||||
tableEl.appendChild(theadEl);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const trEl = E('tr', {
|
||||
id: 'cbi-%s-%s'.format(config_name, cfgsections[i]),
|
||||
class: 'tr cbi-section-table-row',
|
||||
'data-sid': cfgsections[i],
|
||||
'data-section-id': cfgsections[i]
|
||||
});
|
||||
|
||||
for (let j = 0; j < max_cols && nodes[i].firstChild; j++) {
|
||||
trEl.appendChild(nodes[i].firstChild);
|
||||
}
|
||||
|
||||
tbodyEl.appendChild(trEl);
|
||||
}
|
||||
|
||||
if (nodes.length === 0)
|
||||
tbodyEl.appendChild(E('tr', { class: 'tr cbi-section-table-row' },
|
||||
E('td', { class: 'td text-center', colspan: max_cols }, this.renderSectionPlaceholder())));
|
||||
|
||||
tableEl.appendChild(tbodyEl);
|
||||
|
||||
tfootEl.appendChild(this.renderFooterRows(false));
|
||||
|
||||
if (tfootEl.hasChildNodes()) {
|
||||
tableEl.appendChild(tfootEl);
|
||||
}
|
||||
|
||||
this.registerCheckboxEvents(theadEl, tableEl);
|
||||
|
||||
sectionEl.appendChild(tableEl);
|
||||
|
||||
dom.bindClassInstance(sectionEl, this);
|
||||
|
||||
return sectionEl;
|
||||
},
|
||||
|
||||
renderToolbar() {
|
||||
return E('div', { class: 'list-toolbar mb-sm d-flex justify-center gap-xs' }, this.getToolbarButtons());
|
||||
},
|
||||
|
||||
getToolbarButtons() {
|
||||
const buttons = [];
|
||||
|
||||
if (this.handleCreate) {
|
||||
buttons.push(this.getCreateButton());
|
||||
}
|
||||
if (this.handleRemove) {
|
||||
buttons.push(this.getRemoveButton());
|
||||
}
|
||||
|
||||
if (this.handleRefresh) {
|
||||
buttons.push(this.getReloadButton());
|
||||
}
|
||||
|
||||
if (this.toolbarExtraButtons.length > 0) {
|
||||
buttons.push(...this.toolbarExtraButtons);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
},
|
||||
|
||||
getCreateButton() {
|
||||
return new podmanUI.ButtonNew(_('Create'), {
|
||||
click: ui.createHandlerFn(this, 'handleCreate'),
|
||||
type: 'add',
|
||||
}).render();
|
||||
},
|
||||
|
||||
getRemoveButton() {
|
||||
return new podmanUI.ButtonNew(_('Delete'), {
|
||||
click: ui.createHandlerFn(this, 'handleRemove'),
|
||||
type: 'remove',
|
||||
}).render();
|
||||
},
|
||||
|
||||
getReloadButton() {
|
||||
return new podmanUI.ButtonNew(_('Reload'), {
|
||||
click: ui.createHandlerFn(this, 'handleRefresh'),
|
||||
type: 'apply',
|
||||
}).render();
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
const createForm = new this.createForm.init();
|
||||
const formElement = await createForm.render();
|
||||
const title = this.modalCreateTitle || _('Create %s').format(this.view.titleSingle || '').trim();
|
||||
|
||||
const modal = new podmanUI.Modal(title, [ formElement ]);
|
||||
modal.getButtons = () => [
|
||||
modal.getCloseButton(),
|
||||
new podmanUI.ButtonNew(this.modalCreateBtnText || _('Create'), {
|
||||
click: () => {
|
||||
createForm.handleCreate().then(() => this.handleRefresh());
|
||||
},
|
||||
type: 'positive',
|
||||
}).render(),
|
||||
];
|
||||
modal.render();
|
||||
},
|
||||
|
||||
async handleRemove() {
|
||||
const selected = this.getSelectedData();
|
||||
|
||||
if (selected.length === 0) {
|
||||
return this.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
this.view.confirm([
|
||||
E('p', {}, _('Are you sure to delete the records?')),
|
||||
], async () => {
|
||||
let i = 1;
|
||||
for (const item of selected) {
|
||||
this.view.loading(_('Deleting records: %d/%d').format(i, selected.length));
|
||||
|
||||
await item.remove();
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
this.handleRefresh();
|
||||
});
|
||||
},
|
||||
|
||||
async handleRefresh() {
|
||||
const indicatorId = 'podman-refresh-' + this.sectiontype;
|
||||
ui.showIndicator(indicatorId, _('Refreshing %s...').format(this.sectiontype));
|
||||
|
||||
const data = await this.view.load();
|
||||
|
||||
if (this.footer) {
|
||||
const count = Array.isArray(data) ? data.length : 0;
|
||||
this.footer = [`${_('Total')} ${count}`];
|
||||
}
|
||||
|
||||
const obj = {};
|
||||
obj[this.sectiontype] = data;
|
||||
|
||||
this.map.data.__init__(obj);
|
||||
|
||||
this.map.root
|
||||
.querySelector('input[type="hidden"][name="all"] ~ input[type=checkbox]').checked = false;
|
||||
|
||||
await this.map.save(null, false);
|
||||
await this.map.load();
|
||||
await this.map.reset();
|
||||
|
||||
if (this.handleRefreshCb) await this.handleRefreshCb();
|
||||
|
||||
ui.hideIndicator(indicatorId);
|
||||
},
|
||||
|
||||
handleInspect(item, hiddenFields) {
|
||||
this.view.loading(_('Fetching information...'));
|
||||
|
||||
item.inspect().then((data) => {
|
||||
ui.hideModal();
|
||||
this.showInspectModal(data, hiddenFields);
|
||||
});
|
||||
},
|
||||
|
||||
showInspectModal(data, hiddenFields) {
|
||||
const displayData = JSON.parse(JSON.stringify(data));
|
||||
|
||||
if (hiddenFields && hiddenFields.length > 0) {
|
||||
hiddenFields.forEach((field) => {
|
||||
if (displayData[field]) {
|
||||
displayData[field] = '***HIDDEN***';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const content = [
|
||||
new podmanUI.JsonArea(displayData).render(),
|
||||
];
|
||||
|
||||
if (hiddenFields && hiddenFields.length > 0) {
|
||||
content.unshift(E('p', { class: 'mb-sm text-error' }, [
|
||||
E('strong', {}, _('Security Notice:')), ' ', _('Sensitive data is hidden for security reasons.')
|
||||
]));
|
||||
}
|
||||
|
||||
const modal = new podmanUI.Modal('', content);
|
||||
modal.handleSubmit = undefined;
|
||||
modal.render();
|
||||
},
|
||||
|
||||
showNoneSelectedWarning() {
|
||||
this.view.warning(_('No %s selected').format(this.sectiontype));
|
||||
},
|
||||
|
||||
registerCheckboxEvents(theadEl, tableEl) {
|
||||
const checkboxSelectAll = theadEl.querySelector('input[name="all"] + input[type="checkbox"]');
|
||||
const rows = tableEl.querySelectorAll('tbody tr');
|
||||
let lastClickedIndex = -1;
|
||||
|
||||
if (!checkboxSelectAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkboxSelectAll.addEventListener('click', (event) => {
|
||||
const checked = event.target.checked;
|
||||
tableEl.querySelectorAll('tbody tr input[type="checkbox"]').forEach((checkbox) => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
});
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const checkbox = row.querySelector('input[type=checkbox]');
|
||||
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.addEventListener('click', (ev) => {
|
||||
const clickedInteractiveElement = ev.target.closest('button, a, input, .cbi-tooltip-container');
|
||||
|
||||
// If the click was inside an interactive element, ignore it and exit.
|
||||
if (clickedInteractiveElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkbox.click();
|
||||
});
|
||||
|
||||
checkbox.addEventListener('click', (ev) => {
|
||||
// Handle shift+click for range selection
|
||||
if (ev.shiftKey && lastClickedIndex !== -1 &&
|
||||
lastClickedIndex !== index) {
|
||||
const start = Math.min(lastClickedIndex, index);
|
||||
const end = Math.max(lastClickedIndex, index);
|
||||
const targetState = checkbox.checked;
|
||||
|
||||
// Select/deselect all checkboxes in range
|
||||
for (let i = start; i <= end; i++) {
|
||||
rows[i].querySelector('input[type=checkbox]').checked = targetState;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last clicked index
|
||||
lastClickedIndex = index;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getSelectedCheckboxes() {
|
||||
return this.map.root.querySelectorAll('tbody tr input[type=checkbox]:checked');
|
||||
},
|
||||
|
||||
getSelectedData() {
|
||||
return [...this.getSelectedCheckboxes()].map((checkbox) => {
|
||||
const sectionId = checkbox.previousSibling.name;
|
||||
return this.map.data.data[sectionId];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
TableSelectSection: FormTableSelectSection
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
const NOTIFICATION_TIMEOUT = 2000;
|
||||
|
||||
const ICON = {
|
||||
START: '►',
|
||||
STOP: '◼',
|
||||
RESTART: '↻',
|
||||
PAUSE: '❚❚',
|
||||
CLEAR_LOG: '🗑️',
|
||||
INIT_ENABLED: '✓',
|
||||
INIT_MISSING: '⚠',
|
||||
INIT_DISABLED: '⏼',
|
||||
BACK: '🔙',
|
||||
};
|
||||
|
||||
return baseclass.extend({
|
||||
NOTIFICATION_TIMEOUT,
|
||||
ICON,
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require form';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.utils as podmanUtil';
|
||||
|
||||
const FormMemoryValue = form.Value.extend({
|
||||
placeholder: '512m, 1g, -1',
|
||||
validate(_section_id, value) {
|
||||
if (!value) return true;
|
||||
if (value === '-1' || value === '0') return true;
|
||||
if (!/^\d+(?:\.\d+)?\s*[kmg]b?$/i.test(value)) {
|
||||
return _('Expecting: %s').format(_('512m, 1g, or -1 for unlimited'));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormDummyValue = form.DummyValue.extend({
|
||||
__name__: 'Podman.Form.DummyValue',
|
||||
|
||||
cfgformatter: (cfg, _data) => cfg,
|
||||
cfgdatavalue: (_data) => '',
|
||||
cfgtt: (_cfg, _data) => '',
|
||||
|
||||
cfgvalue(section_id, set_value) {
|
||||
if (section_id == null)
|
||||
L.error('TypeError', 'Section ID required');
|
||||
|
||||
const data = this.map.data.data[section_id];
|
||||
const dataValue = this.cfgdatavalue(data);
|
||||
|
||||
if (dataValue) {
|
||||
return this._renderValue(dataValue, data);
|
||||
}
|
||||
|
||||
if (arguments.length === 2 && set_value) {
|
||||
this.data ??= {};
|
||||
this.data[section_id] = set_value;
|
||||
}
|
||||
|
||||
return this._renderValue(this.data?.[section_id], data);
|
||||
},
|
||||
|
||||
_renderValue(value, data) {
|
||||
const tooltip = this.cfgtt(value, data);
|
||||
const formatted = this.cfgformatter(value, data);
|
||||
return tooltip ? new podmanUI.Tooltip(formatted, tooltip).render() : formatted;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormSelectDummyValue = form.DummyValue.extend({
|
||||
__name__: 'Podman.Form.SelectDummyValue',
|
||||
|
||||
cfgvalue(sectionId) {
|
||||
return new ui.Checkbox(0, { hiddenname: sectionId }).render();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormLinkDummyValue = FormDummyValue.extend({
|
||||
__name__: 'Podman.Form.LinkDummyValue',
|
||||
|
||||
url: '#',
|
||||
click: undefined,
|
||||
|
||||
cfgformatter(cfg, model) {
|
||||
return E('a', {
|
||||
class: 'text-bold',
|
||||
href: this.url,
|
||||
click: (ev) => {
|
||||
if (typeof this.click === 'function') {
|
||||
ev.preventDefault();
|
||||
this.click(cfg, model);
|
||||
}
|
||||
}
|
||||
}, cfg);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormDateDummyValue = FormDummyValue.extend({
|
||||
__name__: 'Podman.Form.DateDummyValue',
|
||||
cfgformatter: podmanUtil.format.date,
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormTimestampDummyValue = FormDateDummyValue.extend({
|
||||
__name__: 'Podman.Form.TimestampDummyValue',
|
||||
cfgformatter: (value) => podmanUtil.format.date(parseInt(value)),
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const FormByteDummyValue = FormDummyValue.extend({
|
||||
__name__: 'Podman.Form.ByteDummyValue',
|
||||
cfgformatter: podmanUtil.format.bytes,
|
||||
});
|
||||
|
||||
const FormEditableField = baseclass.extend({
|
||||
__name__: 'Podman.Form.EditableField',
|
||||
name: null,
|
||||
formField: null,
|
||||
|
||||
__init__(name, formField, onSubmit) {
|
||||
this.name = name;
|
||||
this.formField = formField;
|
||||
if (onSubmit) {
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
return E('div', { class: `editable-field editable-field-${this.name}` }, [
|
||||
this.formField,
|
||||
new podmanUI.Button(
|
||||
_('Update'),
|
||||
() => this.onSubmit(document.querySelector(`.editable-field-${this.name} [name="${this.name}"]`).value),
|
||||
'apply'
|
||||
).render()
|
||||
]);
|
||||
},
|
||||
|
||||
onSubmit(_value) {}
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
__name__: 'Podman.Form',
|
||||
|
||||
EditableField: FormEditableField,
|
||||
field: {
|
||||
MemoryValue: FormMemoryValue,
|
||||
DummyValue: FormDummyValue,
|
||||
ByteDummyValue: FormByteDummyValue,
|
||||
DateDummyValue: FormDateDummyValue,
|
||||
LinkDummyValue: FormLinkDummyValue,
|
||||
SelectDummyValue: FormSelectDummyValue,
|
||||
TimestampDummyValue: FormTimestampDummyValue,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.model.Container as Container';
|
||||
|
||||
/**
|
||||
* Create podman container
|
||||
*/
|
||||
const PodmanFormContainer = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Container',
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
container: {
|
||||
name: '',
|
||||
image: '',
|
||||
pod: '',
|
||||
command: null,
|
||||
ports: null,
|
||||
env: null,
|
||||
volumes: null,
|
||||
network: 'bridge',
|
||||
restart: 'no',
|
||||
privileged: '0',
|
||||
tty: '0',
|
||||
remove: '0',
|
||||
autoupdate: '0',
|
||||
start: '0',
|
||||
workdir: null,
|
||||
hostname: null,
|
||||
user: null,
|
||||
groups: null,
|
||||
expose: null,
|
||||
labels: null,
|
||||
cpus: null,
|
||||
memory: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async createForm() {
|
||||
const [images, networks, pods] = await Promise.all([
|
||||
podmanRPC.images.list(),
|
||||
podmanRPC.networks.list(),
|
||||
podmanRPC.pods.list()
|
||||
]);
|
||||
|
||||
let field;
|
||||
field = this.section.option(form.Value, 'name', _('Container Name'));
|
||||
field.placeholder = 'my-container';
|
||||
field.optional = true;
|
||||
field.datatype = 'maxlength(253)';
|
||||
field.description = _('Leave empty to auto-generate');
|
||||
|
||||
field = this.section.option(form.ListValue, 'image', _('Image'));
|
||||
field.value('', _('-- Select %s --').format(_('Image')));
|
||||
images.forEach((img) => {
|
||||
img.getRepoTags().forEach((tag) => {
|
||||
if (tag !== '<none>:<none>') {
|
||||
field.value(tag, tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
field.rmempty = false;
|
||||
field.optional = false;
|
||||
field.validate = (_section_id, value) => {
|
||||
if (!value) {
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
field.description = _('Container image to use');
|
||||
|
||||
field = this.section.option(form.ListValue, 'pod', _('Pod'));
|
||||
field.value('', _('(none)'));
|
||||
pods.forEach((pod) => {
|
||||
const name = pod.getName();
|
||||
if (name && name !== _('Unknown')) {
|
||||
field.value(name, name);
|
||||
}
|
||||
});
|
||||
field.description = _('Add the container to an existing pod. Network, hostname and port mappings come from the pod\'s infra container.');
|
||||
|
||||
field = this.section.option(form.Value, 'command', _('Command'));
|
||||
field.placeholder = '/bin/sh';
|
||||
field.optional = true;
|
||||
field.description = _('Command to run (space-separated)');
|
||||
|
||||
field = this.section.option(form.TextValue, 'ports', _('Port Mappings'));
|
||||
field.depends('pod', '');
|
||||
field.placeholder = '8080:80\n8443:443';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('One per line, format: host:container[/protocol]');
|
||||
|
||||
field = this.section.option(form.Value, 'expose', _('Expose Ports'));
|
||||
field.depends('pod', '');
|
||||
field.placeholder = '6052, 8080/udp';
|
||||
field.optional = true;
|
||||
field.description = _('Comma-separated ports to expose (e.g., 6052, 8080/udp)');
|
||||
|
||||
field = this.section.option(form.TextValue, 'env', _('Environment Variables'));
|
||||
field.placeholder = 'VAR1=value1\nVAR2=value2';
|
||||
field.rows = 4;
|
||||
field.optional = true;
|
||||
field.description = _('One per line, format: key=value');
|
||||
|
||||
field = this.section.option(form.TextValue, 'volumes', _('Volumes'));
|
||||
field.placeholder = '/host/path:/container/path:ro\nvolume-name:/data';
|
||||
field.rows = 4;
|
||||
field.optional = true;
|
||||
field.description = _('One per line. Format: source:destination[:options]. Options: ro, rw, Z, z');
|
||||
|
||||
field = this.section.option(form.ListValue, 'network', _('Network'));
|
||||
field.depends('pod', '');
|
||||
field.value('bridge', 'bridge (default)');
|
||||
field.value('host', 'host');
|
||||
field.value('none', 'none');
|
||||
networks.forEach((net) => {
|
||||
const name = net.getName();
|
||||
if (name && name !== 'bridge' && name !== 'host' && name !== 'none') {
|
||||
field.value(name, name);
|
||||
}
|
||||
});
|
||||
field.rmempty = false;
|
||||
field.description = _(
|
||||
'Select network for the new container. User-created networks provide better isolation and DNS resolution between containers.'
|
||||
);
|
||||
|
||||
field = this.section.option(form.ListValue, 'restart', _('Restart Policy'));
|
||||
field.value('no', _('No'));
|
||||
field.value('always', _('Always'));
|
||||
field.value('on-failure', _('On Failure'));
|
||||
field.value('unless-stopped', _('Unless Stopped'));
|
||||
|
||||
field = this.section.option(form.Flag, 'privileged', _('Privileged Mode'));
|
||||
|
||||
field = this.section.option(form.Flag, 'tty', _('Allocate TTY (-t)'));
|
||||
|
||||
field = this.section.option(form.Flag, 'remove', _('Auto Remove (--rm)'));
|
||||
|
||||
field = this.section.option(form.Flag, 'autoupdate', _('Auto-Update'));
|
||||
field.description = _(
|
||||
'Automatically update container when newer image is available. Adds label: io.containers.autoupdate=registry'
|
||||
);
|
||||
|
||||
field = this.section.option(form.Flag, 'start', _('Start after creation'));
|
||||
field.description = _('Automatically start the container after it is created');
|
||||
|
||||
field = this.section.option(form.Value, 'workdir', _('Working Directory'));
|
||||
field.placeholder = '/app';
|
||||
field.optional = true;
|
||||
|
||||
field = this.section.option(form.Value, 'hostname', _('Hostname'));
|
||||
field.depends('pod', '');
|
||||
field.placeholder = 'container-host';
|
||||
field.optional = true;
|
||||
field.datatype = 'hostname';
|
||||
|
||||
field = this.section.option(form.Value, 'user', _('User'));
|
||||
field.placeholder = '1000:1000';
|
||||
field.optional = true;
|
||||
field.description = _('User and group to run as (UID:GID)');
|
||||
|
||||
field = this.section.option(form.Value, 'groups', _('Supplementary Groups'));
|
||||
field.placeholder = '500,1000';
|
||||
field.optional = true;
|
||||
field.description = _('Comma-separated list of supplementary group IDs');
|
||||
|
||||
field = this.section.option(form.TextValue, 'labels', _('Labels'));
|
||||
field.placeholder = 'key1=value1\nkey2=value2';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('One per line, format: key=value');
|
||||
|
||||
field = this.section.option(form.Value, 'cpus', _('CPU Limit'));
|
||||
field.placeholder = '1.0';
|
||||
field.optional = true;
|
||||
field.datatype = 'ufloat';
|
||||
field.description = _('Number of CPUs (e.g., 0.5, 1.0, 2.0)');
|
||||
|
||||
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
|
||||
field.optional = true;
|
||||
field.description = _('Memory limit (e.g., 512m, 1g)');
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const data = this.getFieldValues();
|
||||
const spec = {
|
||||
image: data.image,
|
||||
privileged: Boolean(parseInt(data.privileged)),
|
||||
terminal: Boolean(parseInt(data.tty)),
|
||||
remove: Boolean(parseInt(data.remove)),
|
||||
};
|
||||
|
||||
if (data.name) spec.name = data.name;
|
||||
if (data.pod) spec.pod = data.pod;
|
||||
if (data.command) spec.command = data.command.split(/\s+/).filter((c) => c.length > 0);
|
||||
|
||||
if (!data.pod && data.ports) {
|
||||
spec.portmappings = [];
|
||||
data.ports.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
const parts = trimmed.split(':');
|
||||
if (parts.length === 2) {
|
||||
const hostPort = parseInt(parts[0], 10);
|
||||
const [containerPortStr, proto] = parts[1].split('/');
|
||||
const containerPort = parseInt(containerPortStr, 10);
|
||||
if (!isNaN(hostPort) && !isNaN(containerPort)) {
|
||||
spec.portmappings.push({
|
||||
host_port: hostPort,
|
||||
container_port: containerPort,
|
||||
protocol: (proto || 'tcp').toLowerCase()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const parseKeyValue = (text, target) => {
|
||||
text.split('\n').forEach((line) => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0].trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key) target[key] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (data.env) {
|
||||
spec.env = {};
|
||||
parseKeyValue(data.env, spec.env);
|
||||
}
|
||||
if (data.volumes) {
|
||||
const mounts = [];
|
||||
const volumes = [];
|
||||
data.volumes.split('\n').forEach((line) => {
|
||||
const parts = line.trim().split(':');
|
||||
if (parts.length < 2) return;
|
||||
|
||||
const opts = parts.length > 2 ? parts[2].split(',') : [];
|
||||
if (parts[0].includes('/')) {
|
||||
const mount = { Source: parts[0], Destination: parts[1] };
|
||||
if (opts.includes('ro')) mount.ReadOnly = true;
|
||||
const selinux = opts.filter((o) => o === 'Z' || o === 'z');
|
||||
if (selinux.length > 0) mount.options = selinux;
|
||||
mounts.push(mount);
|
||||
return;
|
||||
}
|
||||
|
||||
const vol = { Name: parts[0], Dest: parts[1] };
|
||||
if (opts.length > 0) vol.Options = opts;
|
||||
volumes.push(vol);
|
||||
});
|
||||
if (mounts.length > 0) spec.mounts = mounts;
|
||||
if (volumes.length > 0) spec.volumes = volumes;
|
||||
}
|
||||
if (!data.pod) {
|
||||
if (data.network === 'host') {
|
||||
spec.netns = { nsmode: 'host' };
|
||||
} else if (data.network === 'none') {
|
||||
spec.netns = { nsmode: 'none' };
|
||||
} else if (data.network && data.network !== 'bridge') {
|
||||
spec.networks = { [data.network]: {} };
|
||||
}
|
||||
}
|
||||
if (data.restart !== 'no') spec.restart_policy = data.restart;
|
||||
if (data.workdir) spec.work_dir = data.workdir;
|
||||
if (!data.pod && data.hostname) spec.hostname = data.hostname;
|
||||
if (data.user) spec.user = data.user;
|
||||
if (data.groups) {
|
||||
spec.groups = data.groups.split(',').map((g) => g.trim()).filter((g) => g);
|
||||
}
|
||||
if (!data.pod && data.expose) {
|
||||
spec.expose = {};
|
||||
data.expose.split(',').forEach((p) => {
|
||||
const trimmed = p.trim();
|
||||
if (!trimmed) return;
|
||||
const parts = trimmed.split('/');
|
||||
const port = parseInt(parts[0], 10);
|
||||
if (port >= 1 && port <= 65535)
|
||||
spec.expose[String(port)] = (parts[1] || 'tcp').toLowerCase();
|
||||
});
|
||||
}
|
||||
if (data.labels || data.autoupdate === '1') {
|
||||
spec.labels = {};
|
||||
if (data.labels) parseKeyValue(data.labels, spec.labels);
|
||||
if (data.autoupdate === '1') spec.labels['io.containers.autoupdate'] = 'registry';
|
||||
}
|
||||
if (data.cpus || data.memory) {
|
||||
spec.resource_limits = {};
|
||||
if (data.cpus) {
|
||||
spec.resource_limits.cpu = { quota: parseFloat(data.cpus) * 100000 };
|
||||
}
|
||||
if (data.memory) {
|
||||
const memBytes = podmanUtil.format.parseMemory(data.memory);
|
||||
if (memBytes > 0) spec.resource_limits.memory = { limit: memBytes };
|
||||
}
|
||||
}
|
||||
|
||||
const hasRestartPolicy = data.restart && data.restart !== 'no';
|
||||
const shouldStart = Boolean(parseInt(data.start));
|
||||
|
||||
const createFn = async () => {
|
||||
const raw = await podmanRPC.containers.create(spec);
|
||||
const inspected = await Container.getSingleton(raw).inspect();
|
||||
const container = Container.getSingleton(inspected);
|
||||
|
||||
if (hasRestartPolicy) {
|
||||
await container.generateInitScript();
|
||||
await container.enableInitScript();
|
||||
}
|
||||
|
||||
if (shouldStart) await container.start();
|
||||
};
|
||||
|
||||
return this.super('handleCreate', [ createFn, _('Container') ]);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormContainer
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require ui';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.model.Image as Image';
|
||||
|
||||
/**
|
||||
* Pull podman image
|
||||
*/
|
||||
const PodmanFormImage = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Image',
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
image: {
|
||||
registry: '',
|
||||
image: '',
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
createForm() {
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.ListValue, 'registry', _('Registry'));
|
||||
field.value('', 'docker.io');
|
||||
field.value('quay.io/', 'quay.io');
|
||||
field.value('ghcr.io/', 'ghcr.io');
|
||||
field.value('gcr.io/', 'gcr.io');
|
||||
|
||||
field = this.section.option(form.Value, 'image', _('Image'));
|
||||
field.placeholder = 'nginx:latest';
|
||||
field.rmempty = false;
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const registry = this.getFieldValue('registry');
|
||||
const image = this.getFieldValue('image');
|
||||
|
||||
const imageName = this.buildImageName(registry, image);
|
||||
const imageModel = Image.getSingleton({ RepoTags: [imageName] });
|
||||
|
||||
const progressEl = E('pre', { class: 'terminal-area' });
|
||||
const modal = new podmanUI.Modal(_('Pulling %s').format(imageName), [ progressEl ]);
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const closeButton = modal.getCloseButton();
|
||||
closeButton.classList.add('d-none');
|
||||
|
||||
modal.getButtons = () => [ closeButton ];
|
||||
modal.render();
|
||||
|
||||
const append = (text) => {
|
||||
progressEl.appendChild(document.createTextNode(text));
|
||||
progressEl.scrollTop = progressEl.scrollHeight;
|
||||
};
|
||||
|
||||
try {
|
||||
await imageModel.streamPull(append);
|
||||
closeButton.classList.remove('d-none');
|
||||
podmanUI.alert(_('Image pulled successfully'), 'success', true);
|
||||
} catch (error) {
|
||||
this.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
buildImageName(registry, image) {
|
||||
const reg = (registry || '').trim();
|
||||
const img = (image || '').trim();
|
||||
|
||||
if (!reg) {
|
||||
return img.includes('/') ? 'docker.io/' + img : 'docker.io/library/' + img;
|
||||
}
|
||||
|
||||
return reg.replace(/\/+$/, '') + '/' + img.replace(/^\/+/, '');
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormImage
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require network';
|
||||
'require firewall';
|
||||
|
||||
'require podman.utils as utils';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.model.Network as Network';
|
||||
|
||||
/**
|
||||
* Create podman network
|
||||
*/
|
||||
const PodmanFormNetwork = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Network',
|
||||
|
||||
existingZones: null,
|
||||
parentDevices: null,
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
network: {
|
||||
name: null,
|
||||
driver: 'bridge',
|
||||
subnet: null,
|
||||
gateway: null,
|
||||
ip_range: null,
|
||||
ipv6: '0',
|
||||
internal: '0',
|
||||
labels: null,
|
||||
setup_openwrt: '1',
|
||||
firewall_zone: '_create_new_',
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async render() {
|
||||
const [zones, parentDevices] = await Promise.all([
|
||||
firewall.getZones().then((zones) =>
|
||||
zones
|
||||
.filter(zone => zone.data.name?.startsWith('podman'))
|
||||
.map(zone => zone.data.name)
|
||||
),
|
||||
network.getDevices().then((devices) =>
|
||||
devices.filter(this.isValidParentDevice)
|
||||
),
|
||||
]);
|
||||
|
||||
this.existingZones = zones;
|
||||
this.parentDevices = parentDevices;
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
createForm: function () {
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.Value, 'name', _('Network Name'));
|
||||
field.placeholder = 'my-network';
|
||||
field.datatype = 'and(uciname,maxlength(15))';
|
||||
field.description = _('Name for the network');
|
||||
field.rmempty = false;
|
||||
|
||||
field = this.section.option(form.ListValue, 'driver', _('Driver'));
|
||||
field.value('bridge', 'bridge');
|
||||
field.value('macvlan', 'macvlan');
|
||||
field.value('ipvlan', 'ipvlan');
|
||||
field.description = _('Network driver');
|
||||
|
||||
field = this.section.option(form.ListValue, 'parent', _('Parent Interface'));
|
||||
field.depends('driver', 'macvlan');
|
||||
field.depends('driver', 'ipvlan');
|
||||
field.description = _(
|
||||
'Existing physical interface to use as parent. Required for macvlan/ipvlan networks.'
|
||||
);
|
||||
if (this.parentDevices.length > 0) {
|
||||
this.parentDevices.forEach((device) => {
|
||||
const name = device.getName();
|
||||
field.value(name, name + ' (' + device.getType() + ')');
|
||||
});
|
||||
} else {
|
||||
field.value('', _('No suitable interfaces found'));
|
||||
}
|
||||
|
||||
field = this.section.option(form.Value, 'subnet', _('IPv4 Subnet (CIDR)'));
|
||||
field.placeholder = '10.89.0.0/24';
|
||||
field.datatype = 'cidr4';
|
||||
field.description = _('IPv4 subnet in CIDR notation');
|
||||
// field.rmempty = false;
|
||||
|
||||
field = this.section.option(form.Value, 'gateway', _('IPv4 Gateway'));
|
||||
field.placeholder = '10.89.0.1';
|
||||
field.optional = true;
|
||||
field.datatype = 'ip4addr';
|
||||
field.description = _('IPv4 gateway address');
|
||||
|
||||
field = this.section.option(form.Value, 'ip_range', _('IP Range (CIDR)'));
|
||||
field.placeholder = '10.89.0.0/28';
|
||||
field.optional = true;
|
||||
field.datatype = 'cidr4';
|
||||
field.description = _('Allocate container IP from this range');
|
||||
|
||||
field = this.section.option(form.Flag, 'ipv6', _('Enable IPv6'));
|
||||
field.description = _('Enable IPv6 networking');
|
||||
|
||||
field = this.section.option(form.Flag, 'internal', _('Internal Network'));
|
||||
field.description = _('Restrict external access to the network');
|
||||
|
||||
field = this.section.option(form.Flag, 'setup_openwrt', _('Setup OpenWrt Integration'));
|
||||
field.description = _(
|
||||
'Automatically configure OpenWrt network interface, bridge, and firewall zone. <strong>Highly recommended</strong> for proper container networking on OpenWrt.'
|
||||
);
|
||||
|
||||
field = this.section.option(form.Value, 'bridge_name', _('Bridge Interface Name'));
|
||||
field.placeholder = _('Leave empty to auto-generate');
|
||||
field.optional = true;
|
||||
field.datatype = 'netdevname';
|
||||
field.depends({
|
||||
'setup_openwrt': '1',
|
||||
'driver': 'bridge'
|
||||
});
|
||||
field.description = _(
|
||||
'Name of the bridge interface (e.g., podman0, mynet0). Leave empty to use: <network-name>0. Note: If the generated name conflicts with an existing interface, OpenWrt will auto-increment it.'
|
||||
);
|
||||
|
||||
field = this.section.option(form.ListValue, 'firewall_zone', _('Firewall Zone'));
|
||||
field.value('_create_new_', _('Create new zone (will be named: podman_<network-name>)'));
|
||||
if (this.existingZones.length > 0) {
|
||||
this.existingZones.forEach((zoneName) => {
|
||||
field.value(zoneName, zoneName);
|
||||
});
|
||||
}
|
||||
field.depends('setup_openwrt', '1');
|
||||
field.description = _(
|
||||
'Choose firewall zone for this network.'
|
||||
);
|
||||
|
||||
field = this.section.option(form.TextValue, 'labels', _('Labels'));
|
||||
field.placeholder = 'key1=value1\nkey2=value2';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('Labels in key=value format, one per line');
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
await uci.load(['network', 'firewall', 'dhcp']);
|
||||
const ulaPrefix = uci.get('network', 'globals', 'ula_prefix');
|
||||
|
||||
const podnetwork = this.getFieldValues();
|
||||
|
||||
const setupOpenwrt = podnetwork.setup_openwrt === '1';
|
||||
const driver = podnetwork.driver || 'bridge';
|
||||
const requestedZone = podnetwork.firewall_zone || '_create_new_';
|
||||
const zoneName = requestedZone === '_create_new_' ? 'podman_' + podnetwork.name :
|
||||
requestedZone;
|
||||
const baseBridgeName = podnetwork.bridge_name || podnetwork.name;
|
||||
|
||||
let bridgeName = baseBridgeName;
|
||||
let i = 1;
|
||||
while (await network.getDevice(bridgeName)) {
|
||||
bridgeName = `${baseBridgeName}${i}`;
|
||||
i++;
|
||||
}
|
||||
|
||||
if ((driver === 'macvlan' || driver === 'ipvlan') && !podnetwork.parent) {
|
||||
this.error(_('Parent interface is required for macvlan/ipvlan networks'));
|
||||
return;
|
||||
}
|
||||
|
||||
// if (setupOpenwrt && !podnetwork.subnet) {
|
||||
// this.error(_('OpenWrt integration requires subnet to be specified'));
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!podnetwork.gateway && podnetwork.subnet) {
|
||||
podnetwork.gateway = utils.ipv4.firstHost(podnetwork.subnet);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: podnetwork.name,
|
||||
driver: driver
|
||||
};
|
||||
|
||||
if (driver === 'bridge') {
|
||||
payload.network_interface = bridgeName;
|
||||
} else if (driver === 'macvlan' || driver === 'ipvlan') {
|
||||
payload.network_interface = podnetwork.parent;
|
||||
}
|
||||
|
||||
if (podnetwork.subnet) {
|
||||
payload.subnets = [{
|
||||
subnet: podnetwork.subnet
|
||||
}];
|
||||
if (podnetwork.gateway) payload.subnets[0].gateway = podnetwork.gateway;
|
||||
|
||||
if (podnetwork.ip_range) {
|
||||
const range = utils.ipv4.cidrToRange(podnetwork.ip_range);
|
||||
if (range) payload.subnets[0].lease_range = range;
|
||||
}
|
||||
}
|
||||
|
||||
payload.ipv6_enabled = false;
|
||||
if (podnetwork.ipv6 === '1') {
|
||||
const ipv6obj = utils.ipv6.deriveUlaFromIpv4(podnetwork.subnet, ulaPrefix);
|
||||
payload.ipv6_enabled = true;
|
||||
|
||||
if (podnetwork.subnet) {
|
||||
payload.subnets.push({
|
||||
subnet: ipv6obj.ipv6subnet
|
||||
});
|
||||
|
||||
if (podnetwork.gateway)
|
||||
payload.subnets[1].gateway = ipv6obj.ipv6gateway;
|
||||
}
|
||||
}
|
||||
|
||||
payload.internal = podnetwork.internal === '1';
|
||||
if (podnetwork.labels) {
|
||||
payload.labels = {};
|
||||
podnetwork.labels.split('\n').forEach((line) => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0].trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key) payload.labels[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createFn = async () => {
|
||||
const newNetwork = await podmanRPC.networks.create(payload);
|
||||
|
||||
if (!setupOpenwrt) return;
|
||||
|
||||
const inspectData = await Network.getSingleton({
|
||||
name: podnetwork.name
|
||||
}).inspect();
|
||||
const NetworkModel = Network.getSingleton(inspectData);
|
||||
const deviceName = await NetworkModel.integrationCreateDevice();
|
||||
await NetworkModel.integrationCreateNetwork(deviceName);
|
||||
|
||||
if (await NetworkModel.hasDnsmasq()) {
|
||||
await NetworkModel.integrationAddDnsmasqExclusion();
|
||||
}
|
||||
|
||||
if (await firewall.getZone(zoneName)) {
|
||||
const currentNetworks = uci.get('firewall', zoneName, 'network');
|
||||
const networkList = Array.isArray(currentNetworks) ? currentNetworks :
|
||||
currentNetworks ? [currentNetworks] : [];
|
||||
if (!networkList.includes(podnetwork.name)) {
|
||||
networkList.push(podnetwork.name);
|
||||
uci.set('firewall', zoneName, 'network', networkList);
|
||||
}
|
||||
} else {
|
||||
const zoneId = uci.add('firewall', 'zone');
|
||||
uci.set('firewall', zoneId, 'name', zoneName);
|
||||
uci.set('firewall', zoneId, 'input', 'DROP');
|
||||
uci.set('firewall', zoneId, 'output', 'ACCEPT');
|
||||
uci.set('firewall', zoneId, 'forward', 'REJECT');
|
||||
uci.set('firewall', zoneId, 'network', [podnetwork.name]);
|
||||
}
|
||||
|
||||
const dnsRuleName = 'Allow-' + zoneName + '-DNS';
|
||||
const existingDnsRule = uci.sections('firewall', 'rule').find((s) =>
|
||||
uci.get('firewall', s['.name'], 'name') === dnsRuleName
|
||||
);
|
||||
|
||||
if (!existingDnsRule) {
|
||||
const ruleId = uci.add('firewall', 'rule');
|
||||
uci.set('firewall', ruleId, 'name', dnsRuleName);
|
||||
uci.set('firewall', ruleId, 'src', zoneName);
|
||||
uci.set('firewall', ruleId, 'dest_port', '53');
|
||||
uci.set('firewall', ruleId, 'target', 'ACCEPT');
|
||||
}
|
||||
|
||||
await uci.save();
|
||||
ui.changes.apply();
|
||||
await network.flushCache();
|
||||
};
|
||||
|
||||
return this.super('handleCreate', [createFn, _('Network')]);
|
||||
},
|
||||
|
||||
isValidParentDevice(device) {
|
||||
const type = device.getType();
|
||||
const name = device.getName();
|
||||
return ['ethernet', 'bridge', 'vlan'].includes(type) &&
|
||||
name !== 'lo' &&
|
||||
!name.match(/^(podman|cni-|docker|veth)/);
|
||||
}
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormNetwork
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
const PodmanFormPod = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Pod',
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
pod: {
|
||||
name: '',
|
||||
hostname: null,
|
||||
share: ['cgroup', 'ipc', 'net', 'uts'],
|
||||
infra: '1',
|
||||
infra_image: null,
|
||||
network: 'bridge',
|
||||
dns_server: null,
|
||||
dns_search: null,
|
||||
cpus: null,
|
||||
cpuset_cpus: null,
|
||||
memory: null,
|
||||
labels: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async createForm() {
|
||||
const networks = await podmanRPC.networks.list();
|
||||
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.Value, 'name', _('Pod Name'));
|
||||
field.placeholder = 'my-pod';
|
||||
field.optional = true;
|
||||
field.datatype = 'and(uciname,maxlength(63))';
|
||||
field.description = _('Leave empty to auto-generate');
|
||||
|
||||
field = this.section.option(form.Value, 'hostname', _('Hostname'));
|
||||
field.placeholder = 'pod-host';
|
||||
field.optional = true;
|
||||
field.datatype = 'hostname';
|
||||
field.description = _('Hostname for the pod (defaults to pod name)');
|
||||
|
||||
field = this.section.option(form.MultiValue, 'share', _('Shared Namespaces'));
|
||||
field.value('cgroup', 'cgroup');
|
||||
field.value('ipc', 'ipc');
|
||||
field.value('net', 'net');
|
||||
field.value('pid', 'pid');
|
||||
field.value('uts', 'uts');
|
||||
field.description = _('Linux namespaces shared between containers in the pod. Default matches `podman pod create`.');
|
||||
|
||||
field = this.section.option(form.Flag, 'infra', _('Create Infra Container'));
|
||||
field.description = _('Required for shared network namespace. Disable only if you know you do not need it.');
|
||||
|
||||
field = this.section.option(form.Value, 'infra_image', _('Infra Image'));
|
||||
field.depends('infra', '1');
|
||||
field.placeholder = 'k8s.gcr.io/pause:3.5';
|
||||
field.optional = true;
|
||||
field.description = _('Override the image used for the infra container');
|
||||
|
||||
field = this.section.option(form.ListValue, 'network', _('Network'));
|
||||
field.depends('infra', '1');
|
||||
field.value('bridge', 'bridge (default)');
|
||||
field.value('host', 'host');
|
||||
field.value('none', 'none');
|
||||
networks.forEach((net) => {
|
||||
const name = net.getName();
|
||||
if (name && name !== 'bridge' && name !== 'host' && name !== 'none') {
|
||||
field.value(name, name);
|
||||
}
|
||||
});
|
||||
field.description = _('Network namespace for the pod. Containers in the pod share this network.');
|
||||
|
||||
field = this.section.option(form.DynamicList, 'dns_server', _('DNS Servers'));
|
||||
field.depends('infra', '1');
|
||||
field.datatype = 'ipaddr';
|
||||
field.optional = true;
|
||||
field.description = _('DNS servers for the infra container (shared with all pod containers)');
|
||||
|
||||
field = this.section.option(form.DynamicList, 'dns_search', _('DNS Search Domains'));
|
||||
field.depends('infra', '1');
|
||||
field.optional = true;
|
||||
field.description = _('DNS search domains for the infra container');
|
||||
|
||||
field = this.section.option(form.Value, 'cpus', _('CPU Limit'));
|
||||
field.placeholder = '1.0';
|
||||
field.optional = true;
|
||||
field.datatype = 'ufloat';
|
||||
field.description = _('Number of CPUs available to the pod (e.g., 0.5, 1.0, 2.0)');
|
||||
|
||||
field = this.section.option(form.Value, 'cpuset_cpus', _('CPU Set'));
|
||||
field.placeholder = '0-3';
|
||||
field.optional = true;
|
||||
field.description = _('CPUs the pod is allowed to run on (e.g., "0-3" or "0,2")');
|
||||
|
||||
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
|
||||
field.optional = true;
|
||||
field.description = _('Memory limit for the pod (e.g., 512m, 1g)');
|
||||
|
||||
field = this.section.option(form.TextValue, 'labels', _('Labels'));
|
||||
field.placeholder = 'key1=value1\nkey2=value2';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('One per line, format: key=value');
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const data = this.getFieldValues();
|
||||
const spec = {};
|
||||
|
||||
if (data.name) spec.name = data.name;
|
||||
if (data.hostname) spec.hostname = data.hostname;
|
||||
|
||||
const shareRaw = data.share;
|
||||
spec.share = Array.isArray(shareRaw)
|
||||
? shareRaw
|
||||
: (typeof shareRaw === 'string' && shareRaw.trim() ? shareRaw.trim().split(/\s+/) : []);
|
||||
|
||||
const hasInfra = data.infra === '1';
|
||||
spec.infra = hasInfra;
|
||||
if (hasInfra && data.infra_image) spec.infra_image = data.infra_image;
|
||||
|
||||
if (hasInfra) {
|
||||
if (data.network === 'host') {
|
||||
spec.netns = { nsmode: 'host' };
|
||||
} else if (data.network === 'none') {
|
||||
spec.netns = { nsmode: 'none' };
|
||||
} else if (data.network && data.network !== 'bridge') {
|
||||
spec.Networks = { [data.network]: {} };
|
||||
}
|
||||
|
||||
if (data.dns_server && data.dns_server.length) spec.dns_server = data.dns_server;
|
||||
if (data.dns_search && data.dns_search.length) spec.dns_search = data.dns_search;
|
||||
}
|
||||
|
||||
if (data.cpus) spec.cpus = parseFloat(data.cpus);
|
||||
if (data.cpuset_cpus) spec.cpuset_cpus = data.cpuset_cpus;
|
||||
|
||||
if (data.memory) {
|
||||
const memBytes = podmanUtil.format.parseMemory(data.memory);
|
||||
if (memBytes > 0) {
|
||||
spec.resource_limits = { memory: { limit: memBytes } };
|
||||
}
|
||||
}
|
||||
|
||||
if (data.labels) {
|
||||
spec.labels = {};
|
||||
data.labels.split('\n').forEach((line) => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0].trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key) spec.labels[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createFn = () => podmanRPC.pods.create(spec);
|
||||
|
||||
return this.super('handleCreate', [ createFn, _('Pod') ]);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormPod
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
/**
|
||||
* Update podman container resources
|
||||
*/
|
||||
const PodmanFormResource = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Resource',
|
||||
|
||||
container: null,
|
||||
|
||||
makeData() {
|
||||
if (!this.container) {
|
||||
return { resources: { cpuLimit: '', cpuShares: '', memory: '', memorySwap: '' } };
|
||||
}
|
||||
|
||||
const hostConfig = this.container.getHostConfig();
|
||||
return {
|
||||
resources: {
|
||||
cpuLimit: hostConfig.CpuQuota > 0 ? (hostConfig.CpuQuota / 100000).toFixed(2) : '',
|
||||
cpuShares: hostConfig.CpuShares || '',
|
||||
memory: hostConfig.Memory > 0 ? podmanUtil.format.bytes(hostConfig.Memory, 0) : '',
|
||||
memorySwap: hostConfig.MemorySwap > 0 ? podmanUtil.format.bytes(hostConfig.MemorySwap, 0) : '',
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async render(container) {
|
||||
this.container = container;
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
createForm() {
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.Value, 'cpuLimit', _('CPU Limit'));
|
||||
field.datatype = 'ufloat';
|
||||
field.placeholder = '0.5, 1.0, 2.0';
|
||||
field.description = _('Number of CPUs (e.g., 0.5, 1.0, 2.0). Leave empty for unlimited.');
|
||||
|
||||
field = this.section.option(form.Value, 'cpuShares', _('CPU Shares Weight'));
|
||||
field.datatype = 'uinteger';
|
||||
field.placeholder = '1024';
|
||||
field.validate = (_section_id, value) => {
|
||||
if (value) {
|
||||
const n = parseInt(value);
|
||||
if (n < 0 || n > 262144) return _('Must be between 0 and 262144');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
field.description = _('CPU shares (relative weight). 0 = use default.');
|
||||
|
||||
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
|
||||
field.description = _('Memory limit (e.g., 512m, 1g). -1 = unlimited, 0 = use default.');
|
||||
|
||||
field = this.section.option(podmanForm.field.MemoryValue, 'memorySwap', _('Memory + Swap Limit'));
|
||||
field.description = _('Total memory limit (memory + swap). -1 = unlimited, 0 = use default.');
|
||||
|
||||
field = this.section.option(form.Button, '_update', ' ');
|
||||
field.inputtitle = _('Update Resources');
|
||||
field.inputstyle = 'save';
|
||||
field.onclick = () => this.handleUpdate();
|
||||
},
|
||||
|
||||
async handleUpdate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const data = this.getFieldValues();
|
||||
const updateData = {
|
||||
cpu: {
|
||||
quota: 0,
|
||||
period: 0,
|
||||
shares: parseInt(data.cpuShares) || 0,
|
||||
},
|
||||
};
|
||||
|
||||
const parseMemory = (val) =>
|
||||
(!val || val === '-1' || val === '0') ? -1 : podmanUtil.format.parseMemory(val);
|
||||
|
||||
const memory = parseMemory(data.memory);
|
||||
const memorySwap = parseMemory(data.memorySwap);
|
||||
|
||||
if (data.cpuLimit) {
|
||||
const period = 100000;
|
||||
updateData.cpu.quota = Math.floor(parseFloat(data.cpuLimit) * period);
|
||||
updateData.cpu.period = period;
|
||||
}
|
||||
|
||||
updateData.memory = { limit: memory };
|
||||
if (memory === -1 || memorySwap === -1) {
|
||||
updateData.memory.swap = -1;
|
||||
} else if (memorySwap > 0) {
|
||||
updateData.memory.swap = memorySwap;
|
||||
} else {
|
||||
updateData.memory.swap = null;
|
||||
}
|
||||
|
||||
const createFn = () => this.container.update(updateData);
|
||||
|
||||
return this.super('handleCreate', [ createFn, _('Saving resource'), _('Updating container'), _('Container updated successfully') ]);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormResource
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
/**
|
||||
* Create podman secret
|
||||
*/
|
||||
const PodmanFormSecret = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Secret',
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
secret: {
|
||||
name: null,
|
||||
data: null,
|
||||
labels: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
createForm() {
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.Value, 'name', _('Secret Name'));
|
||||
field.placeholder = 'my-secret';
|
||||
field.rmempty = false;
|
||||
field.datatype = 'maxlength(253)';
|
||||
field.validate = (_section_id, value) => {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return _(
|
||||
'Secret name can only contain letters, numbers, underscores, and hyphens'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
field.description = _(
|
||||
'1-253 characters: letters, numbers, underscore (_), hyphen (-) only');
|
||||
|
||||
field = this.section.option(form.TextValue, 'data', _('Secret Data'));
|
||||
field.placeholder = _('Enter secret data (password, token, key, etc.)');
|
||||
field.rmempty = false;
|
||||
field.rows = 6;
|
||||
field.description = _('The sensitive data to store securely');
|
||||
|
||||
field = this.section.option(form.TextValue, 'labels', _('Labels'));
|
||||
field.placeholder = 'key1=value1\nkey2=value2';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('Labels in key=value format, one per line');
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const data = this.getFieldValues();
|
||||
|
||||
if (!data.name || !data.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = {};
|
||||
if (data.labels) {
|
||||
data.labels.split('\n').forEach((line) => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0].trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key) labels[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createFn = () => podmanRPC.secrets.create(data.name, data.data, labels);
|
||||
|
||||
return this.super('handleCreate', [ createFn, _('Secret') ]);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormSecret
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
/**
|
||||
* Create podman volume
|
||||
*/
|
||||
const PodmanFormVolume = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.Volume',
|
||||
|
||||
sectionName: 'volume',
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
volume: {
|
||||
name: null,
|
||||
driver: 'local',
|
||||
options: null,
|
||||
labels: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
createForm() {
|
||||
let field;
|
||||
|
||||
field = this.section.option(form.Value, 'name', _('Volume Name'));
|
||||
field.placeholder = 'my-volume';
|
||||
field.optional = true;
|
||||
field.datatype = 'maxlength(253)';
|
||||
field.description = _('Volume name. Leave empty to auto-generate.');
|
||||
|
||||
field = this.section.option(form.ListValue, 'driver', _('Driver'));
|
||||
field.value('local', 'local');
|
||||
field.value('image', 'image');
|
||||
field.description = _('Volume driver to use');
|
||||
|
||||
field = this.section.option(form.Value, 'options', _('Mount Options'));
|
||||
field.placeholder = 'type=tmpfs,device=tmpfs,o=size=100m';
|
||||
field.optional = true;
|
||||
field.description = _('Driver-specific options (comma-separated, e.g., type=tmpfs,o=size=100m)');
|
||||
|
||||
field = this.section.option(form.TextValue, 'labels', _('Labels'));
|
||||
field.placeholder = 'key1=value1\nkey2=value2';
|
||||
field.rows = 3;
|
||||
field.optional = true;
|
||||
field.description = _('Labels in key=value format, one per line');
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
if (!this.isValid()) {
|
||||
return this.scrollToInvalid();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
const data = this.getFieldValues();
|
||||
const payload = {
|
||||
Name: data.name,
|
||||
Driver: data.driver,
|
||||
};
|
||||
|
||||
if (data.options) {
|
||||
payload.Options = {};
|
||||
data.options.split(',').forEach((opt) => {
|
||||
const parts = opt.split('=');
|
||||
if (parts.length >= 2) {
|
||||
payload.Options[parts[0].trim()] = parts.slice(1).join('=').trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.labels) {
|
||||
payload.Labels = {};
|
||||
data.labels.split('\n').forEach((line) => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0].trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key) payload.Labels[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createFn = () => podmanRPC.volumes.create(payload);
|
||||
|
||||
return this.super('handleCreate', [ createFn, _('Volume') ]);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormVolume,
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require request';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
/**
|
||||
* Import podman volume
|
||||
*/
|
||||
const PodmanFormVolumeImport = podmanView.form.extend({
|
||||
__name__: 'Podman.Form.VolumeImport',
|
||||
|
||||
sectionName: 'volume',
|
||||
|
||||
isCompressed: false,
|
||||
file: null,
|
||||
|
||||
makeData() {
|
||||
return {
|
||||
volume: {
|
||||
name: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async render(file) {
|
||||
this.file = file;
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
createForm() {
|
||||
const isCompressed = this.file.name.endsWith('.tar.gz') || this.file.name.endsWith('.tgz');
|
||||
const volumeName = this.file.name.replace(/\.(tar\.gz|tgz|tar)$/, '');
|
||||
|
||||
this.isCompressed = isCompressed;
|
||||
|
||||
const field = this.section.option(form.Value, 'name', _('Volume Name'));
|
||||
field.placeholder = 'my-volume';
|
||||
field.rmempty = false;
|
||||
field.datatype = 'rangelength(1,253)';
|
||||
field.default = volumeName;
|
||||
},
|
||||
|
||||
async handleCreate() {
|
||||
await this.save();
|
||||
|
||||
const name = this.getFieldValue('name');
|
||||
|
||||
this.loading(_('Uploading file...'));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('sessionid', L.env.sessionid);
|
||||
formData.append('filename', '/tmp/podman-import');
|
||||
formData.append('filedata', this.file);
|
||||
|
||||
const response = await request.post(L.env.cgi_base + '/cgi-upload', formData);
|
||||
if (!response.ok) {
|
||||
this.error(_('Upload failed: %s').format(response.statusText));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Importing volume...'));
|
||||
|
||||
await podmanRPC.volumes.import(name, this.isCompressed);
|
||||
this.success(_('Volume imported successfully'));
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
init: PodmanFormVolumeImport,
|
||||
});
|
||||
@@ -0,0 +1,785 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
'require podman.model.Network as Network';
|
||||
'require podman.model.Image as Image';
|
||||
'require podman.constants as constants';
|
||||
'require podman.utils as podmanUtil';
|
||||
|
||||
|
||||
const InitScript = {
|
||||
generate: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'init_script_generate',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
show: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'init_script_show',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
status: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'init_script_status',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
setEnabled: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'init_script_set_enabled',
|
||||
params: ['name', 'enabled']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'init_script_remove',
|
||||
params: ['name']
|
||||
})
|
||||
};
|
||||
|
||||
const ContainerRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_inspect',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
update: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_update',
|
||||
params: ['id', 'data']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_remove',
|
||||
params: ['id', 'force', 'depend']
|
||||
}),
|
||||
|
||||
start: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_start',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
stop: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_stop',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
restart: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_restart',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
pause: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_pause',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
unpause: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_unpause',
|
||||
params: ['id']
|
||||
}),
|
||||
|
||||
rename: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_rename',
|
||||
params: ['id', 'name']
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_create',
|
||||
params: ['data']
|
||||
}),
|
||||
};
|
||||
|
||||
const Container = Model.base.extend({
|
||||
__name__: 'Podman.Model.Container',
|
||||
|
||||
getID() {
|
||||
return this.Id || '';
|
||||
},
|
||||
|
||||
getName() {
|
||||
if (this.Names && Array.isArray(this.Names) && this.Names.length > 0) {
|
||||
return this.Names[0];
|
||||
}
|
||||
|
||||
if (this.Names && !Array.isArray(this.Names) && this.Names !== '') {
|
||||
return this.Names;
|
||||
}
|
||||
|
||||
if (this.Name) {
|
||||
return this.Name;
|
||||
}
|
||||
|
||||
return _('Unknown');
|
||||
},
|
||||
|
||||
getState() {
|
||||
if (typeof this.State === 'string') {
|
||||
return this.State;
|
||||
}
|
||||
|
||||
return this.State?.Status || this.Status || '';
|
||||
},
|
||||
|
||||
getStateBadge() {
|
||||
return E('div', { class: `badge ${this.getState()}` }, [ this.getState() ]);
|
||||
},
|
||||
|
||||
getAutoUpdateLabel() {
|
||||
const labels = this.Config?.Labels || this.Labels;
|
||||
if (!labels) {
|
||||
return false;
|
||||
}
|
||||
return labels['io.containers.autoupdate'] || false;
|
||||
},
|
||||
|
||||
getCmdString() {
|
||||
return this.Config?.Cmd ? this.Config.Cmd.join(' ') : '';
|
||||
},
|
||||
|
||||
getEntrypointString() {
|
||||
return this.Config?.Entrypoint ? this.Config.Entrypoint.join(' ') : '';
|
||||
},
|
||||
|
||||
getCreateCommandString() {
|
||||
return this.Config?.CreateCommand ? this.Config?.CreateCommand.join(' ') : '';
|
||||
},
|
||||
|
||||
inspectToSpec(imageRef) {
|
||||
const config = this.Config || {};
|
||||
const hostConfig = this.HostConfig || {};
|
||||
const networkSettings = this.NetworkSettings || {};
|
||||
const mounts = this.Mounts || [];
|
||||
|
||||
const spec = { image: imageRef };
|
||||
|
||||
// Name (strip leading slash added by Podman inspect)
|
||||
if (this.Name) spec.name = this.Name.replace(/^\//, '');
|
||||
|
||||
// Process
|
||||
if (config.Cmd && config.Cmd.length) spec.command = config.Cmd;
|
||||
if (config.Entrypoint && config.Entrypoint.length) spec.entrypoint = config.Entrypoint;
|
||||
if (config.Tty) spec.terminal = true;
|
||||
if (config.OpenStdin) spec.stdin = true;
|
||||
if (config.WorkingDir) spec.work_dir = config.WorkingDir;
|
||||
if (config.User) spec.user = config.User;
|
||||
if (config.Hostname) spec.hostname = config.Hostname;
|
||||
if (config.StopSignal) {
|
||||
const sigMap = {
|
||||
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5,
|
||||
SIGABRT: 6, SIGBUS: 7, SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10,
|
||||
SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15,
|
||||
SIGCHLD: 17, SIGCONT: 18, SIGSTOP: 19, SIGTSTP: 20, SIGTTIN: 21,
|
||||
SIGTTOU: 22, SIGURG: 23, SIGXCPU: 24, SIGXFSZ: 25, SIGVTALRM: 26,
|
||||
SIGPROF: 27, SIGWINCH: 28, SIGIO: 29, SIGPWR: 30, SIGSYS: 31,
|
||||
};
|
||||
const sigStr = String(config.StopSignal).toUpperCase().replace(/^SIG/, '');
|
||||
const sigNum = sigMap['SIG' + sigStr] ?? parseInt(config.StopSignal, 10);
|
||||
if (sigNum > 0) spec.stop_signal = sigNum;
|
||||
}
|
||||
if (config.StopTimeout) spec.stop_timeout = config.StopTimeout;
|
||||
if (config.Healthcheck) spec.healthconfig = config.Healthcheck;
|
||||
|
||||
// Environment: ["KEY=val", ...] → {KEY: "val", ...}
|
||||
if (config.Env && config.Env.length) {
|
||||
spec.env = {};
|
||||
for (const e of config.Env) {
|
||||
const idx = e.indexOf('=');
|
||||
if (idx > 0) spec.env[e.substring(0, idx)] = e.substring(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Labels
|
||||
if (config.Labels && Object.keys(config.Labels).length)
|
||||
spec.labels = config.Labels;
|
||||
|
||||
// HostConfig
|
||||
if (hostConfig.Privileged) spec.privileged = true;
|
||||
if (hostConfig.ReadonlyRootfs) spec.read_only_filesystem = true;
|
||||
if (hostConfig.AutoRemove) spec.remove = true;
|
||||
if (hostConfig.CapAdd && hostConfig.CapAdd.length) spec.cap_add = hostConfig.CapAdd;
|
||||
if (hostConfig.CapDrop && hostConfig.CapDrop.length) spec.cap_drop = hostConfig.CapDrop;
|
||||
if (hostConfig.Dns && hostConfig.Dns.length) spec.dns_server = hostConfig.Dns;
|
||||
if (hostConfig.DnsOptions && hostConfig.DnsOptions.length) spec.dns_option = hostConfig.DnsOptions;
|
||||
if (hostConfig.DnsSearch && hostConfig.DnsSearch.length) spec.dns_search = hostConfig.DnsSearch;
|
||||
if (hostConfig.ExtraHosts && hostConfig.ExtraHosts.length) spec.hostadd = hostConfig.ExtraHosts;
|
||||
if (hostConfig.GroupAdd && hostConfig.GroupAdd.length) spec.groups = hostConfig.GroupAdd;
|
||||
if (hostConfig.ShmSize) spec.shm_size = hostConfig.ShmSize;
|
||||
if (hostConfig.OomScoreAdj) spec.oom_score_adj = hostConfig.OomScoreAdj;
|
||||
|
||||
// Restart policy
|
||||
const rp = hostConfig.RestartPolicy;
|
||||
if (rp && rp.Name && rp.Name !== 'no') {
|
||||
spec.restart_policy = rp.Name;
|
||||
if (rp.MaximumRetryCount) spec.restart_tries = rp.MaximumRetryCount;
|
||||
}
|
||||
|
||||
// Exposed ports: {"8080/tcp": {}} → {8080: "tcp"}
|
||||
const exposedPorts = config.ExposedPorts || {};
|
||||
if (Object.keys(exposedPorts).length) {
|
||||
spec.expose = {};
|
||||
for (const portProto of Object.keys(exposedPorts)) {
|
||||
const [port, proto] = portProto.split('/');
|
||||
const portNum = parseInt(port, 10);
|
||||
if (portNum > 0 && portNum <= 65535)
|
||||
spec.expose[portNum] = proto || 'tcp';
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings: {"8080/tcp": [{HostIp, HostPort}]} → array
|
||||
const portBindings = hostConfig.PortBindings || {};
|
||||
if (Object.keys(portBindings).length) {
|
||||
spec.portmappings = [];
|
||||
for (const [portProto, bindings] of Object.entries(portBindings)) {
|
||||
if (!bindings || !bindings.length) continue;
|
||||
const [port, protocol] = portProto.split('/');
|
||||
const containerPort = parseInt(port, 10);
|
||||
for (const b of bindings) {
|
||||
const m = { container_port: containerPort, protocol: protocol || 'tcp' };
|
||||
if (b.HostPort) m.host_port = parseInt(b.HostPort, 10);
|
||||
if (b.HostIp && b.HostIp !== '0.0.0.0') m.host_ip = b.HostIp;
|
||||
spec.portmappings.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resource limits - use CFS quota/period directly (most precise)
|
||||
if (hostConfig.Memory > 0 || hostConfig.CpuQuota > 0) {
|
||||
spec.resource_limits = {};
|
||||
if (hostConfig.Memory > 0)
|
||||
spec.resource_limits.memory = { limit: hostConfig.Memory };
|
||||
if (hostConfig.CpuQuota > 0) {
|
||||
spec.resource_limits.cpu = { quota: hostConfig.CpuQuota };
|
||||
if (hostConfig.CpuPeriod > 0)
|
||||
spec.resource_limits.cpu.period = hostConfig.CpuPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
// Volumes and bind mounts
|
||||
const namedVols = mounts.filter(m => m.Type === 'volume');
|
||||
const bindMounts = mounts.filter(m => m.Type === 'bind');
|
||||
|
||||
if (namedVols.length) {
|
||||
spec.volumes = namedVols.map(m => ({
|
||||
Name: m.Name,
|
||||
Dest: m.Destination,
|
||||
Options: m.Options || []
|
||||
}));
|
||||
}
|
||||
|
||||
if (bindMounts.length) {
|
||||
spec.mounts = bindMounts.map(m => ({
|
||||
Source: m.Source,
|
||||
Destination: m.Destination,
|
||||
ReadOnly: !m.RW,
|
||||
options: m.Options || []
|
||||
}));
|
||||
}
|
||||
|
||||
// Network
|
||||
const nm = hostConfig.NetworkMode || '';
|
||||
if (nm === 'host') {
|
||||
spec.netns = { nsmode: 'host' };
|
||||
} else if (nm === 'none') {
|
||||
spec.netns = { nsmode: 'none' };
|
||||
} else {
|
||||
const nets = networkSettings.Networks || {};
|
||||
const nonDefault = Object.keys(nets).filter(n => n !== 'bridge' && n !== 'default');
|
||||
if (nonDefault.length) {
|
||||
spec.networks = {};
|
||||
for (const n of nonDefault) spec.networks[n] = {};
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
},
|
||||
|
||||
getWorkingDir() {
|
||||
return this.Config?.WorkingDir;
|
||||
},
|
||||
|
||||
getHostname() {
|
||||
return this.Config?.Hostname;
|
||||
},
|
||||
|
||||
getUser() {
|
||||
return this.Config?.User;
|
||||
},
|
||||
|
||||
getInteractive() {
|
||||
return this.Config?.OpenStdin;
|
||||
},
|
||||
|
||||
getPrivileged() {
|
||||
return this.HostConfig?.Privileged;
|
||||
},
|
||||
|
||||
getNetworkMode() {
|
||||
return this.HostConfig?.NetworkMode;
|
||||
},
|
||||
|
||||
getNetworkSettings() {
|
||||
return this.NetworkSettings || {};
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
return this.Config || {};
|
||||
},
|
||||
|
||||
getHostConfig() {
|
||||
return this.HostConfig || {};
|
||||
},
|
||||
|
||||
getTty() {
|
||||
return this.Config?.Tty;
|
||||
},
|
||||
|
||||
getMounts() {
|
||||
return this.Mounts || [];
|
||||
},
|
||||
|
||||
getEnvironmentVars() {
|
||||
return this.Config?.Env || [];
|
||||
},
|
||||
|
||||
getPorts() {
|
||||
const ports = [];
|
||||
const networkPorts = this.getNetworkSettings().Ports || {};
|
||||
|
||||
Object.keys(networkPorts).forEach((port) => {
|
||||
const [portOnly, protocol] = port.split('/');
|
||||
const portBindings = networkPorts[port] || [];
|
||||
|
||||
if (portBindings.length === 0) {
|
||||
ports.push({ string: port, port: portOnly, protocol: protocol || '', hostPort: null, hostIp: null });
|
||||
return;
|
||||
}
|
||||
|
||||
portBindings.forEach((portBinding) => {
|
||||
const bindingString = [port];
|
||||
if (portBinding.HostIp) bindingString.push(portBinding.HostIp);
|
||||
if (portBinding.HostPort) bindingString.push(portBinding.HostPort);
|
||||
ports.push({
|
||||
string: bindingString.join(':'),
|
||||
port: portOnly,
|
||||
protocol: protocol || '',
|
||||
hostIp: portBinding.HostIp,
|
||||
hostPort: portBinding.HostPort,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return ports;
|
||||
},
|
||||
|
||||
getImageName() {
|
||||
return (this.ImageName || this.Image).replace(/^[^\/]+\//, '').split(':')[0];
|
||||
},
|
||||
|
||||
_parseImageRef() {
|
||||
const cleanUrl = this.Image.split('@')[0];
|
||||
const lastColon = cleanUrl.lastIndexOf(':');
|
||||
const lastSlash = cleanUrl.lastIndexOf('/');
|
||||
return { cleanUrl, lastColon, lastSlash };
|
||||
},
|
||||
|
||||
getImageRepo() {
|
||||
const { cleanUrl, lastColon, lastSlash } = this._parseImageRef();
|
||||
return lastColon > lastSlash ? cleanUrl.substring(0, lastColon) : '';
|
||||
},
|
||||
|
||||
getImageTag() {
|
||||
const { cleanUrl, lastColon, lastSlash } = this._parseImageRef();
|
||||
return lastColon > lastSlash ? cleanUrl.substring(lastColon + 1) : 'latest';
|
||||
},
|
||||
|
||||
getCreated(dateFormat) {
|
||||
return dateFormat ? podmanUtil.format.date(this.Created) : this.Created;
|
||||
},
|
||||
|
||||
getStartedAt(dateFormat) {
|
||||
const date = this.StartedAt || this.State?.StartedAt;
|
||||
return dateFormat ? podmanUtil.format.date(date) : date;
|
||||
},
|
||||
|
||||
getRestartPolicyName() {
|
||||
if (this.HostConfig?.RestartPolicy?.Name) {
|
||||
return this.HostConfig.RestartPolicy.Name;
|
||||
}
|
||||
|
||||
return 'no';
|
||||
},
|
||||
|
||||
getDetailLink(text) {
|
||||
return E('a', {
|
||||
class: 'text-bold',
|
||||
href: this.getDetailUrl(),
|
||||
'data-container-id': this.getID()
|
||||
}, text || this.getID());
|
||||
},
|
||||
|
||||
getDetailUrl() {
|
||||
return L.url('admin/podman/container', this.getID());
|
||||
},
|
||||
|
||||
getAutoStartStatusIcon(status) {
|
||||
let text = '-';
|
||||
|
||||
if (status === 'enabled') {
|
||||
text = constants.ICON.INIT_ENABLED;
|
||||
} else if (status === 'missing') {
|
||||
text = constants.ICON.INIT_MISSING;
|
||||
} else if (status === 'disabled') {
|
||||
text = constants.ICON.INIT_DISABLED;
|
||||
}
|
||||
|
||||
return E('span', { class: `autostart-status autostart-${status || ''}` }, text);
|
||||
},
|
||||
|
||||
isRunning() {
|
||||
const state = this.getState();
|
||||
return this.State?.Running
|
||||
|| state === 'running'
|
||||
|| state === 'starting'
|
||||
|| state === 'unhealthy';
|
||||
},
|
||||
|
||||
isStopped() {
|
||||
const state = this.getState();
|
||||
return state === 'exited' || state === 'created';
|
||||
},
|
||||
|
||||
isPaused() {
|
||||
return !!this.State?.Paused || this.getState() === 'paused';
|
||||
},
|
||||
|
||||
async checkInitScript() {
|
||||
if (!this.HostConfig) {
|
||||
const inspectData = await this.inspect();
|
||||
if (!inspectData) return 'none';
|
||||
this.HostConfig = inspectData.HostConfig;
|
||||
}
|
||||
const hasRestartPolicy = this._hasRestartPolicy();
|
||||
|
||||
if (!hasRestartPolicy) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const initStatus = await InitScript.status(this.getName());
|
||||
if (!initStatus) return 'none';
|
||||
|
||||
if (initStatus.exists && initStatus.enabled) {
|
||||
return 'enabled';
|
||||
} else if (!initStatus.exists) {
|
||||
return 'missing';
|
||||
} else if (initStatus.exists && !initStatus.enabled) {
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
},
|
||||
|
||||
async generateInitScript() {
|
||||
return InitScript.generate(this.getName());
|
||||
},
|
||||
|
||||
async showInitScript() {
|
||||
return InitScript.show(this.getName());
|
||||
},
|
||||
|
||||
async removeInitScript() {
|
||||
return InitScript.remove(this.getName());
|
||||
},
|
||||
|
||||
async enableInitScript() {
|
||||
return InitScript.setEnabled(this.getName(), true);
|
||||
},
|
||||
|
||||
async disableInitScript() {
|
||||
return InitScript.setEnabled(this.getName(), false);
|
||||
},
|
||||
|
||||
_hasRestartPolicy() {
|
||||
return this.getRestartPolicyName() !== 'no';
|
||||
},
|
||||
|
||||
async inspect() {
|
||||
return ContainerRPC.inspect(this.getID());
|
||||
},
|
||||
|
||||
async update(data) {
|
||||
await ContainerRPC.update(this.getID(), data);
|
||||
|
||||
const initScriptStatus = await this.checkInitScript();
|
||||
const policy = data.RestartPolicy;
|
||||
const hasRestartPolicy = policy !== 'no';
|
||||
|
||||
if (hasRestartPolicy && (initScriptStatus === 'none' || initScriptStatus === 'missing')) {
|
||||
await this.generateInitScript();
|
||||
await this.enableInitScript();
|
||||
} else if (!hasRestartPolicy && (initScriptStatus === 'enabled' || initScriptStatus === 'disabled')) {
|
||||
await this.removeInitScript();
|
||||
}
|
||||
},
|
||||
|
||||
async remove(force, volumes) {
|
||||
const initScriptStatus = await this.checkInitScript();
|
||||
if (initScriptStatus === 'enabled' || initScriptStatus === 'disabled') {
|
||||
await this.removeInitScript();
|
||||
}
|
||||
return ContainerRPC.remove(this.getID(), force ?? false, volumes ?? false);
|
||||
},
|
||||
|
||||
async start() {
|
||||
return ContainerRPC.start(this.getID());
|
||||
},
|
||||
|
||||
async stop() {
|
||||
return ContainerRPC.stop(this.getID());
|
||||
},
|
||||
|
||||
async restart() {
|
||||
return ContainerRPC.restart(this.getID());
|
||||
},
|
||||
|
||||
async pause() {
|
||||
return ContainerRPC.pause(this.getID());
|
||||
},
|
||||
|
||||
async unpause() {
|
||||
return ContainerRPC.unpause(this.getID());
|
||||
},
|
||||
|
||||
async connect(networkName, payload) {
|
||||
return Network.getSingleton({ name: networkName }).connect(Object.assign({ container: this.getID() }, payload));
|
||||
},
|
||||
|
||||
async disconnect(networkName) {
|
||||
return Network.getSingleton({ name: networkName }).disconnect({ Container: this.getID() });
|
||||
},
|
||||
|
||||
streamTop(onChunk, delay, psargs) {
|
||||
const params = new URLSearchParams({ delay: String(delay || 2) });
|
||||
if (psargs) params.set('ps_args', psargs);
|
||||
const url = L.url('admin/podman/stream/top', this.getID()) + '?' + params;
|
||||
return this._stream(
|
||||
() => url,
|
||||
(data) => { if (!data.raw && !data.Error) onChunk(data); }
|
||||
);
|
||||
},
|
||||
|
||||
streamStats(onChunk, interval) {
|
||||
const params = new URLSearchParams({ interval: String(interval || 2) });
|
||||
const url = L.url('admin/podman/stream/stats', this.getID()) + '?' + params;
|
||||
return this._stream(
|
||||
() => url,
|
||||
(data) => {
|
||||
if (data.raw || data.Error || !data.Stats || data.Stats.length === 0) {
|
||||
return;
|
||||
}
|
||||
onChunk(data.Stats[0]);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
streamLogs(onChunk, { tail = 100, since = null, until = null, follow = true } = {}) {
|
||||
if (!follow) {
|
||||
// Historical fetch: single request, stop after completion (no reconnect)
|
||||
let handle;
|
||||
handle = this._stream(
|
||||
() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tail', String(tail));
|
||||
params.set('follow', 'false');
|
||||
if (since !== null) params.set('since', String(since));
|
||||
if (until !== null) params.set('until', String(until));
|
||||
return L.url('admin/podman/stream/logs', this.getID()) + '?' + params;
|
||||
},
|
||||
onChunk,
|
||||
() => handle?.stop()
|
||||
);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Live stream: reconnect with since= after each disconnect
|
||||
let reconnectSince = null;
|
||||
return this._stream(
|
||||
() => {
|
||||
const params = new URLSearchParams();
|
||||
if (reconnectSince !== null) {
|
||||
// Reconnect: continue from last close, no tail (would cap new lines)
|
||||
params.set('since', String(reconnectSince));
|
||||
} else {
|
||||
// First connect: apply user params
|
||||
params.set('tail', String(tail));
|
||||
if (since !== null) params.set('since', String(since));
|
||||
}
|
||||
params.set('follow', 'true');
|
||||
if (until !== null) params.set('until', String(until));
|
||||
return L.url('admin/podman/stream/logs', this.getID()) + '?' + params;
|
||||
},
|
||||
onChunk,
|
||||
() => { reconnectSince = Date.now() / 1000; }
|
||||
);
|
||||
},
|
||||
|
||||
async checkImageUpdate() {
|
||||
const findArchDigest = (manifest, arch, os) => {
|
||||
if (!manifest || !manifest.manifests) {
|
||||
return manifest.digest || null;
|
||||
}
|
||||
|
||||
const entry = manifest.manifests.find((m) =>
|
||||
m.platform &&
|
||||
m.platform.architecture === arch &&
|
||||
m.platform.os === os
|
||||
);
|
||||
|
||||
return entry ? entry.digest : null;
|
||||
};
|
||||
|
||||
try {
|
||||
const image = await this.getImage();
|
||||
|
||||
const imageRef = image.getDisplayTag() || this.Image;
|
||||
if (!imageRef || imageRef.startsWith('<none>'))
|
||||
return { hasUpdate: false };
|
||||
|
||||
const localArch = image.Architecture || 'amd64';
|
||||
const localOs = image.Os || 'linux';
|
||||
|
||||
const manifest = await image.inspectManifest(imageRef);
|
||||
|
||||
const remoteDigest = findArchDigest(manifest, localArch, localOs);
|
||||
|
||||
const repoDigests = image.RepoDigests || [];
|
||||
const hasUpdate = !!(remoteDigest && !repoDigests.some(rd => rd.includes(remoteDigest)));
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
remoteDigest,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
error: err.message || String(err)
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async _reinstateInitScript(status) {
|
||||
if (status !== 'enabled' && status !== 'disabled') return;
|
||||
await this.generateInitScript();
|
||||
if (status === 'enabled') await this.enableInitScript();
|
||||
},
|
||||
|
||||
getUpdateSkipReason() {
|
||||
if (this.Pod)
|
||||
return _('Part of a pod ("%s")').format(this.Pod);
|
||||
const hostConfig = this.getHostConfig();
|
||||
if (hostConfig.AutoRemove)
|
||||
return _('Ephemeral (--rm flag)');
|
||||
if (hostConfig.NetworkMode?.startsWith('container:'))
|
||||
return _('Shares network with another container');
|
||||
return null;
|
||||
},
|
||||
|
||||
async updateImage(onProgress) {
|
||||
const skipReason = this.getUpdateSkipReason();
|
||||
if (skipReason) throw new Error(skipReason);
|
||||
|
||||
const [image, initStatus] = await Promise.all([this.getImage(), this.checkInitScript()]);
|
||||
|
||||
if (!image.RepoTags?.length || image.RepoTags[0] === '<none>:<none>') {
|
||||
const ref = this.ImageName || this.Config?.Image;
|
||||
if (ref) image.RepoTags = [ref];
|
||||
}
|
||||
|
||||
const imageRef = image.getDisplayTag();
|
||||
if (!imageRef || imageRef === '<none>:<none>')
|
||||
throw new Error(_('Container image has no tag - cannot update'));
|
||||
|
||||
onProgress?.(_('Pulling %s').format(imageRef) + '\n');
|
||||
const oldImageId = image.getID();
|
||||
const newImageId = await image.update(onProgress);
|
||||
|
||||
if (!newImageId)
|
||||
throw new Error(_('Pull did not return an image ID - pull may have failed'));
|
||||
|
||||
if (newImageId === oldImageId)
|
||||
throw new Error(_('Image is already up-to-date'));
|
||||
|
||||
const wasRunning = this.isRunning();
|
||||
const spec = this.inspectToSpec(newImageId);
|
||||
|
||||
if (wasRunning) {
|
||||
onProgress?.('→ ' + _('Stopping container') + '\n');
|
||||
try { await this.stop(); }
|
||||
catch (e) { throw new Error(_('Stop failed: %s').format(e.message || e)); }
|
||||
}
|
||||
|
||||
onProgress?.('→ ' + _('Removing container') + '\n');
|
||||
try { await this.remove(); }
|
||||
catch (e) { throw new Error(_('Remove failed: %s').format(e.message || e)); }
|
||||
|
||||
onProgress?.('→ ' + _('Creating container') + '\n');
|
||||
let created;
|
||||
try { created = await ContainerRPC.create(spec); }
|
||||
catch (e) { throw new Error(_('Create failed: %s').format(e.message || e)); }
|
||||
if (!created || created.error)
|
||||
throw new Error(_('Create failed: %s').format(created?.error || _('Failed to create container')));
|
||||
|
||||
if (wasRunning) {
|
||||
onProgress?.('→ ' + _('Starting container') + '\n');
|
||||
try { await ContainerRPC.start(created.Id); }
|
||||
catch (e) { throw new Error(_('Start failed: %s').format(e.message || e)); }
|
||||
}
|
||||
|
||||
onProgress?.('→ ' + _('Reinstating init script') + '\n');
|
||||
try { await this._reinstateInitScript(initStatus); }
|
||||
catch (e) { throw new Error(_('Init script reinstate failed: %s').format(e.message || e)); }
|
||||
|
||||
onProgress?.('✓ ' + _('Updated to %s').format((newImageId || '').substring(0, 12)) + '\n');
|
||||
return { oldImage: image };
|
||||
},
|
||||
|
||||
async getImage() {
|
||||
const image = Image.getSingleton({ Id: this.ImageID || this.Image });
|
||||
return Image.getSingleton(await image.inspect());
|
||||
},
|
||||
|
||||
async updateName(name) {
|
||||
if (!name || name === this.getName()) return;
|
||||
|
||||
await ContainerRPC.rename(this.getID(), name);
|
||||
|
||||
const initScriptStatus = await this.checkInitScript();
|
||||
if (initScriptStatus === 'enabled' || initScriptStatus === 'disabled') {
|
||||
await this.removeInitScript();
|
||||
}
|
||||
|
||||
if (this.Names && this.Names.length > 0) this.Names[0] = name;
|
||||
if (this.Name) this.Name = name;
|
||||
|
||||
await this._reinstateInitScript(initScriptStatus);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
getSingleton(container) {
|
||||
return Container.extend(container).instantiate([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
|
||||
const ImageRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'image_inspect',
|
||||
params: ['id'],
|
||||
}),
|
||||
|
||||
inspectManifest: Model.declareRPCSilent({
|
||||
object: 'podman',
|
||||
method: 'image_manifest_inspect',
|
||||
params: ['image']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'image_remove',
|
||||
params: ['id', 'force'],
|
||||
}),
|
||||
};
|
||||
|
||||
const Image = Model.base.extend({
|
||||
__name__: 'Podman.Model.Image',
|
||||
|
||||
getID() {
|
||||
return this.Id;
|
||||
},
|
||||
|
||||
getDisplayTag() {
|
||||
return this.getRepoTags()[0];
|
||||
},
|
||||
|
||||
getRepoTags() {
|
||||
return this.RepoTags || ['<none>:<none>'];
|
||||
},
|
||||
|
||||
getRepository() {
|
||||
const tag = this.getDisplayTag() || '<none>:<none>';
|
||||
return tag.split(':')[0] || '<none>';
|
||||
},
|
||||
|
||||
getTag() {
|
||||
const tag = this.getDisplayTag() || '<none>:<none>';
|
||||
return tag.split(':')[1] || '<none>';
|
||||
},
|
||||
|
||||
getDigest() {
|
||||
if (this.Digest) {
|
||||
return this.Digest;
|
||||
}
|
||||
|
||||
if (this.RepoDigests && this.RepoDigests.length > 0) {
|
||||
for (const rd of this.RepoDigests) {
|
||||
if (rd.includes('@sha256:')) {
|
||||
return rd.split('@')[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
inspect() {
|
||||
return ImageRPC.inspect(this.getID());
|
||||
},
|
||||
|
||||
inspectManifest(ref) {
|
||||
return ImageRPC.inspectManifest(ref || this.getDisplayTag());
|
||||
},
|
||||
|
||||
remove() {
|
||||
return ImageRPC.remove(this.getID());
|
||||
},
|
||||
|
||||
update(onProgress) {
|
||||
return this.streamPull(onProgress);
|
||||
},
|
||||
|
||||
streamPull(onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let handle;
|
||||
handle = this._stream(
|
||||
() => L.url('admin/podman/stream/pull') + '?reference=' + encodeURIComponent(this.getDisplayTag()),
|
||||
(chunk) => {
|
||||
if (chunk.images && chunk.images.length > 0) { resolve(chunk.images[0]); handle.stop(); return; }
|
||||
if (chunk.error) { reject(new Error(chunk.error)); handle.stop(); return; }
|
||||
if (chunk.stream) onProgress?.(chunk.stream);
|
||||
if (chunk.raw) onProgress?.(chunk.raw + '\n');
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
getSingleton(image) {
|
||||
return Image.extend(image).instantiate([]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
|
||||
function checkResponse(response) {
|
||||
if (response?.response >= 400) {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function catchError(err) {
|
||||
const msg = err?.message || String(err);
|
||||
if (msg.includes('Access denied') || msg.includes('session is expired')) {
|
||||
window.location.href = L.url('admin');
|
||||
return;
|
||||
}
|
||||
ui.hideModal();
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
podmanUI.alert(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
function processLines(buffer, onChunk) {
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
onChunk(JSON.parse(line));
|
||||
} catch (e) {
|
||||
onChunk({ raw: line.trimEnd() });
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const Model = baseclass.extend({
|
||||
__name: 'Podman.Model',
|
||||
|
||||
_stream(getUrl, onData, onDone) {
|
||||
let stopped = false;
|
||||
let currentController = null;
|
||||
|
||||
const doStream = async () => {
|
||||
const decoder = new TextDecoder();
|
||||
let backoff = 1000;
|
||||
|
||||
while (!stopped) {
|
||||
currentController = new AbortController();
|
||||
try {
|
||||
const response = await fetch(getUrl(), { signal: currentController.signal });
|
||||
if (response.status === 403) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// Flush any trailing line the server sent without a final '\n'
|
||||
if (buffer.trim() && !stopped)
|
||||
processLines(buffer + '\n', onData);
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = processLines(buffer, onData);
|
||||
}
|
||||
|
||||
backoff = 1000;
|
||||
if (!stopped) onDone?.();
|
||||
} catch (err) {
|
||||
if (stopped) break; // AbortController fired from stop()
|
||||
// network error - reconnect with backoff
|
||||
await new Promise(r => setTimeout(r, backoff));
|
||||
backoff = Math.min(backoff * 2, 30000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
doStream();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (currentController) currentController.abort();
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
base: Model,
|
||||
declareRPC(options) {
|
||||
const fn = rpc.declare(options);
|
||||
return (...args) => fn(...args).then(checkResponse).catch(catchError);
|
||||
},
|
||||
// Like declareRPC but does not show the global error dialog on failure.
|
||||
// Use for background checks where the caller handles the error itself.
|
||||
declareRPCSilent(options) {
|
||||
const fn = rpc.declare(options);
|
||||
return (...args) => fn(...args).then(checkResponse);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require network';
|
||||
'require firewall';
|
||||
'require uci';
|
||||
'require ui';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
|
||||
const NetworkRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'network_inspect',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'network_remove',
|
||||
params: ['name', 'force']
|
||||
}),
|
||||
|
||||
connect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'network_connect',
|
||||
params: ['name', 'data']
|
||||
}),
|
||||
|
||||
disconnect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'network_disconnect',
|
||||
params: ['name', 'data']
|
||||
}),
|
||||
};
|
||||
|
||||
const Network = Model.base.extend({
|
||||
__name__: 'Podman.Model.Network',
|
||||
|
||||
getID() {
|
||||
return this.ID;
|
||||
},
|
||||
|
||||
getName() {
|
||||
return this.name || _('Unknown');
|
||||
},
|
||||
|
||||
getDriver() {
|
||||
return this.driver;
|
||||
},
|
||||
|
||||
getSubnetIP4() {
|
||||
if (this.subnets?.length > 0) {
|
||||
return this.subnets[0].subnet || '';
|
||||
}
|
||||
if (this.IPAM?.Config?.length > 0) {
|
||||
return this.IPAM.Config[0].Subnet || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
getSubnetIP6() {
|
||||
if (this.ipv6_enabled && this.subnets?.length > 1) {
|
||||
return this.subnets[1].subnet || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
getGatewayIP4() {
|
||||
if (this.subnets?.length > 0) {
|
||||
return this.subnets[0].gateway || '';
|
||||
}
|
||||
if (this.IPAM?.Config?.length > 0) {
|
||||
return this.IPAM.Config[0].Gateway || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
getGatewayIP6() {
|
||||
if (this.ipv6_enabled && this.subnets?.length > 1) {
|
||||
return this.subnets[1].gateway || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
isBridge() {
|
||||
return this.getDriver() === 'bridge';
|
||||
},
|
||||
|
||||
async inspect() {
|
||||
return NetworkRPC.inspect(this.getName());
|
||||
},
|
||||
|
||||
async remove(force) {
|
||||
return NetworkRPC.remove(this.getName(), force ?? false);
|
||||
},
|
||||
|
||||
async connect(data) {
|
||||
return NetworkRPC.connect(this.getName(), data || {});
|
||||
},
|
||||
|
||||
async disconnect(data) {
|
||||
return NetworkRPC.disconnect(this.getName(), data || {});
|
||||
},
|
||||
|
||||
async integrationCheck() {
|
||||
const check = {
|
||||
hasInterface: false,
|
||||
hasDevice: false,
|
||||
hasDnsmasqExclusion: false,
|
||||
};
|
||||
|
||||
const networkObj = await network.getNetwork(this.getName());
|
||||
let deviceObj;
|
||||
|
||||
if (networkObj) {
|
||||
check.hasInterface = true;
|
||||
deviceObj = networkObj.getDevice();
|
||||
|
||||
if (deviceObj) {
|
||||
check.hasDevice = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deviceObj) {
|
||||
const devices = (await network.getDevices()).map((device) => device.device);
|
||||
|
||||
if (devices.includes(this.network_interface)) {
|
||||
check.hasDevice = true;
|
||||
}
|
||||
}
|
||||
|
||||
await uci.load('dhcp');
|
||||
const mainSection = await this.hasDnsmasq() ? this._getDnsmasqSection() : null;
|
||||
if (mainSection) {
|
||||
const notInterfaces = uci.get('dhcp', mainSection, 'notinterface');
|
||||
|
||||
if ([].concat(notInterfaces ?? []).includes(this.getName())) {
|
||||
check.hasDnsmasqExclusion = true;
|
||||
}
|
||||
} else {
|
||||
check.hasDnsmasqExclusion = true;
|
||||
}
|
||||
|
||||
return check;
|
||||
},
|
||||
|
||||
async integrationRepair() {
|
||||
await uci.load('dhcp');
|
||||
const status = await this.integrationCheck();
|
||||
if (status.hasInterface && status.hasDevice && status.hasDnsmasqExclusion) {
|
||||
return;
|
||||
}
|
||||
|
||||
let deviceName;
|
||||
let changes = false;
|
||||
|
||||
if (status.hasDevice === false && this.isBridge()) {
|
||||
deviceName = await this.integrationCreateDevice();
|
||||
changes = true;
|
||||
|
||||
if (status.hasInterface === true) {
|
||||
const net = await network.getNetwork(this.getName());
|
||||
net.addDevice(deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.hasInterface === false) {
|
||||
if (status.hasDevice === true) {
|
||||
deviceName = this.network_interface;
|
||||
}
|
||||
|
||||
await this.integrationCreateNetwork(deviceName);
|
||||
changes = true;
|
||||
}
|
||||
|
||||
if (await this.hasDnsmasq() && status.hasDnsmasqExclusion === false) {
|
||||
await this.integrationAddDnsmasqExclusion();
|
||||
changes = true;
|
||||
}
|
||||
|
||||
if (changes === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await uci.save();
|
||||
ui.changes.apply();
|
||||
await network.flushCache();
|
||||
},
|
||||
|
||||
async integrationRemove() {
|
||||
await uci.load('dhcp');
|
||||
const status = await this.integrationCheck();
|
||||
let changes = false;
|
||||
|
||||
// Check device shared status BEFORE deleting the interface to avoid stale network cache
|
||||
let removeDevice = false;
|
||||
if (status.hasDevice === true && this.isBridge()) {
|
||||
const device = await network.getDevice(this.network_interface);
|
||||
removeDevice = device?.getNetworks().length === 1;
|
||||
}
|
||||
|
||||
if (status.hasInterface === true) {
|
||||
const zone = await firewall.getZoneByNetwork(this.getName());
|
||||
if (zone) await zone.deleteNetwork(this.getName());
|
||||
await network.deleteNetwork(this.getName());
|
||||
changes = true;
|
||||
}
|
||||
|
||||
if (removeDevice) {
|
||||
uci.remove('network', this.network_interface + '_device');
|
||||
changes = true;
|
||||
}
|
||||
|
||||
if (await this.hasDnsmasq() && status.hasDnsmasqExclusion === true) {
|
||||
const mainSection = this._getDnsmasqSection();
|
||||
if (mainSection) {
|
||||
const notInterfaces = [].concat(uci.get('dhcp', mainSection, 'notinterface') || [])
|
||||
.filter(iface => iface !== this.getName());
|
||||
if (notInterfaces.length > 0)
|
||||
uci.set('dhcp', mainSection, 'notinterface', notInterfaces);
|
||||
else
|
||||
uci.unset('dhcp', mainSection, 'notinterface');
|
||||
changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changes === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await uci.save();
|
||||
ui.changes.apply();
|
||||
await network.flushCache();
|
||||
},
|
||||
|
||||
async integrationCreateNetwork(deviceName) {
|
||||
const networkName = this.getName();
|
||||
|
||||
if (this.subnets?.length === 0 && this.ipam_options?.driver === 'dhcp') {
|
||||
uci.add('network', 'interface', networkName);
|
||||
uci.set('network', networkName, 'proto', 'dhcp');
|
||||
return;
|
||||
}
|
||||
|
||||
const subnet = this.getSubnetIP4();
|
||||
if (!subnet) return;
|
||||
|
||||
const prefix = parseInt(subnet.split('/')[1], 10);
|
||||
const netmask = network.prefixToMask(prefix);
|
||||
|
||||
const sid = `${networkName}`;
|
||||
uci.add('network', 'interface', sid);
|
||||
uci.set('network', sid, 'proto', 'static');
|
||||
if (deviceName) uci.set('network', sid, 'device', deviceName);
|
||||
uci.set('network', sid, 'ipaddr', this.getGatewayIP4());
|
||||
uci.set('network', sid, 'netmask', netmask);
|
||||
|
||||
if (this.ipv6_enabled) {
|
||||
uci.set('network', sid, 'ip6addr', this.getGatewayIP6() + '/64');
|
||||
}
|
||||
},
|
||||
|
||||
async integrationCreateDevice() {
|
||||
const devices = (await network.getDevices()).map((device) => device.device);
|
||||
|
||||
const baseName = this.network_interface || this.getName();
|
||||
let i = 0;
|
||||
let deviceName = baseName;
|
||||
|
||||
while (devices.includes(deviceName)) {
|
||||
i++;
|
||||
deviceName = `${baseName}${i}`;
|
||||
}
|
||||
|
||||
const sid = `${deviceName}_device`;
|
||||
uci.add('network', 'device', sid);
|
||||
uci.set('network', sid, 'type', 'bridge');
|
||||
uci.set('network', sid, 'name', deviceName);
|
||||
uci.set('network', sid, 'bridge_empty', '1');
|
||||
uci.set('network', sid, 'ipv6', this.ipv6_enabled ? '1' : '0');
|
||||
|
||||
if (this.ipv6_enabled) {
|
||||
uci.set('network', sid, 'ip6assign', '64');
|
||||
}
|
||||
|
||||
return deviceName;
|
||||
},
|
||||
|
||||
_getDnsmasqSection() {
|
||||
const sections = uci.sections('dhcp', 'dnsmasq');
|
||||
return sections.length > 0 ? sections[0]['.name'] : null;
|
||||
},
|
||||
|
||||
async hasDnsmasq() {
|
||||
return L.hasSystemFeature('dnsmasq');
|
||||
},
|
||||
|
||||
async integrationAddDnsmasqExclusion() {
|
||||
const mainSection = this._getDnsmasqSection();
|
||||
if (mainSection) {
|
||||
const notInterfaces = [].concat(uci.get('dhcp', mainSection, 'notinterface') || []);
|
||||
if (!notInterfaces.includes(this.getName())) {
|
||||
notInterfaces.push(this.getName());
|
||||
uci.set('dhcp', mainSection, 'notinterface', notInterfaces);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
__name__: 'Podman.Model.Network',
|
||||
getSingleton(data) {
|
||||
return Network.extend(data).instantiate([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
'require podman.ui as podmanUI';
|
||||
|
||||
const PodRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_inspect',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
start: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_start',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
stop: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_stop',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
kill: Model.declareRPCSilent({
|
||||
object: 'podman',
|
||||
method: 'pod_kill',
|
||||
params: ['name', 'signal']
|
||||
}),
|
||||
|
||||
restart: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_restart',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
pause: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_pause',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
unpause: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_unpause',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_remove',
|
||||
params: ['name', 'force']
|
||||
}),
|
||||
};
|
||||
|
||||
const Pod = Model.base.extend({
|
||||
__name__: 'Podman.Model.Pod',
|
||||
|
||||
getID() {
|
||||
return this.Id;
|
||||
},
|
||||
|
||||
getName() {
|
||||
return this.Name || this.Id || _('Unknown');
|
||||
},
|
||||
|
||||
getStatus() {
|
||||
return this.Status || this.State || '';
|
||||
},
|
||||
|
||||
getStatusBadge() {
|
||||
const status = this.getStatus();
|
||||
const cssClass = status.toLowerCase();
|
||||
return E('div', { class: `badge ${cssClass}` }, [ status ]);
|
||||
},
|
||||
|
||||
getCreatedAt() {
|
||||
return this.Created;
|
||||
},
|
||||
|
||||
getInfraId() {
|
||||
return this.InfraId || this.InfraContainerID;
|
||||
},
|
||||
|
||||
getNetworks() {
|
||||
return this.Networks || [];
|
||||
},
|
||||
|
||||
getLabels() {
|
||||
return this.Labels || {};
|
||||
},
|
||||
|
||||
getContainers() {
|
||||
return this.Containers || [];
|
||||
},
|
||||
|
||||
isRunning() {
|
||||
return this.getStatus() === 'Running';
|
||||
},
|
||||
|
||||
isPaused() {
|
||||
return this.getStatus() === 'Paused';
|
||||
},
|
||||
|
||||
isStopped() {
|
||||
const state = this.getStatus();
|
||||
return state === 'Stopped' || state === 'Exited' || state === 'Created' || state === 'Dead';
|
||||
},
|
||||
|
||||
async inspect() {
|
||||
return PodRPC.inspect(this.getName());
|
||||
},
|
||||
|
||||
async start() {
|
||||
return PodRPC.start(this.getName());
|
||||
},
|
||||
|
||||
async stop() {
|
||||
const result = await PodRPC.stop(this.getName());
|
||||
if (result?.Id) return result;
|
||||
|
||||
try {
|
||||
return await PodRPC.kill(this.getName(), 'SIGKILL');
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (msg.includes('no running containers')) return result;
|
||||
ui.hideModal();
|
||||
podmanUI.alert(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async restart() {
|
||||
return PodRPC.restart(this.getName());
|
||||
},
|
||||
|
||||
async pause() {
|
||||
return PodRPC.pause(this.getName());
|
||||
},
|
||||
|
||||
async unpause() {
|
||||
return PodRPC.unpause(this.getName());
|
||||
},
|
||||
|
||||
async remove(force) {
|
||||
return PodRPC.remove(this.getName(), force ?? true);
|
||||
},
|
||||
|
||||
streamTop(onChunk, delay) {
|
||||
const params = new URLSearchParams({ delay: String(delay || 2) });
|
||||
const url = L.url('admin/podman/stream/pod_top', this.getName()) + '?' + params;
|
||||
return this._stream(
|
||||
() => url,
|
||||
(data) => { if (!data.raw && !data.Error) onChunk(data); }
|
||||
);
|
||||
},
|
||||
|
||||
streamStats(onChunk) {
|
||||
const url = L.url('admin/podman/stream/pod_stats', this.getName());
|
||||
return this._stream(
|
||||
() => url,
|
||||
(data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
onChunk(data);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
getDetailLink(label) {
|
||||
return E('a', { href: L.url('admin/podman/pod', this.getID()) }, label || this.getName());
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
__name__: 'Podman.Model.Pod',
|
||||
getSingleton(data) {
|
||||
return Pod.extend(data).instantiate([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
|
||||
const SecretRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'secret_inspect',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'secret_remove',
|
||||
params: ['name']
|
||||
}),
|
||||
};
|
||||
|
||||
const Secret = Model.base.extend({
|
||||
__name__: 'Podman.Model.Secret',
|
||||
|
||||
getID() {
|
||||
return this.ID;
|
||||
},
|
||||
|
||||
getName() {
|
||||
return this.Spec?.Name;
|
||||
},
|
||||
|
||||
getDriver() {
|
||||
return this.Spec?.Driver?.Name;
|
||||
},
|
||||
|
||||
getLabels() {
|
||||
return this.Spec?.Labels ?? {};
|
||||
},
|
||||
|
||||
async inspect() {
|
||||
return SecretRPC.inspect(this.getName());
|
||||
},
|
||||
|
||||
async remove() {
|
||||
return SecretRPC.remove(this.getName());
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
getSingleton(secret) {
|
||||
return Secret.extend(secret).instantiate([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
'require podman.model.Model as Model';
|
||||
|
||||
const VolumeRPC = {
|
||||
inspect: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'volume_inspect',
|
||||
params: ['name']
|
||||
}),
|
||||
|
||||
remove: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'volume_remove',
|
||||
params: ['name', 'force']
|
||||
}),
|
||||
};
|
||||
|
||||
const Volume = Model.base.extend({
|
||||
__name__: 'Podman.Model.Volume',
|
||||
|
||||
getName() {
|
||||
return this.Name || _('Unknown');
|
||||
},
|
||||
|
||||
async inspect() {
|
||||
return VolumeRPC.inspect(this.getName());
|
||||
},
|
||||
|
||||
async remove(force) {
|
||||
return VolumeRPC.remove(this.getName(), force || false);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
getSingleton(volume) {
|
||||
return Volume.extend(volume).instantiate([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require podman.model.Model as Model';
|
||||
'require podman.model.Container as Container';
|
||||
'require podman.model.Image as Image';
|
||||
'require podman.model.Network as Network';
|
||||
'require podman.model.Volume as Volume';
|
||||
'require podman.model.Secret as Secret';
|
||||
'require podman.model.Pod as Pod';
|
||||
|
||||
return baseclass.extend({
|
||||
/**
|
||||
* Container methods.
|
||||
*/
|
||||
containers: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'containers_list',
|
||||
params: ['query'],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (containers) => containers
|
||||
.map((container) => Container.getSingleton(container))
|
||||
.sort((containerA, containerB) => containerA
|
||||
.getName()
|
||||
.localeCompare(containerB.getName()))
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'container_create',
|
||||
params: ['data']
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Image methods.
|
||||
*/
|
||||
images: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'images_list',
|
||||
params: [],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (images) => images
|
||||
.map((image) => Image.getSingleton(image))
|
||||
.sort((imageA, imageB) => imageA.getDisplayTag().localeCompare(imageB.getDisplayTag())),
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Pod methods.
|
||||
*/
|
||||
pods: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pods_list',
|
||||
params: [],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (pods) => pods
|
||||
.map((pod) => Pod.getSingleton(pod))
|
||||
.sort((a, b) => a.getName().localeCompare(b.getName()))
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'pod_create',
|
||||
params: ['data']
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Network management methods.
|
||||
*/
|
||||
networks: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'networks_list',
|
||||
params: [],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (networks) => networks
|
||||
.map((network) => Network.getSingleton(network))
|
||||
.sort((networkA, networkB) => networkA.getName().localeCompare(networkB.getName())),
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'network_create',
|
||||
params: ['data']
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Volume management methods.
|
||||
*/
|
||||
volumes: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'volumes_list',
|
||||
params: [],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (volumes) => volumes
|
||||
.map((volume) => Volume.getSingleton(volume))
|
||||
.sort((volumeA, volumeB) => volumeA.getName().localeCompare(volumeB.getName()))
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'volume_create',
|
||||
params: ['data']
|
||||
}),
|
||||
|
||||
import: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'volume_import',
|
||||
params: ['name', 'compressed']
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Secret management methods.
|
||||
*/
|
||||
secrets: {
|
||||
list: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'secrets_list',
|
||||
params: [],
|
||||
expect: {
|
||||
data: []
|
||||
},
|
||||
filter: (secrets) => secrets
|
||||
.map((secret) => Secret.getSingleton(secret))
|
||||
.sort((secretA, secretB) => secretA.getName().localeCompare(secretB.getName()))
|
||||
}),
|
||||
|
||||
create: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'secret_create',
|
||||
params: ['name', 'data', 'labels']
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* System information methods.
|
||||
*/
|
||||
system: {
|
||||
version: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'version',
|
||||
params: []
|
||||
}),
|
||||
|
||||
info: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'info',
|
||||
params: []
|
||||
}),
|
||||
|
||||
df: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'system_df',
|
||||
params: []
|
||||
}),
|
||||
|
||||
prune: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'system_prune',
|
||||
params: ['all', 'volumes']
|
||||
}),
|
||||
|
||||
debug: Model.declareRPC({
|
||||
object: 'podman',
|
||||
method: 'system_debug',
|
||||
params: []
|
||||
})
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require podman.constants as c';
|
||||
|
||||
const AbstractInputField = ui.Textfield.extend({
|
||||
__name__: 'Podman.UI.AbstractInputField',
|
||||
|
||||
inputType: 'text',
|
||||
|
||||
__init__(value, options) {
|
||||
this.value = value;
|
||||
this.options = Object.assign({ min: '', max: '' }, options);
|
||||
},
|
||||
|
||||
render() {
|
||||
const frameEl = E('div', { id: this.options.id });
|
||||
const inputEl = E('input', {
|
||||
id: this.options.id ? `widget.${this.options.id}` : null,
|
||||
name: this.options.name,
|
||||
type: this.inputType,
|
||||
class: 'cbi-input-text',
|
||||
readonly: this.options.readonly ? '' : null,
|
||||
disabled: this.options.disabled ? '' : null,
|
||||
min: this.options.min,
|
||||
max: this.options.max,
|
||||
placeholder: this.options.placeholder,
|
||||
value: this.value,
|
||||
});
|
||||
|
||||
frameEl.appendChild(inputEl);
|
||||
|
||||
return this.bind(frameEl);
|
||||
},
|
||||
});
|
||||
|
||||
const UINumberfield = AbstractInputField.extend({
|
||||
__name__: 'Podman.UI.Numberfield',
|
||||
inputType: 'number',
|
||||
});
|
||||
|
||||
const UIDatefield = AbstractInputField.extend({
|
||||
__name__: 'Podman.UI.Datefield',
|
||||
inputType: 'date',
|
||||
});
|
||||
|
||||
const UIBase = baseclass.extend({
|
||||
__name__: 'Podman.UI',
|
||||
|
||||
alert(text, type, timeLimited) {
|
||||
timeLimited = timeLimited === true ? c.NOTIFICATION_TIMEOUT : timeLimited;
|
||||
type = type || 'info';
|
||||
|
||||
if (Array.isArray(text) !== true) {
|
||||
text = E('p', {}, text);
|
||||
}
|
||||
|
||||
if (parseInt(timeLimited) > 0) {
|
||||
ui.addTimeLimitedNotification(type.toUpperCase(), text, timeLimited, type);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(type.toUpperCase(), text, type);
|
||||
},
|
||||
|
||||
showSpinningModal(title, text) {
|
||||
ui.showModal(title, [E('div', { class: 'spinning' }, text)], 'loading-modal');
|
||||
},
|
||||
});
|
||||
|
||||
const UITooltip = baseclass.extend({
|
||||
__name__: 'Podman.UI.Tooltip',
|
||||
|
||||
node: '',
|
||||
tooltip: '',
|
||||
options: {},
|
||||
|
||||
__init__(node, tooltip, options) {
|
||||
this.node = node || '';
|
||||
this.tooltip = tooltip || '';
|
||||
this.options = options || {};
|
||||
},
|
||||
|
||||
render() {
|
||||
const cssClass = 'cbi-tooltip-container';
|
||||
const cls = this.options.class ? `${this.options.class} ${cssClass}` : cssClass;
|
||||
const options = Object.assign({}, this.options, { class: cls });
|
||||
|
||||
return E('div', options, [
|
||||
this.node,
|
||||
E('div', { class: 'cbi-tooltip' }, this.tooltip),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const UISecretText = baseclass.extend({
|
||||
__name__: 'Podman.UI.SecretText',
|
||||
|
||||
value: '',
|
||||
censoredValue: '',
|
||||
show: false,
|
||||
|
||||
__init__(value, censoredValue) {
|
||||
this.value = value;
|
||||
this.censoredValue = censoredValue;
|
||||
},
|
||||
|
||||
render() {
|
||||
const me = this;
|
||||
|
||||
return new UITooltip(
|
||||
me.censoredValue,
|
||||
_('Click to reveal/hide value'),
|
||||
{
|
||||
class: 'tooltip',
|
||||
click() {
|
||||
if (me.show) {
|
||||
me.show = false;
|
||||
this.childNodes[0].nodeValue = me.censoredValue;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
me.show = true;
|
||||
this.childNodes[0].nodeValue = me.value;
|
||||
},
|
||||
}
|
||||
).render();
|
||||
}
|
||||
});
|
||||
|
||||
const UIButton = baseclass.extend({
|
||||
__name__: 'Podman.UI.Button',
|
||||
|
||||
__init__(text, href, cssClass, tooltip) {
|
||||
this.text = text;
|
||||
this.href = href;
|
||||
this.cssClass = cssClass;
|
||||
this.tooltip = tooltip;
|
||||
},
|
||||
|
||||
render() {
|
||||
const attrs = {
|
||||
class: this.cssClass ? 'cbi-button cbi-button-' + this.cssClass : 'cbi-button',
|
||||
click: typeof this.href === 'function' ? this.href : (ev) => {
|
||||
ev.preventDefault();
|
||||
window.location.href = this.href;
|
||||
}
|
||||
};
|
||||
|
||||
if (this.tooltip) {
|
||||
attrs.title = this.tooltip;
|
||||
}
|
||||
|
||||
return E('button', attrs, this.text || '');
|
||||
}
|
||||
});
|
||||
|
||||
const UIButtonNew = baseclass.extend({
|
||||
__name__: 'Podman.UI.ButtonNew',
|
||||
|
||||
__init__(text, options) {
|
||||
this.options = Object.assign({
|
||||
text: text || '',
|
||||
// class: ''
|
||||
type: '',
|
||||
href: '',
|
||||
click: () => { },
|
||||
tooltip: '',
|
||||
}, options);
|
||||
},
|
||||
|
||||
render() {
|
||||
const click = this.options.href
|
||||
? ui.createHandlerFn(this, (event) => {
|
||||
event.preventDefault();
|
||||
window.location.href = this.options.href;
|
||||
})
|
||||
: this.options.click;
|
||||
|
||||
const button = E('button', {
|
||||
class: `cbi-button cbi-button-${this.options.type}`,
|
||||
click,
|
||||
}, this.options.text);
|
||||
|
||||
if (this.options.tooltip) {
|
||||
return new UITooltip(button, this.options.tooltip).render();
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
const UIJsonArea = baseclass.extend({
|
||||
__name__: 'Podman.UI.JsonArea',
|
||||
|
||||
__init__(data) {
|
||||
this.data = data || this.data;
|
||||
},
|
||||
|
||||
render() {
|
||||
const el = E('pre', { class: 'json-area' });
|
||||
const json = JSON.stringify(this.data, null, 2);
|
||||
const nodes = [];
|
||||
let last = 0;
|
||||
json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
|
||||
(match, _p1, _p2, _p3, _p4, offset) => {
|
||||
if (offset > last)
|
||||
nodes.push(document.createTextNode(json.slice(last, offset)));
|
||||
let cls = 'number';
|
||||
if (/^"/.test(match)) cls = /:$/.test(match) ? 'key' : 'string';
|
||||
else if (/true|false/.test(match)) cls = 'boolean';
|
||||
else if (/null/.test(match)) cls = 'null';
|
||||
nodes.push(E('span', { 'class': cls }, match));
|
||||
last = offset + match.length;
|
||||
}
|
||||
);
|
||||
if (last < json.length)
|
||||
nodes.push(document.createTextNode(json.slice(last)));
|
||||
for (const node of nodes) el.appendChild(node);
|
||||
return el;
|
||||
},
|
||||
});
|
||||
|
||||
const UIBashCodeArea = baseclass.extend({
|
||||
__name__: 'Podman.UI.BashCodeArea',
|
||||
|
||||
code: '',
|
||||
|
||||
__init__(code) {
|
||||
this.code = code || this.code;
|
||||
},
|
||||
|
||||
render() {
|
||||
const el = E('pre', { class: 'code-area bash' });
|
||||
const code = this.code || '';
|
||||
const nodes = [];
|
||||
let last = 0;
|
||||
const bashRegex = /(#[^\n]*)|("(?:\\.|[^\\"])*"|'(?:\\.|[^\\'])*')|(\$\w+|\$\{[^}]+\})|\b(if|then|else|elif|fi|case|esac|for|while|do|done|in|break|continue|exit|return|stop_service|start_service)\b|(\b\d+\b)/g;
|
||||
code.replace(bashRegex, (match, comment, string, variable, keyword, number, offset) => {
|
||||
if (offset > last)
|
||||
nodes.push(document.createTextNode(code.slice(last, offset)));
|
||||
let cls = null;
|
||||
if (comment) cls = 'comment';
|
||||
else if (string) cls = 'string';
|
||||
else if (variable) cls = 'variable';
|
||||
else if (keyword) cls = 'keyword';
|
||||
else if (number) cls = 'number';
|
||||
nodes.push(cls ? E('span', { 'class': cls }, match) : document.createTextNode(match));
|
||||
last = offset + match.length;
|
||||
});
|
||||
if (last < code.length)
|
||||
nodes.push(document.createTextNode(code.slice(last)));
|
||||
for (const node of nodes) el.appendChild(node);
|
||||
return el;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Scrolling text log for streaming output. Each append() is one logical
|
||||
* update: lines containing CR overwrite the last line (Podman pull progress
|
||||
* uses CR to refresh "Copying blob X 50%"); plain lines append. Sticky
|
||||
* auto-scroll: jumps to bottom on append unless the user scrolled up.
|
||||
*/
|
||||
const UIStreamLog = baseclass.extend({
|
||||
__name__: 'Podman.UI.StreamLog',
|
||||
|
||||
maxLines: 2000,
|
||||
autoScroll: true,
|
||||
|
||||
__init__(opts) {
|
||||
opts = opts || {};
|
||||
if (typeof opts.maxLines === 'number') this.maxLines = opts.maxLines;
|
||||
if (typeof opts.autoScroll === 'boolean') this.autoScroll = opts.autoScroll;
|
||||
this._container = E('div', { class: 'podman-stream-log' });
|
||||
this._lastLineNode = null;
|
||||
this._lineCount = 0;
|
||||
},
|
||||
|
||||
append(text) {
|
||||
if (text == null) return this;
|
||||
const stripped = String(text).replace(/\n+$/, '');
|
||||
if (!stripped) return this;
|
||||
|
||||
const stick = this.autoScroll && this._isAtBottom();
|
||||
const cr = stripped.lastIndexOf('\r');
|
||||
const isProgress = cr >= 0;
|
||||
const display = isProgress ? stripped.slice(cr + 1) : stripped;
|
||||
|
||||
if (isProgress && this._lastLineNode) {
|
||||
this._lastLineNode.textContent = display;
|
||||
} else {
|
||||
this._appendLine(display);
|
||||
}
|
||||
|
||||
if (stick) this._container.scrollTop = this._container.scrollHeight;
|
||||
return this;
|
||||
},
|
||||
|
||||
appendBlock(lines) {
|
||||
if (Array.isArray(lines)) for (const l of lines) this.append(l);
|
||||
return this;
|
||||
},
|
||||
|
||||
clear() {
|
||||
while (this._container.firstChild) this._container.removeChild(this._container.firstChild);
|
||||
this._lastLineNode = null;
|
||||
this._lineCount = 0;
|
||||
return this;
|
||||
},
|
||||
|
||||
render() {
|
||||
return this._container;
|
||||
},
|
||||
|
||||
_appendLine(text) {
|
||||
const node = E('div', { class: 'log-line' }, [text || ' ']);
|
||||
this._container.appendChild(node);
|
||||
this._lastLineNode = node;
|
||||
this._lineCount++;
|
||||
while (this._lineCount > this.maxLines && this._container.firstChild) {
|
||||
const removed = this._container.firstChild;
|
||||
this._container.removeChild(removed);
|
||||
this._lineCount--;
|
||||
if (this._lastLineNode === removed) this._lastLineNode = this._container.lastChild;
|
||||
}
|
||||
},
|
||||
|
||||
_isAtBottom() {
|
||||
const el = this._container;
|
||||
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 5;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Table builder with header and row chaining.
|
||||
*/
|
||||
const UITable = baseclass.extend({
|
||||
__name__: 'Podman.UI.Table',
|
||||
|
||||
options: { class: 'table' },
|
||||
|
||||
headers: [],
|
||||
rows: [],
|
||||
|
||||
__init__(options) {
|
||||
this.headers = [];
|
||||
this.rows = [];
|
||||
|
||||
const baseClass = this.options.class;
|
||||
this.options = Object.assign({}, this.options, options);
|
||||
if (options?.class) this.options.class = `${baseClass} ${options.class}`;
|
||||
},
|
||||
|
||||
addHeader(header, options) {
|
||||
this.headers.push({
|
||||
inner: header,
|
||||
options: Object.assign({ class: 'th' }, options || {}),
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
setHeaders(headers) {
|
||||
this.headers = headers;
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
addRow(cells, options) {
|
||||
this.rows.push({
|
||||
cells,
|
||||
options: Object.assign({ class: 'tr' }, options || {}),
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
setRows(rows) {
|
||||
this.rows = rows;
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
render() {
|
||||
let headerRow = '';
|
||||
|
||||
if (Array.isArray(this.headers) && this.headers.length > 0) {
|
||||
headerRow = E('tr', { class: 'tr table-titles' },
|
||||
this.headers.map((header) => E('th', header.options, header.inner))
|
||||
);
|
||||
}
|
||||
|
||||
let rows = '';
|
||||
|
||||
if (Array.isArray(this.rows) && this.rows.length > 0) {
|
||||
rows = this.rows.map((row) => E('tr', row.options,
|
||||
row.cells.map((cell) => E('td', Object.assign({ class: 'td' }, cell.options || {}), cell.inner))
|
||||
));
|
||||
}
|
||||
|
||||
return E('table', this.options, [headerRow].concat(rows));
|
||||
}
|
||||
});
|
||||
|
||||
const UITableList = UITable.extend({
|
||||
__name__: 'Podman.UI.TableList',
|
||||
|
||||
options: { class: 'table table-list' },
|
||||
|
||||
addRow(label, value, rowOptions) {
|
||||
return this.super('addRow', [[
|
||||
{ inner: label },
|
||||
{ inner: value }
|
||||
], rowOptions]);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tabbed interface using LuCI tabs API.
|
||||
*/
|
||||
const UITabs = baseclass.extend({
|
||||
__name__: 'Podman.UI.Tabs',
|
||||
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
|
||||
__init__(activeTab) {
|
||||
this.tabs = [];
|
||||
this.activeTab = activeTab || null;
|
||||
},
|
||||
|
||||
addTab(id, title, content, active) {
|
||||
this.tabs.push({
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
active: active || false
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
render() {
|
||||
const tabPanes = this.tabs.map((tab, index) => {
|
||||
const isActive = tab.active || (!this.activeTab && index === 0) || (this.activeTab === tab.id);
|
||||
|
||||
return E('div', {
|
||||
class: 'tab-pane',
|
||||
'data-tab': tab.id,
|
||||
'data-tab-title': tab.title,
|
||||
'data-tab-active': isActive ? 'true' : null
|
||||
}, [tab.content || E('div', { class: 'cbi-section' }, E('div', { class: 'cbi-section-node' }))]);
|
||||
});
|
||||
|
||||
const tabContainer = E('div', { class: 'cbi-section' }, [
|
||||
E('div', { class: 'cbi-section-node' }, [
|
||||
E('div', { class: 'tab-panes' }, tabPanes)
|
||||
])
|
||||
]);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const panes = tabContainer.querySelectorAll('.tab-pane');
|
||||
ui.tabs.initTabGroup(panes);
|
||||
});
|
||||
|
||||
return tabContainer;
|
||||
}
|
||||
});
|
||||
|
||||
const UIModal = baseclass.extend({
|
||||
__name__: 'Podman.UI.Modal',
|
||||
|
||||
title: '',
|
||||
content: null,
|
||||
extraClasses: [],
|
||||
|
||||
__init__(title, content, extraClasses) {
|
||||
this.title = title || this.title;
|
||||
this.content = content || this.content;
|
||||
this.extraClasses = extraClasses || this.extraClasses;
|
||||
},
|
||||
|
||||
render() {
|
||||
ui.showModal(this.title, [
|
||||
E('div', {}, this.getContent()),
|
||||
E('div', { class: 'd-flex justify-end mt-sm gap-5' }, this.getButtons()),
|
||||
], ...this.extraClasses);
|
||||
},
|
||||
|
||||
getContent() {
|
||||
return this.content;
|
||||
},
|
||||
|
||||
getButtons() {
|
||||
const buttons = [];
|
||||
|
||||
if (typeof this.handleClose === 'function') {
|
||||
buttons.push(this.getCloseButton());
|
||||
}
|
||||
|
||||
if (typeof this.handleSubmit === 'function') {
|
||||
buttons.push(this.getOkButton());
|
||||
}
|
||||
|
||||
return buttons;
|
||||
},
|
||||
|
||||
async handleClose() {
|
||||
ui.hideModal();
|
||||
},
|
||||
|
||||
async handleSubmit() {
|
||||
ui.hideModal();
|
||||
},
|
||||
|
||||
getOkButton() {
|
||||
return new UIButtonNew(_('OK'), {
|
||||
click: ui.createHandlerFn(this, 'handleSubmit'),
|
||||
type: 'positive',
|
||||
}).render();
|
||||
},
|
||||
|
||||
getCloseButton() {
|
||||
return new UIButtonNew(_('Close'), {
|
||||
click: ui.createHandlerFn(this, 'handleClose'),
|
||||
type: 'negative',
|
||||
}).render();
|
||||
},
|
||||
});
|
||||
|
||||
const PodmanUI = UIBase.extend({
|
||||
__name__: 'Podman.UI',
|
||||
|
||||
Modal: UIModal,
|
||||
|
||||
Button: UIButton,
|
||||
ButtonNew: UIButtonNew,
|
||||
// MultiButton: UIMultiButton,
|
||||
|
||||
Numberfield: UINumberfield,
|
||||
Datefield: UIDatefield,
|
||||
|
||||
SecretText: UISecretText,
|
||||
|
||||
JsonArea: UIJsonArea,
|
||||
BashCodeArea: UIBashCodeArea,
|
||||
|
||||
Table: UITable,
|
||||
TableList: UITableList,
|
||||
Tabs: UITabs,
|
||||
Tooltip: UITooltip,
|
||||
|
||||
StreamLog: UIStreamLog,
|
||||
});
|
||||
|
||||
return PodmanUI;
|
||||
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
|
||||
const UtilIpv6 = baseclass.extend({
|
||||
/**
|
||||
* Derive ULA IPv6 subnet and gateway from IPv4 subnet.
|
||||
* Uses IPv4 octets 3-4 as subnet ID for consistent IPv4/IPv6 mapping.
|
||||
* @param {string} ipv4 - IPv4 subnet in CIDR (e.g., "192.168.20.0/24")
|
||||
* @param {string} ula_prefix - ULA prefix (e.g., "fd52:425:78eb::/48")
|
||||
* @returns {{ipv6subnet: string, ipv6gateway: string}} IPv6 config with subnet/gateway
|
||||
*/
|
||||
deriveUlaFromIpv4(ipv4, ula_prefix) {
|
||||
const ipv4Address = ipv4.split('/')[0];
|
||||
const octets = ipv4Address.split('.').map(Number);
|
||||
const subnetIdHex = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, '0');
|
||||
const ulaAddress = ula_prefix.split('/')[0];
|
||||
const ulaParts = ulaAddress.split('::');
|
||||
|
||||
let hextets = ulaParts[0].split(':');
|
||||
if (hextets.length === 1 && hextets[0] === "") {
|
||||
hextets = [];
|
||||
}
|
||||
|
||||
while (hextets.length < 3) {
|
||||
hextets.push('0');
|
||||
}
|
||||
|
||||
const ipv6SubnetAddress = `${hextets.slice(0, 3).join(':')}:${subnetIdHex}::`;
|
||||
|
||||
return {
|
||||
ipv6subnet: `${ipv6SubnetAddress}/64`,
|
||||
ipv6gateway: `${ipv6SubnetAddress}1`
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const UtilIpv4 = baseclass.extend({
|
||||
// >>> 0 forces unsigned 32-bit interpretation (JS bitwise ops are signed)
|
||||
_ipToInt(octets) {
|
||||
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
||||
},
|
||||
|
||||
_intToIp(n) {
|
||||
return [(n >>> 24) & 0xFF, (n >>> 16) & 0xFF, (n >>> 8) & 0xFF, n & 0xFF].join('.');
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert a CIDR string to a start/end IP range.
|
||||
* Works for both network addresses (10.89.0.0/24) and host addresses
|
||||
* within a subnet (10.89.0.5/24) - the mask normalises either to the
|
||||
* full network range.
|
||||
* @param {string} cidr - CIDR notation (e.g., "10.89.0.128/25")
|
||||
* @returns {{start_ip: string, end_ip: string}|null} Range or null if invalid
|
||||
*/
|
||||
cidrToRange(cidr) {
|
||||
if (!cidr || typeof cidr !== 'string') return null;
|
||||
|
||||
const parts = cidr.split('/');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const octets = parts[0].split('.').map(Number);
|
||||
const prefixLen = parseInt(parts[1], 10);
|
||||
|
||||
if (octets.length !== 4 || octets.some(o => isNaN(o) || o < 0 || o > 255)) return null;
|
||||
if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) return null;
|
||||
|
||||
const ipInt = this._ipToInt(octets);
|
||||
const mask = prefixLen === 0 ? 0 : (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
|
||||
|
||||
return {
|
||||
start_ip: this._intToIp((ipInt & mask) >>> 0),
|
||||
end_ip: this._intToIp((ipInt | (~mask >>> 0)) >>> 0),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the first usable host address in a subnet (network address + 1).
|
||||
* Conventionally used as the gateway address.
|
||||
* @param {string} cidr - CIDR notation (e.g., "10.89.0.0/24")
|
||||
* @returns {string|null} First host IP (e.g., "10.89.0.1") or null if invalid
|
||||
*/
|
||||
firstHost(cidr) {
|
||||
const range = this.cidrToRange(cidr);
|
||||
if (!range) return null;
|
||||
|
||||
const octets = range.start_ip.split('.').map(Number);
|
||||
const next = (this._ipToInt(octets) + 1) >>> 0;
|
||||
|
||||
return this._intToIp(next);
|
||||
},
|
||||
});
|
||||
|
||||
const UtilFormat = baseclass.extend({
|
||||
__name__: 'Podman.Util.Format',
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size
|
||||
*/
|
||||
bytes(bytes, decimals) {
|
||||
if (!bytes) return '0 B';
|
||||
|
||||
decimals = decimals ?? 2;
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / k ** i).toFixed(decimals)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
/**
|
||||
* Format timestamp to locale string with leading zeros
|
||||
*/
|
||||
date(timestamp) {
|
||||
if (!timestamp) {
|
||||
return _('Never');
|
||||
}
|
||||
|
||||
let date;
|
||||
|
||||
if (typeof timestamp === 'string') {
|
||||
if (timestamp === '0001-01-01T00:00:00Z' || timestamp.startsWith('0001-')) {
|
||||
return _('Never');
|
||||
}
|
||||
date = new Date(timestamp);
|
||||
} else {
|
||||
date = new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return _('Unknown');
|
||||
}
|
||||
|
||||
if (date.getFullYear() < 1970) {
|
||||
return _('Never');
|
||||
}
|
||||
|
||||
const day = ('0' + date.getDate()).slice(-2);
|
||||
const month = ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
const year = date.getFullYear();
|
||||
const hours = ('0' + date.getHours()).slice(-2);
|
||||
const minutes = ('0' + date.getMinutes()).slice(-2);
|
||||
const seconds = ('0' + date.getSeconds()).slice(-2);
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse memory string to bytes
|
||||
* Supports formats: 512m, 1g, 2gb, 1024, etc.
|
||||
* @param {string} memStr - Memory string (e.g., "512m", "1g", "2gb")
|
||||
* @returns {number} Size in bytes
|
||||
*/
|
||||
parseMemory(memStr) {
|
||||
if (!memStr) return 0;
|
||||
|
||||
const match = memStr.match(/^(\d+(?:\.\d+)?)\s*([kmgt]?b?)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'b').toLowerCase();
|
||||
|
||||
const multipliers = {
|
||||
b: 1,
|
||||
k: 1024,
|
||||
kb: 1024,
|
||||
m: 1024 * 1024,
|
||||
mb: 1024 * 1024,
|
||||
g: 1024 * 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
t: 1024 * 1024 * 1024 * 1024,
|
||||
tb: 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse duration string to nanoseconds (Podman format)
|
||||
* Supports formats: 30s, 1m, 1h, 500ms, etc.
|
||||
* @param {string} duration - Duration string (e.g., "30s", "1m", "1h")
|
||||
* @returns {number} Duration in nanoseconds, or 0 if invalid
|
||||
*/
|
||||
parseDuration(duration) {
|
||||
if (!duration) return 0;
|
||||
|
||||
const match = duration.match(/^(\d+(?:\.\d+)?)\s*(ns|us|ms|s|m|h)$/);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
const multipliers = {
|
||||
ns: 1,
|
||||
us: 1000,
|
||||
ms: 1000000,
|
||||
s: 1000000000,
|
||||
m: 60000000000,
|
||||
h: 3600000000000
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 0));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared utility functions.
|
||||
*/
|
||||
return baseclass.extend({
|
||||
ipv4: new UtilIpv4(),
|
||||
|
||||
ipv6: new UtilIpv6(),
|
||||
|
||||
format: new UtilFormat(),
|
||||
|
||||
truncate(str, maxLength) {
|
||||
if (!str || str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.substring(0, maxLength) + '...';
|
||||
},
|
||||
|
||||
moveArrayItem(arr, fromIndex, toIndex) {
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
},
|
||||
|
||||
_p(stringPlural) {
|
||||
return this._n(2, '', stringPlural);
|
||||
},
|
||||
|
||||
_n(length, stringSingular, stringPlural) {
|
||||
return parseInt(length) === 1 ?
|
||||
stringSingular : stringPlural;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require view';
|
||||
'require form';
|
||||
'require dom';
|
||||
'require ui';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.constants as constant';
|
||||
|
||||
const ViewDefault = {
|
||||
scrollToTop() {
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
},
|
||||
|
||||
loading(text) {
|
||||
this.scrollToTop();
|
||||
podmanUI.showSpinningModal(null, text || _('Loading...'));
|
||||
},
|
||||
|
||||
confirm(content, onConfirm) {
|
||||
const modal = new podmanUI.Modal(_('Confirm'), content, [ 'confirm-modal' ]);
|
||||
modal.getCloseButton = () => new podmanUI.ButtonNew(_('Cancel'), {
|
||||
click: () => ui.hideModal(),
|
||||
type: 'negative',
|
||||
}).render();
|
||||
modal.handleSubmit = onConfirm;
|
||||
modal.render();
|
||||
},
|
||||
|
||||
error(message) {
|
||||
this.scrollToTop();
|
||||
ui.hideModal();
|
||||
podmanUI.alert(message, 'error');
|
||||
},
|
||||
|
||||
warning(message) {
|
||||
this.scrollToTop();
|
||||
ui.hideModal();
|
||||
podmanUI.alert(message, 'warning', true);
|
||||
},
|
||||
|
||||
success(message) {
|
||||
this.scrollToTop();
|
||||
ui.hideModal();
|
||||
podmanUI.alert(message, 'success', true);
|
||||
},
|
||||
};
|
||||
|
||||
const ViewBase = view.extend({
|
||||
...ViewDefault,
|
||||
__name__: 'PodmanView.Base',
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
__init__() {
|
||||
document.querySelector('head').appendChild(E('link', {
|
||||
rel: 'stylesheet',
|
||||
type: 'text/css',
|
||||
href: L.resource('view/podman/podman.css'),
|
||||
}));
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') ui.hideModal();
|
||||
});
|
||||
|
||||
this.super('__init__', []);
|
||||
},
|
||||
});
|
||||
|
||||
const ViewList = ViewBase.extend({
|
||||
__name__: 'Podman.View.List',
|
||||
|
||||
map: null,
|
||||
section: null,
|
||||
title: '',
|
||||
sectionName: '',
|
||||
sectionHeadline: '',
|
||||
TableSelectSection: null,
|
||||
|
||||
async render(data) {
|
||||
const listData = {};
|
||||
listData[this.sectionName] = data;
|
||||
|
||||
await this.initSection(listData);
|
||||
await this.updateSection();
|
||||
|
||||
return this.transformMap(await this.map.render());
|
||||
},
|
||||
|
||||
transformMap(map) {
|
||||
return map;
|
||||
},
|
||||
|
||||
initTableSelectSection(data) {
|
||||
this.map = new form.JSONMap(data, this.title);
|
||||
|
||||
this.section = this.map.section(
|
||||
this.TableSelectSection,
|
||||
this.sectionName,
|
||||
'',
|
||||
this.sectionHeadline || _('Manage podman %s').format(this.title.toLowerCase())
|
||||
);
|
||||
this.section.view = this;
|
||||
this.section.anonymous = true;
|
||||
this.section.footer = true;
|
||||
|
||||
return this.section;
|
||||
},
|
||||
|
||||
async initSection(data) {
|
||||
if (!this.TableSelectSection) {
|
||||
// Require on-the-fly to avoid circular dependency
|
||||
const mod = await L.require('podman.TableSelectSection');
|
||||
this.TableSelectSection = mod.TableSelectSection;
|
||||
}
|
||||
return this.initTableSelectSection(data);
|
||||
},
|
||||
|
||||
getSection() {
|
||||
return this.section;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Form View
|
||||
*/
|
||||
const ViewForm = baseclass.extend({
|
||||
...ViewDefault,
|
||||
__name__: 'Podman.View.Form',
|
||||
|
||||
map: null,
|
||||
section: null,
|
||||
|
||||
__init__() {
|
||||
|
||||
},
|
||||
|
||||
makeData() {
|
||||
return {};
|
||||
},
|
||||
|
||||
async render() {
|
||||
const data = this.makeData();
|
||||
const sectionName = Object.keys(data)[0];
|
||||
this.map = new form.JSONMap(data, '', '');
|
||||
this.section = this.map.section(form.TypedSection, sectionName, '');
|
||||
this.section.anonymous = true;
|
||||
|
||||
await this.createForm();
|
||||
|
||||
return this.map.render();
|
||||
},
|
||||
|
||||
createForm() {},
|
||||
|
||||
getField(name) {
|
||||
return this.section?.getOption(name);
|
||||
},
|
||||
|
||||
getFields() {
|
||||
return this.section?.getOption() || {};
|
||||
},
|
||||
|
||||
getFieldValue(name) {
|
||||
return this.getField(name)?.formvalue(this.section?.sectiontype);
|
||||
},
|
||||
|
||||
getFieldValues() {
|
||||
const values = {};
|
||||
const fields = this.getFields();
|
||||
|
||||
Object.keys(fields).forEach((name) => {
|
||||
values[name] = fields[name].formvalue(this.section?.sectiontype);
|
||||
});
|
||||
|
||||
return values;
|
||||
},
|
||||
|
||||
isValid() {
|
||||
const fields = this.getFields();
|
||||
const sectionId = this.section.sectiontype;
|
||||
|
||||
for (const [_key, field] of Object.entries(fields)) {
|
||||
if (field.isValid(sectionId) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
scrollToInvalid() {
|
||||
const invalidElement = this.map.root.querySelector('.cbi-input-invalid');
|
||||
invalidElement.scrollIntoView();
|
||||
},
|
||||
|
||||
async save() {
|
||||
try {
|
||||
await this.map.save(() => {}, true);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
async handleCreate(createFn, title, textLoading, textSuccess) {
|
||||
ui.hideModal();
|
||||
|
||||
const loadingText = textLoading || _('Creating %s').format(title);
|
||||
const successText = textSuccess || _('%s created successfully').format(title);
|
||||
|
||||
this.loading(loadingText);
|
||||
|
||||
await createFn();
|
||||
ui.hideModal();
|
||||
if (successText !== false) {
|
||||
this.success(successText);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const ViewTabs = ViewBase.extend({
|
||||
__name__: 'Podman.View.Tabs',
|
||||
tabs: null,
|
||||
|
||||
__init__() {
|
||||
this.tabs = new podmanUI.Tabs('info');
|
||||
this.super('__init__', []);
|
||||
},
|
||||
|
||||
renderTab(tab, content, description) {
|
||||
const tabContainer = document.querySelector(`.tab-pane[data-tab="${tab}"]`);
|
||||
const tabContainerNode = tabContainer?.querySelector('.cbi-section-node');
|
||||
|
||||
if (!tabContainerNode) return;
|
||||
|
||||
if (description) {
|
||||
tabContainer.insertBefore(E('div', {
|
||||
class: 'cbi-section-descr'
|
||||
}, description), tabContainer.firstChild);
|
||||
}
|
||||
|
||||
dom.content(tabContainerNode, content);
|
||||
},
|
||||
|
||||
getTabInstance(name) {
|
||||
const tabNode = document.querySelector(`.tab-pane[data-tab="${name}"]`);
|
||||
return tabNode ? dom.findClassInstance(tabNode) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const ViewContainer = ViewTabs.extend({
|
||||
__name__: 'Podman.View.Container',
|
||||
listUrl: '#',
|
||||
|
||||
async render() {
|
||||
window.addEventListener('pagehide', () => this.stopStreams(), { once: true });
|
||||
|
||||
return E('div', {}, [ this.createHeader(), this.tabs.render() ]);
|
||||
},
|
||||
|
||||
createHeader(name, isRunning, isStopped, isPaused) {
|
||||
return E('div', { class: 'mb-sm container-toolbar' }, [
|
||||
E('div', { class: 'd-flex align-start' }, [
|
||||
E('h2', { class: 'mb-sm' }, [ name ]),
|
||||
new podmanUI.ButtonNew(constant.ICON.BACK, {
|
||||
click: () => this.redirectToList(),
|
||||
type: 'none',
|
||||
}).render(),
|
||||
]),
|
||||
E('div', { class: 'd-flex align-center' }, [
|
||||
new podmanUI.ButtonNew(constant.ICON.START, {
|
||||
click: ui.createHandlerFn(this, 'handleStart'),
|
||||
type: isRunning ? 'active' : '',
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(constant.ICON.STOP, {
|
||||
click: ui.createHandlerFn(this, 'handleStop'),
|
||||
type: isStopped ? 'active' : '',
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(constant.ICON.RESTART, {
|
||||
click: ui.createHandlerFn(this, 'handleRestart'),
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(constant.ICON.PAUSE, {
|
||||
click: ui.createHandlerFn(this, 'handlePause'),
|
||||
type: isPaused ? 'active' : '',
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(_('Delete'), {
|
||||
click: ui.createHandlerFn(this, 'handleRemove'),
|
||||
type: 'negative',
|
||||
}).render(),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
redirectToList() {
|
||||
this.stopStreams();
|
||||
window.location.href = this.listUrl;
|
||||
},
|
||||
|
||||
stopStreams() {
|
||||
},
|
||||
});
|
||||
|
||||
const ViewTabContent = baseclass.extend({
|
||||
...ViewDefault,
|
||||
__name__: 'PodmanView.TabContent',
|
||||
tab: null,
|
||||
active: false,
|
||||
|
||||
__init__(tab) {
|
||||
this.tab = tab || this.tab;
|
||||
this.super('__init__', []);
|
||||
|
||||
if (!this.tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
if (attempts++ > 100) { reject(); return; }
|
||||
const element = document.querySelector(`[data-tab="${this.tab}"]`);
|
||||
if (element) resolve(element);
|
||||
else setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
}).then(() => {
|
||||
const mainNode = document.querySelector(`.tab-pane[data-tab="${this.tab}"]`);
|
||||
|
||||
dom.bindClassInstance(mainNode, this);
|
||||
|
||||
const mutationObserver = new MutationObserver((mutationsList) => {
|
||||
let handled = false;
|
||||
mutationsList.forEach((mutation) => {
|
||||
const setActive = mutation.target.dataset.tabActive === 'true';
|
||||
|
||||
if (handled || mutation.attributeName !== 'data-tab-active') return;
|
||||
handled = true;
|
||||
|
||||
if (this.active === setActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (setActive) {
|
||||
this.active = true;
|
||||
this.onTabActive();
|
||||
return;
|
||||
}
|
||||
|
||||
this.active = false;
|
||||
this.onTabInactive();
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(mainNode, { attributes: true });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
async onTabActive() { },
|
||||
|
||||
async onTabInactive() { },
|
||||
|
||||
renderTabContent(headline, content) {
|
||||
return E('div', {}, [
|
||||
E('h4', {}, headline),
|
||||
...(content || []),
|
||||
]);
|
||||
},
|
||||
|
||||
warningContent(message) {
|
||||
return E('div', { class: 'alert-message d-flex justify-center p-sm' }, message);
|
||||
},
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
base: ViewBase,
|
||||
list: ViewList,
|
||||
form: ViewForm,
|
||||
container: ViewContainer,
|
||||
tabContent: ViewTabContent,
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as utils';
|
||||
|
||||
'require podman.model.Container as Container';
|
||||
|
||||
const CENSORED_VALUE = '••••••••';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'info',
|
||||
|
||||
container: null,
|
||||
initScriptStatus: '',
|
||||
|
||||
async render(container) {
|
||||
this.container = container;
|
||||
|
||||
const sections = await Promise.all([
|
||||
this.basicSection(),
|
||||
this.configSection(),
|
||||
this.networkSection(),
|
||||
this.envSection(),
|
||||
this.mountSection(),
|
||||
]);
|
||||
|
||||
this.updateInitScriptIcon(sections[0]);
|
||||
|
||||
return sections;
|
||||
},
|
||||
|
||||
basicSection() {
|
||||
const basicTable = new podmanUI.TableList();
|
||||
|
||||
const editableNameField = new ui.Textfield(this.container.getName(), { name: 'container-name' }).render();
|
||||
const editableName = new podmanForm.EditableField('container-name', editableNameField);
|
||||
editableName.onSubmit = (value) => this.handleUpdateName(value);
|
||||
|
||||
const policies = {
|
||||
'no': _('No'),
|
||||
'always': _('Always'),
|
||||
'on-failure': _('On Failure'),
|
||||
'unless-stopped': _('Unless Stopped')
|
||||
};
|
||||
const currentPolicy = this.container.getRestartPolicyName();
|
||||
const editableRestartField = new ui.Select(currentPolicy, policies, { name: 'container-restart' }).render();
|
||||
const editableRestart = new podmanForm.EditableField('container-restart', editableRestartField);
|
||||
editableRestart.onSubmit = (value) => this.handleUpdateRestartPolicy(value);
|
||||
|
||||
basicTable
|
||||
.addRow(_('Name'), editableName.render())
|
||||
.addRow(_('ID'), this.container.getID().substring(0, 64))
|
||||
.addRow(_('Image'), new podmanUI.Tooltip(this.container.getImageName(), this.container.Image, { class: 'tooltip' }).render())
|
||||
.addRow(_('Tag'), this.container.getImageTag())
|
||||
.addRow(_('Status'), this.container.getStateBadge())
|
||||
.addRow(_('Created'), this.container.getCreated(true))
|
||||
.addRow(_('Started'), this.container.getStartedAt(true))
|
||||
.addRow(_('Restart Policy'), editableRestart.render())
|
||||
.addRow(_('Auto Update'), this.container.getAutoUpdateLabel() || _('Disabled'))
|
||||
.addRow(_('Init Script'), E('div', {
|
||||
class: `init-script-form align-center ${this.container.getRestartPolicyName() === 'no' ? 'd-none' : 'd-flex'}`
|
||||
}, [
|
||||
new podmanUI.Tooltip(
|
||||
new podmanUI.ButtonNew(
|
||||
this.container.getAutoStartStatusIcon(),
|
||||
{
|
||||
click: ui.createHandlerFn(this, 'handleToggleInitScript'),
|
||||
}
|
||||
).render(),
|
||||
_('Click to enable/disable'),
|
||||
{
|
||||
class: 'cursor-pointer',
|
||||
}
|
||||
).render(),
|
||||
|
||||
new podmanUI.ButtonNew(_('Show'), {
|
||||
type: 'none',
|
||||
click: ui.createHandlerFn(this, 'handleShowInitScript'),
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(_('Generate'), {
|
||||
type: 'none',
|
||||
click: ui.createHandlerFn(this, 'handleGenerateInitScript'),
|
||||
}).render(),
|
||||
]))
|
||||
;
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Basic Information')),
|
||||
basicTable.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
configSection() {
|
||||
const configTable = new podmanUI.TableList();
|
||||
const cmd = this.container.getCmdString() || '-';
|
||||
const entrypoint = this.container.getEntrypointString() || '-';
|
||||
const createCommand = this.container.getCreateCommandString() || '-';
|
||||
const createCommandField = new podmanUI.SecretText(createCommand, CENSORED_VALUE);
|
||||
|
||||
configTable
|
||||
.addRow(_('Create Command'), createCommandField.render())
|
||||
.addRow(_('Command'), cmd)
|
||||
.addRow(_('Entrypoint'), entrypoint)
|
||||
.addRow(_('Working Directory'), this.container.getWorkingDir())
|
||||
.addRow(_('User'), this.container.getUser())
|
||||
.addRow(_('Hostname'), this.container.getHostname())
|
||||
.addRow(_('Privileged'), this.container.getPrivileged() ? _('Yes') : _('No'))
|
||||
.addRow(_('TTY'), this.container.getTty() ? _('Yes') : _('No'))
|
||||
.addRow(_('Interactive'), this.container.getInteractive() ? _('Yes') : _('No'))
|
||||
;
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Configuration')),
|
||||
configTable.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
async networkSection () {
|
||||
const table = new podmanUI.TableList();
|
||||
const { NetworkMode, Links } = this.container.getHostConfig();
|
||||
const { Networks, IPAddress } = this.container.getNetworkSettings();
|
||||
|
||||
table.addRow(_('Network Mode'), NetworkMode || _('default'));
|
||||
|
||||
const systemNetworks = ['bridge', 'host', 'none', 'container', 'slirp4netns'];
|
||||
let userNetworks = [];
|
||||
|
||||
if (Networks && Object.keys(Networks).length > 0) {
|
||||
userNetworks = Object.keys(Networks).filter((netName) => !systemNetworks.includes(netName));
|
||||
|
||||
userNetworks.forEach((netName) => {
|
||||
const net = Networks[netName];
|
||||
const tooltip = (() => {
|
||||
if (!this.container.isRunning()) {
|
||||
return _('Container is not running');
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (net.IPAddress) parts.push(`IPv4: ${net.IPAddress}`);
|
||||
if (net.GlobalIPv6Address) parts.push(`IPv6: ${net.GlobalIPv6Address}`);
|
||||
else parts.push('IPv6: disabled');
|
||||
if (net.Gateway) parts.push(`Gateway: ${net.Gateway}`);
|
||||
if (net.MacAddress) parts.push(`MAC: ${net.MacAddress}`);
|
||||
return parts.join('\n');
|
||||
})();
|
||||
|
||||
const ipContent = net.GlobalIPv6Address
|
||||
? [net.IPAddress || '-', E('br'), net.GlobalIPv6Address]
|
||||
: net.IPAddress || '-';
|
||||
|
||||
table.addRow(
|
||||
new podmanUI.Tooltip(E('span', { class: 'tooltip' }, netName), tooltip).render(),
|
||||
E('div', { class: 'd-flex align-center' }, [
|
||||
E('span', {}, ipContent),
|
||||
new podmanUI.Tooltip('✖', _('Click to disconnect'), {
|
||||
class: 'text-error cursor-pointer container-icon-disconnect ml-sm border-0',
|
||||
click: () => this.handleNetworkDisconnect(netName),
|
||||
}).render(),
|
||||
])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (IPAddress && userNetworks.length === 0) {
|
||||
table.addRow(_('IP Address'), IPAddress);
|
||||
}
|
||||
|
||||
const podmanNetworks = await podmanRPC.networks.list();
|
||||
const networkOptions = Object.fromEntries(podmanNetworks.map(item => [item.name, item.name]));
|
||||
|
||||
const networkSelectWidget = new ui.Select([''], networkOptions, { name: 'container-connect-network', optional: false });
|
||||
const ipTextWidget = new ui.Textfield('', { name: 'container-connect-ip', placeholder: '192.168.1.100', datatype: 'ip4addr' });
|
||||
|
||||
table.addRow(_('Connect to'), [
|
||||
E('div', { class: 'd-flex align-center container-connect' }, [
|
||||
networkSelectWidget.render(),
|
||||
ipTextWidget.render(),
|
||||
new podmanUI.Button(_('Connect'), () => {
|
||||
const selectedNetwork = networkSelectWidget.getValue();
|
||||
const selectedIp = ipTextWidget.getValue();
|
||||
|
||||
networkSelectWidget.triggerValidation();
|
||||
ipTextWidget.triggerValidation();
|
||||
|
||||
if (!selectedNetwork || !ipTextWidget.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleNetworkConnect(selectedNetwork, selectedIp);
|
||||
}).render(),
|
||||
]),
|
||||
]);
|
||||
|
||||
table.addRow(_('Ports'), E('div', {},
|
||||
this.container.getPorts().map((port) => E('div', {}, port.string)),
|
||||
));
|
||||
|
||||
if (Links && Links.length > 0) {
|
||||
const linkNodes = Links.reduce((nodes, link, i) => {
|
||||
if (i > 0) nodes.push(E('br'));
|
||||
nodes.push(link);
|
||||
return nodes;
|
||||
}, []);
|
||||
table.addRow(_('Links'), linkNodes);
|
||||
}
|
||||
|
||||
return E('div', { class: 'networks' }, [
|
||||
E('h4', {}, _('Network')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
envSection() {
|
||||
const table = new podmanUI.Table();
|
||||
table
|
||||
.addHeader(_('Variable'), { style: 'width: 50%;' })
|
||||
.addHeader(_('Value'), { style: 'width: 50%;' });
|
||||
|
||||
this.container.getEnvironmentVars().forEach((env) => {
|
||||
const parts = env.split('=');
|
||||
const varName = parts[0];
|
||||
const varValue = parts.slice(1).join('=');
|
||||
const valueField = new podmanUI.SecretText(varValue, CENSORED_VALUE);
|
||||
|
||||
table.addRow([{
|
||||
inner: varName
|
||||
},
|
||||
{
|
||||
inner: valueField.render(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Environment Variables')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
mountSection() {
|
||||
const table = new podmanUI.Table();
|
||||
table
|
||||
.addHeader(_('Type'), { style: 'width: 10%;' })
|
||||
.addHeader(_('Source'), { style: 'width: 35%;' })
|
||||
.addHeader(_('Destination'), { style: 'width: 35%;' })
|
||||
.addHeader(_('Mode'), { style: 'width: 10%;' })
|
||||
;
|
||||
|
||||
const truncatedField = (value) => {
|
||||
const v = value || '-';
|
||||
return v.length > 50 ? new podmanUI.Tooltip(utils.truncate(v, 50), v).render() : v;
|
||||
};
|
||||
|
||||
this.container.getMounts().forEach((mount) => {
|
||||
const mountSourceField = truncatedField(mount.Source);
|
||||
const mountDestField = truncatedField(mount.Destination);
|
||||
|
||||
table.addRow([
|
||||
{ inner: mount.Type || '-' },
|
||||
{ inner: mountSourceField },
|
||||
{ inner: mountDestField },
|
||||
{ inner: mount.RW ? 'rw' : 'ro' },
|
||||
]);
|
||||
})
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Mounts')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
async _refreshContainer() {
|
||||
const inspectData = await this.container.inspect();
|
||||
this.container = Container.getSingleton(inspectData);
|
||||
},
|
||||
|
||||
async _refreshNetworkSection() {
|
||||
const networkSection = await this.networkSection();
|
||||
document.querySelector('.networks').replaceWith(networkSection);
|
||||
},
|
||||
|
||||
async updateInitScriptIcon(infoSection) {
|
||||
this.initScriptStatus = await this.container.checkInitScript();
|
||||
const initScriptIconElement = (infoSection || document).querySelector('.autostart-status');
|
||||
const initScriptIcon = this.container.getAutoStartStatusIcon(this.initScriptStatus);
|
||||
initScriptIconElement.replaceWith(initScriptIcon);
|
||||
},
|
||||
|
||||
async handleUpdateName(name) {
|
||||
if (!name || this.container.Name === name) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Update name'));
|
||||
|
||||
await this.container.updateName(name);
|
||||
|
||||
document.querySelector('.container-toolbar h2').textContent = name;
|
||||
this.success(_('Name updated successfully'));
|
||||
},
|
||||
|
||||
async handleUpdateRestartPolicy(policy) {
|
||||
this.loading(_('Update restart policy'));
|
||||
|
||||
const updateData = { RestartPolicy: policy };
|
||||
if (policy === 'on-failure') updateData.RestartRetries = 5;
|
||||
|
||||
await this.container.update(updateData);
|
||||
await this._refreshContainer();
|
||||
|
||||
const initScriptForm = document.querySelector('.init-script-form');
|
||||
if (policy === 'no') {
|
||||
initScriptForm.classList.replace('d-flex', 'd-none');
|
||||
} else {
|
||||
initScriptForm.classList.replace('d-none', 'd-flex');
|
||||
}
|
||||
|
||||
this.updateInitScriptIcon();
|
||||
|
||||
this.success(_('Restart policy updated successfully'));
|
||||
},
|
||||
|
||||
async handleNetworkConnect(networkName, ip) {
|
||||
this.loading(_('Connecting container to network'));
|
||||
|
||||
const params = {};
|
||||
if (ip) params.static_ips = [ip];
|
||||
|
||||
await this.container.connect(networkName, params);
|
||||
await this._refreshContainer();
|
||||
this.success(_('Connected successfully to %s').format(networkName));
|
||||
await this._refreshNetworkSection();
|
||||
},
|
||||
|
||||
async handleNetworkDisconnect(networkName) {
|
||||
this.loading(_('Disconnect container from network'));
|
||||
|
||||
await this.container.disconnect(networkName);
|
||||
await this._refreshContainer();
|
||||
this.success(_('Disconnected from %s').format(networkName));
|
||||
await this._refreshNetworkSection();
|
||||
},
|
||||
|
||||
async handleGenerateInitScript() {
|
||||
this.loading(_('Creating auto-start configuration for %s').format(this.container.getName()));
|
||||
|
||||
await this.container.generateInitScript();
|
||||
await this.container.enableInitScript();
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
async handleShowInitScript() {
|
||||
this.loading(_('Loading init script'));
|
||||
|
||||
const result = await this.container.showInitScript();
|
||||
ui.hideModal();
|
||||
|
||||
if (!result || !result.path || !result.content) return;
|
||||
|
||||
const modal = new podmanUI.Modal(result.path, [
|
||||
new podmanUI.BashCodeArea(result.content.replace(/\\n/g, '\n')).render()
|
||||
]);
|
||||
modal.handleSubmit = undefined;
|
||||
modal.render();
|
||||
},
|
||||
|
||||
async handleToggleInitScript() {
|
||||
if (this.initScriptStatus === 'missing') {
|
||||
return this.handleGenerateInitScript();
|
||||
} else if (this.initScriptStatus === 'enabled') {
|
||||
this.loading(_('Disabling init script'));
|
||||
await this.container.disableInitScript();
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
} else if (this.initScriptStatus === 'disabled') {
|
||||
this.loading(_('Enabling init script'));
|
||||
await this.container.enableInitScript();
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
const TIMESTAMP_RE_GLOBAL = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\s*/g;
|
||||
const ANSI_REGEX = /\x1B\[[0-9;]*[A-Za-z]/g;
|
||||
const MAX_LOG_LINES = 1000;
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'logs',
|
||||
container: null,
|
||||
|
||||
render(container) {
|
||||
this.container = container;
|
||||
|
||||
this.logViewer = E('pre', { id: 'log-viewer', class: 'terminal-area' }, []);
|
||||
|
||||
return this.renderTabContent('', [
|
||||
this.renderLogToolbar(),
|
||||
this.logViewer,
|
||||
]);
|
||||
},
|
||||
|
||||
onTabActive() {
|
||||
// Running: auto-start live stream. Stopped: load historical logs on demand via play button.
|
||||
if (this.container.isRunning()) {
|
||||
this.startStream();
|
||||
}
|
||||
},
|
||||
|
||||
onTabInactive() {
|
||||
this.stopStream();
|
||||
},
|
||||
|
||||
_setStreamActive(active) {
|
||||
this.logLinesInput.disabled = active;
|
||||
this.logSinceInput.disabled = active;
|
||||
this.logUntilInput.disabled = active;
|
||||
this.playButton.querySelector('.cbi-button').classList.toggle('cbi-button-active', active);
|
||||
this.stopButton.querySelector('.cbi-button').classList.toggle('cbi-button-active', !active);
|
||||
},
|
||||
|
||||
startStream() {
|
||||
if (!this.container || this.logsStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setStreamActive(true);
|
||||
|
||||
this.logViewer.textContent = '';
|
||||
|
||||
const toUnixSince = (val) => val ? new Date(val + 'T00:00:00').getTime() / 1000 : null;
|
||||
const toUnixUntil = (val) => val ? new Date(val + 'T23:59:59').getTime() / 1000 : null;
|
||||
|
||||
this.logsStream = this.container.streamLogs((data) => {
|
||||
if (!data || !data.raw) return;
|
||||
|
||||
const line = data.raw
|
||||
.replace(TIMESTAMP_RE_GLOBAL, '')
|
||||
.replace(ANSI_REGEX, '')
|
||||
.trim();
|
||||
|
||||
if (!line) return;
|
||||
|
||||
this.logViewer.textContent += line + '\n';
|
||||
this.trimLogOutput();
|
||||
this.logViewer.scrollTop = this.logViewer.scrollHeight;
|
||||
}, {
|
||||
tail: this.logLinesInput.value || 100,
|
||||
since: toUnixSince(this.logSinceInput.value),
|
||||
until: toUnixUntil(this.logUntilInput.value),
|
||||
follow: this.container.isRunning(),
|
||||
});
|
||||
},
|
||||
|
||||
stopStream() {
|
||||
if (!this.logsStream) return;
|
||||
|
||||
this._setStreamActive(false);
|
||||
|
||||
this.logsStream.stop();
|
||||
this.logsStream = null;
|
||||
},
|
||||
|
||||
renderLogToolbar() {
|
||||
// Fields start disabled only for running containers (stream auto-starts and locks them).
|
||||
// For stopped containers they start enabled so the user can set filters before fetching.
|
||||
const initialDisabled = this.container.isRunning();
|
||||
const linesField = new podmanUI.Numberfield(100, { id: 'log-lines', min: '10', max: '250', disabled: initialDisabled }).render();
|
||||
const sinceField = new podmanUI.Datefield(null, { id: 'log-since', disabled: initialDisabled }).render();
|
||||
const untilField = new podmanUI.Datefield(null, { id: 'log-until', disabled: initialDisabled }).render();
|
||||
|
||||
this.logLinesInput = linesField.querySelector('input');
|
||||
this.logSinceInput = sinceField.querySelector('input');
|
||||
this.logUntilInput = untilField.querySelector('input');
|
||||
|
||||
this.stopButton = new podmanUI.ButtonNew('◼', {
|
||||
click: () => this.stopStream(),
|
||||
type: ' stop-button',
|
||||
tooltip: _('Stop stream'),
|
||||
}).render();
|
||||
|
||||
this.playButton = new podmanUI.ButtonNew('►', {
|
||||
click: () => this.startStream(),
|
||||
type: 'active play-button',
|
||||
tooltip: _('Start stream'),
|
||||
}).render();
|
||||
|
||||
return E('div', { class: 'd-flex align-center mb-sm' }, [
|
||||
E('label', { class: 'mr-xs' }, _('Number of lines')),
|
||||
linesField,
|
||||
'',
|
||||
E('label', { class: 'ml-xs mr-xs' }, _('Since')),
|
||||
sinceField,
|
||||
|
||||
E('label', { class: 'ml-xs mr-xs' }, _('Until')),
|
||||
untilField,
|
||||
|
||||
E('div', { class: 'ml-xs' }, ''),
|
||||
|
||||
new podmanUI.ButtonNew('🗑️', {
|
||||
click: () => this.logViewer.textContent = '',
|
||||
tooltip: _('Clear logs'),
|
||||
}).render(),
|
||||
this.stopButton,
|
||||
this.playButton,
|
||||
]);
|
||||
},
|
||||
|
||||
trimLogOutput() {
|
||||
const lines = this.logViewer.textContent.split('\n');
|
||||
if (lines.length > MAX_LOG_LINES) {
|
||||
this.logViewer.textContent = lines.slice(lines.length - MAX_LOG_LINES).join('\n');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'ps',
|
||||
container: null,
|
||||
|
||||
render(container) {
|
||||
this.container = container;
|
||||
|
||||
if (!this.container.isRunning()) {
|
||||
return this.warningContent(_('Container is not running'));
|
||||
}
|
||||
|
||||
this.tableContent = E('div', { class: 'ps-table-content' }, []);
|
||||
|
||||
return this.renderTabContent('', [
|
||||
this.tableContent,
|
||||
]);
|
||||
},
|
||||
|
||||
onTabActive() {
|
||||
if (!this.container || !this.container.isRunning() || this.processStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processStream = this.container.streamTop((ps) => {
|
||||
this.updateProcessList(ps);
|
||||
});
|
||||
},
|
||||
|
||||
onTabInactive() {
|
||||
if (!this.processStream) return;
|
||||
|
||||
this.processStream.stop();
|
||||
this.processStream = null;
|
||||
},
|
||||
|
||||
updateProcessList(ps) {
|
||||
if (!ps || !ps.Titles || !ps.Processes || ps.Titles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const psTable = new podmanUI.Table();
|
||||
const columnWidth = `width: ${100 / ps.Titles.length}%;`;
|
||||
const timeIndex = ps.Titles.indexOf('ELAPSED');
|
||||
|
||||
ps.Titles.forEach((title) => {
|
||||
psTable.addHeader(_(title), { style: columnWidth });
|
||||
});
|
||||
|
||||
ps.Processes.forEach((process) => {
|
||||
psTable.addRow(process.map((detail, i) => ({
|
||||
inner: i === timeIndex ? this._formatElapsedTime(detail) : detail
|
||||
})));
|
||||
});
|
||||
|
||||
dom.content(this.tableContent, psTable.render());
|
||||
},
|
||||
|
||||
_formatElapsedTime(timeStr) {
|
||||
if (!timeStr) return '-';
|
||||
|
||||
const result = [];
|
||||
const pattern = /(\d+(?:\.\d+)?)([ydhms])/g;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(timeStr)) !== null) {
|
||||
let value = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 's' && value > 1000) {
|
||||
value = Math.floor(value / 1000000000);
|
||||
|
||||
if (value >= 60) {
|
||||
const mins = Math.floor(value / 60);
|
||||
const secs = value % 60;
|
||||
if (mins > 0) result.push(`${mins}m`);
|
||||
if (secs > 0) result.push(`${secs}s`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
value = Math.floor(value);
|
||||
}
|
||||
|
||||
if (unit === 's' || unit === 'm') {
|
||||
result.push(String(value).padStart(2, '0') + unit);
|
||||
} else {
|
||||
result.push(value + unit);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result.join('') : timeStr;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.utils as podmanUtil';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'stats',
|
||||
container: null,
|
||||
|
||||
render(container) {
|
||||
this.container = container;
|
||||
|
||||
if (!this.container.isRunning()) {
|
||||
return this.warningContent(_('Container is not running'));
|
||||
}
|
||||
|
||||
return this.renderTabContent(_('Statistics'), [
|
||||
this.renderStatsTable(),
|
||||
]);
|
||||
},
|
||||
|
||||
onTabActive() {
|
||||
if (!this.container || !this.container.isRunning() || this.statsStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statsStream = this.container.streamStats((stats) => {
|
||||
this.updateStatsDisplay(stats);
|
||||
});
|
||||
},
|
||||
|
||||
onTabInactive() {
|
||||
if (!this.statsStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statsStream.stop();
|
||||
this.statsStream = null;
|
||||
},
|
||||
|
||||
renderStatsTable() {
|
||||
const table = new podmanUI.TableList();
|
||||
|
||||
table
|
||||
.addRow(_('CPU Usage'), '-', { 'data-stat': 'cpu' })
|
||||
.addRow(_('Memory Usage'), '-', { 'data-stat': 'memory' })
|
||||
.addRow(_('Memory Limit'), '-', { 'data-stat': 'memory-limit' })
|
||||
.addRow(_('Memory %'), '-', { 'data-stat': 'memory-percent' })
|
||||
.addRow(_('Network I/O'), '-', { 'data-stat': 'network-io' })
|
||||
.addRow(_('Block I/O'), '-', { 'data-stat': 'block-io' })
|
||||
.addRow(_('PIDs'), '-', { 'data-stat': 'pids' })
|
||||
;
|
||||
|
||||
return table.render();
|
||||
},
|
||||
|
||||
updateStatsDisplay(stats) {
|
||||
if (!stats) return;
|
||||
|
||||
if (!this.statElements) {
|
||||
this.statElements = {};
|
||||
for (const key of ['cpu', 'memory', 'memory-limit', 'memory-percent', 'network-io', 'block-io', 'pids']) {
|
||||
this.statElements[key] = document.querySelector(`[data-stat="${key}"] td:last-of-type`);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = [
|
||||
['cpu', stats.CPU != null ? stats.CPU.toFixed(2) + '%' : '-'],
|
||||
['memory', podmanUtil.format.bytes(stats.MemUsage) || '-'],
|
||||
['memory-limit', podmanUtil.format.bytes(stats.MemLimit) || '-'],
|
||||
['memory-percent', stats.MemPerc != null ? stats.MemPerc.toFixed(2) + '%' : '-'],
|
||||
['network-io', this._formatNetworkIO(stats.Network)],
|
||||
['block-io', this._formatBlockIO(stats.BlockInput, stats.BlockOutput)],
|
||||
['pids', stats.PIDs],
|
||||
];
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
if (this.statElements[key])
|
||||
dom.content(this.statElements[key], value);
|
||||
}
|
||||
},
|
||||
|
||||
_formatNetworkIO(networks) {
|
||||
const parts = Object.keys(networks || {}).map((iface) => {
|
||||
const net = networks[iface];
|
||||
return `${iface}: ↓ ${podmanUtil.format.bytes(net.RxBytes)} / ↑ ${podmanUtil.format.bytes(net.TxBytes)}`;
|
||||
});
|
||||
|
||||
if (parts.length === 0) return '-';
|
||||
|
||||
return parts.reduce((nodes, part, i) => {
|
||||
if (i > 0) nodes.push(E('br'));
|
||||
nodes.push(part);
|
||||
return nodes;
|
||||
}, []);
|
||||
},
|
||||
|
||||
_formatBlockIO(blockInput, blockOutput) {
|
||||
return _('Read: %s / Write: %s').format(
|
||||
podmanUtil.format.bytes(blockInput || 0),
|
||||
podmanUtil.format.bytes(blockOutput || 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
'require dom';
|
||||
|
||||
'require podman.constants as constant';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.model.Container as Container';
|
||||
'require podman.form.resource as PodmanFormResource';
|
||||
|
||||
'require view.podman.container-tab.info as ContainerInfoTab';
|
||||
'require view.podman.container-tab.stats as ContainerStatsTab';
|
||||
'require view.podman.container-tab.processes as ContainerProcessesTab';
|
||||
'require view.podman.container-tab.logs as ContainerLogsTab';
|
||||
|
||||
/**
|
||||
* Container detail view with tabbed interface
|
||||
*/
|
||||
return podmanView.container.extend({
|
||||
container: null,
|
||||
data: null,
|
||||
listUrl: L.url('admin/podman/containers'),
|
||||
|
||||
async load() {
|
||||
// Extract container ID from URL path
|
||||
// URL format: /cgi-bin/luci/admin/podman/container/<id>
|
||||
const path = L.location();
|
||||
const matches = path.match(/container\/([a-f0-9]+)/i);
|
||||
|
||||
if (!matches || !matches[1]) {
|
||||
this.redirectToList();
|
||||
return;
|
||||
}
|
||||
|
||||
const containerId = matches[1];
|
||||
const container = Container.getSingleton({ Id: containerId });
|
||||
|
||||
const inspectData = await container.inspect();
|
||||
this.data = inspectData;
|
||||
return Container.getSingleton(inspectData);
|
||||
},
|
||||
|
||||
async render(container) {
|
||||
if (!container) {
|
||||
this.redirectToList();
|
||||
return;
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
this.tabs
|
||||
.addTab('info', _('Info'))
|
||||
.addTab('resources', _('Resources'))
|
||||
.addTab('stats', _('Stats'))
|
||||
.addTab('ps', _('Processes'))
|
||||
.addTab('logs', _('Logs'))
|
||||
// .addTab('health', _('Health'))
|
||||
.addTab('inspect', _('Inspect'))
|
||||
// .addTab('console', _('Console'))
|
||||
;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.renderInfoTab();
|
||||
this.renderResourcesTab();
|
||||
this.renderStatsTab();
|
||||
this.renderProcessesTab();
|
||||
this.renderLogsTab();
|
||||
this.renderInspectTab();
|
||||
});
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
createHeader() {
|
||||
return this.super('createHeader', [
|
||||
this.container.getName(),
|
||||
this.container.isRunning(),
|
||||
this.container.isStopped(),
|
||||
this.container.isPaused(),
|
||||
]);
|
||||
},
|
||||
|
||||
stopStreams() {
|
||||
this.getTabInstance('stats')?.onTabInactive();
|
||||
this.getTabInstance('ps')?.onTabInactive();
|
||||
this.getTabInstance('logs')?.onTabInactive();
|
||||
},
|
||||
|
||||
async renderInfoTab() {
|
||||
const content = await ContainerInfoTab.render(this.container);
|
||||
this.renderTab('info', content);
|
||||
},
|
||||
|
||||
async renderResourcesTab() {
|
||||
const resourceForm = await new PodmanFormResource.init().render(this.container);
|
||||
this.renderTab('resources', resourceForm, _('Configure resource limits for this container.'));
|
||||
},
|
||||
|
||||
async renderStatsTab() {
|
||||
const content = await ContainerStatsTab.render(this.container);
|
||||
this.renderTab('stats', content);
|
||||
},
|
||||
|
||||
async renderProcessesTab() {
|
||||
const content = await ContainerProcessesTab.render(this.container);
|
||||
this.renderTab('ps', content);
|
||||
},
|
||||
|
||||
async renderLogsTab() {
|
||||
const content = await ContainerLogsTab.render(this.container);
|
||||
this.renderTab('logs', content);
|
||||
},
|
||||
|
||||
renderInspectTab() {
|
||||
this.renderTab('inspect', new podmanUI.JsonArea(this.data).render());
|
||||
},
|
||||
|
||||
async handleStart() {
|
||||
if (this.container.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Start container'));
|
||||
|
||||
if (this.container.isPaused()) {
|
||||
this.container.unpause().then(() => window.location.reload());
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.start().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleStop() {
|
||||
if (this.container.isStopped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Stop container'));
|
||||
this.stopStreams();
|
||||
this.container.stop().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleRestart() {
|
||||
this.loading(_('Restart container'));
|
||||
this.stopStreams();
|
||||
this.container.restart().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handlePause() {
|
||||
if (!this.container.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Pause container'));
|
||||
this.stopStreams();
|
||||
this.container.pause().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleRemove() {
|
||||
this.confirm([
|
||||
E('p', {}, _('Are you sure to remove container?')),
|
||||
], async () => {
|
||||
this.loading(_('Remove container'));
|
||||
this.container.remove().then(() => this.redirectToList());
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
'require ui';
|
||||
|
||||
'require podman.constants as constant';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as utils';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form.container as PodmanFormContainer';
|
||||
|
||||
/**
|
||||
* Manage podman containers
|
||||
*/
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'containers',
|
||||
|
||||
title: _('Containers'),
|
||||
titleSingle: _('Container'),
|
||||
|
||||
async load() {
|
||||
return podmanRPC.containers.list('all=true');
|
||||
},
|
||||
|
||||
/**
|
||||
* Update list section
|
||||
*/
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'Id';
|
||||
this.section.createForm = PodmanFormContainer;
|
||||
this.section.handleRefreshCb = () => this.checkInitScripts();
|
||||
this.section.toolbarExtraButtons = [
|
||||
new podmanUI.Button(constant.ICON.START, ui.createHandlerFn(this, 'handleStart')).render(),
|
||||
new podmanUI.Button(constant.ICON.STOP, ui.createHandlerFn(this, 'handleStop')).render(),
|
||||
new podmanUI.Button(constant.ICON.RESTART, ui.createHandlerFn(this, 'handleRestart')).render(),
|
||||
new podmanUI.Button(constant.ICON.PAUSE, ui.createHandlerFn(this, 'handlePause')).render(),
|
||||
];
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Names', _('Name'));
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
|
||||
o.cfgdatavalue = (container) => container.getDetailLink(utils.truncate(container.getID(), 10));
|
||||
o.width = '12%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Image', _('Image'));
|
||||
o.cfgdatavalue = (container) => container.getImageName();
|
||||
o.width = '25%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'ImageTag', _('Tag'));
|
||||
o.cfgdatavalue = (container) => container.getImageTag();
|
||||
o.cfgformatter = (imageTag) => utils.truncate(imageTag, 10);
|
||||
o.cfgtt = (imageTag) => imageTag.length > 10 ? imageTag : '';
|
||||
o.width = '11%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'State', _('Status'));
|
||||
o.cfgdatavalue = (container) => container.getStateBadge();
|
||||
o.width = '8%';
|
||||
|
||||
o = this.section.option(podmanForm.field.TimestampDummyValue, 'StartedAt', _('Started At'));
|
||||
o.width = '17%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'InitScript', _('Boot'));
|
||||
o.cfgvalue = () => E('span', { class: 'autostart-status autostart-' }, '...');
|
||||
o.width = '6%';
|
||||
},
|
||||
|
||||
transformMap(map) {
|
||||
this.checkInitScripts(map);
|
||||
return map;
|
||||
},
|
||||
|
||||
async checkInitScripts(renderedTable) {
|
||||
const data = this.map.data.data;
|
||||
|
||||
for (const [key, container] of Object.entries(data)) {
|
||||
const iconEl = (renderedTable || document)
|
||||
.querySelector(`tr[data-sid="${key}"] td[data-name="InitScript"] .autostart-status`);
|
||||
|
||||
container.checkInitScript().then((initScriptStatus) => {
|
||||
let initScriptIcon = container.getAutoStartStatusIcon(initScriptStatus);
|
||||
|
||||
if (initScriptStatus === 'missing') {
|
||||
initScriptIcon = new podmanUI.Tooltip(
|
||||
initScriptIcon,
|
||||
_('Start script missing\n\nClick to generate'),
|
||||
{
|
||||
class: 'border-0',
|
||||
click: ui.createHandlerFn(this, 'handleGenerateInitScript')
|
||||
}
|
||||
).render();
|
||||
} else if (initScriptStatus === 'disabled') {
|
||||
initScriptIcon = new podmanUI.Tooltip(initScriptIcon, _('Start script disabled'), { class: 'border-0' }).render();
|
||||
} else if (initScriptStatus === 'enabled') {
|
||||
initScriptIcon = new podmanUI.Tooltip(initScriptIcon, _('Start on boot'), { class: 'border-0' }).render();
|
||||
}
|
||||
|
||||
if (iconEl) iconEl.replaceWith(initScriptIcon);
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
async handleGenerateInitScript(event) {
|
||||
const clickedIcon = event.target;
|
||||
const recordRow = dom.parent(clickedIcon, 'tr');
|
||||
const container = this.map.data.data[recordRow.getAttribute('data-sid')];
|
||||
|
||||
await container.generateInitScript();
|
||||
await container.enableInitScript();
|
||||
|
||||
this.section.handleRefresh();
|
||||
},
|
||||
|
||||
handleStart() {
|
||||
return this.updateContainersStatus(
|
||||
(container) => container.isPaused() ? container.unpause() : container.start(),
|
||||
_('Start container')
|
||||
);
|
||||
},
|
||||
|
||||
handleStop() {
|
||||
return this.updateContainersStatus((container) => container.stop(), _('Stop container'));
|
||||
},
|
||||
|
||||
handleRestart() {
|
||||
return this.updateContainersStatus((container) => container.restart(), _('Restart container'));
|
||||
},
|
||||
|
||||
handlePause() {
|
||||
return this.updateContainersStatus((container) => container.pause(), _('Pause container'));
|
||||
},
|
||||
|
||||
async updateContainersStatus(statusFunction, textLoad) {
|
||||
const section = this.getSection();
|
||||
const selected = section.getSelectedData();
|
||||
if (selected.length === 0) {
|
||||
return section.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
for (const [i, container] of selected.entries()) {
|
||||
this.loading(_('%s: %d/%d').format(textLoad, i + 1, selected.length));
|
||||
await statusFunction(container);
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
section.handleRefresh();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
'require form';
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as utils';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form.image as PodmanFormImage';
|
||||
|
||||
const IMAGE_ID_TRUNCATE_LEN = 12;
|
||||
const IMAGE_TAG_TRUNCATE_LEN = 14;
|
||||
|
||||
/**
|
||||
* Manage podman images
|
||||
*/
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'images',
|
||||
|
||||
title: _('Images'),
|
||||
titleSingle: _('Image'),
|
||||
|
||||
async load() {
|
||||
return podmanRPC.images.list();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update list section
|
||||
*/
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'Id';
|
||||
this.section.handleCreateText = _('Pull Image');
|
||||
this.section.getCreateButton = function() {
|
||||
return new podmanUI.ButtonNew(_('Pull Image'), {
|
||||
click: ui.createHandlerFn(this, 'handleCreate'),
|
||||
type: 'add',
|
||||
}).render();
|
||||
};
|
||||
this.section.modalCreateTitle = _('Pull Image');
|
||||
this.section.createForm = PodmanFormImage;
|
||||
this.section.toolbarExtraButtons = [
|
||||
new podmanUI.Button(_('Update'), ui.createHandlerFn(this, 'handlePullImages')).render(),
|
||||
];
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Repository', _('Repository'));
|
||||
o.cfgdatavalue = async (image) => image.getRepository();
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Tag', _('Tag'));
|
||||
o.cfgdatavalue = async (image) => {
|
||||
const tagValue = image.getTag();
|
||||
|
||||
if (tagValue.length > IMAGE_TAG_TRUNCATE_LEN) {
|
||||
return new podmanUI.Tooltip(utils.truncate(tagValue, IMAGE_TAG_TRUNCATE_LEN), tagValue).render();
|
||||
}
|
||||
|
||||
return tagValue;
|
||||
};
|
||||
o.width = '17%';
|
||||
|
||||
o = this.section.option(podmanForm.field.LinkDummyValue, 'ImageId', _('Image ID'));
|
||||
o.click = (_value, image) => this.section.handleInspect(image);
|
||||
o.cfgdatavalue = (image) => utils.truncate(image.getID(), IMAGE_ID_TRUNCATE_LEN);
|
||||
o.width = '15%';
|
||||
|
||||
o = this.section.option(podmanForm.field.ByteDummyValue, 'Size', _('Size'));
|
||||
o.width = '12%';
|
||||
|
||||
o = this.section.option(podmanForm.field.TimestampDummyValue, 'Created', _('Created'));
|
||||
o.width = '18%';
|
||||
|
||||
return this.map.render();
|
||||
},
|
||||
|
||||
handlePullImages: async function () {
|
||||
const selected = this.section.getSelectedData();
|
||||
|
||||
if (selected.length === 0) {
|
||||
return this.section.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
const progressEl = E('pre', { class: 'terminal-area' });
|
||||
const modal = new podmanUI.Modal(_('Updating Images'), [ progressEl ]);
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const closeButton = modal.getCloseButton();
|
||||
closeButton.classList.add('d-none');
|
||||
|
||||
modal.getButtons = () => [ closeButton ];
|
||||
modal.render();
|
||||
|
||||
const append = (text) => {
|
||||
progressEl.textContent += text;
|
||||
progressEl.scrollTop = progressEl.scrollHeight;
|
||||
};
|
||||
|
||||
let errors = 0;
|
||||
let first = true;
|
||||
for (const image of selected) {
|
||||
if (!first) append('\n');
|
||||
first = false;
|
||||
try {
|
||||
await image.streamPull((text) => append(text));
|
||||
} catch (err) {
|
||||
podmanUI.alert(`${_('Error')}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
this.section.handleRefresh();
|
||||
closeButton.classList.remove('d-none');
|
||||
if (errors === 0)
|
||||
podmanUI.alert(_('All images updated successfully'), 'success', true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.model.Container as Container';
|
||||
|
||||
return podmanUI.Modal.extend({
|
||||
title: _('Auto Update'),
|
||||
containers: [],
|
||||
buttons: [],
|
||||
|
||||
getContent() {
|
||||
if (!this.containers || this.containers.length === 0) {
|
||||
this.buttons = [ this.getCloseButton() ];
|
||||
return [
|
||||
E('p', {}, _('No containers with auto-update label found.')),
|
||||
E('p', { class: 'mt-sm' },
|
||||
_('To enable auto-update for a container, add the label: io.containers.autoupdate=registry')
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
this.buttons = [
|
||||
this.getCloseButton(),
|
||||
new podmanUI.ButtonNew(_('Check'), {
|
||||
click: ui.createHandlerFn(this, 'handleCheckUpdate'),
|
||||
type: 'positive',
|
||||
}).render()
|
||||
];
|
||||
|
||||
return [
|
||||
E('p', {}, _('Check %d containers for updates?').format(this.containers.length))
|
||||
];
|
||||
},
|
||||
|
||||
setContainers(containers) {
|
||||
this.containers = containers ?? [];
|
||||
return this;
|
||||
},
|
||||
|
||||
getButtons() {
|
||||
return this.buttons;
|
||||
},
|
||||
|
||||
getCloseButton() {
|
||||
return new podmanUI.ButtonNew(_('Close'), {
|
||||
click: () => ui.hideModal(),
|
||||
type: 'remove',
|
||||
}).render();
|
||||
},
|
||||
|
||||
async handleCheckUpdate() {
|
||||
const containersWithUpdates = [];
|
||||
const checkErrors = [];
|
||||
|
||||
for (const [index, container] of this.containers.entries()) {
|
||||
podmanUI.showSpinningModal(null, _('Check for updates: %s/%s').format(index + 1, this.containers.length));
|
||||
|
||||
const updateState = await container.checkImageUpdate();
|
||||
if (updateState.hasUpdate) {
|
||||
containersWithUpdates.push(container);
|
||||
} else if (updateState.error) {
|
||||
checkErrors.push({ name: container.getName(), error: updateState.error });
|
||||
}
|
||||
}
|
||||
|
||||
if (containersWithUpdates.length === 0) {
|
||||
const content = [ E('p', {}, _('No new updates available.')) ];
|
||||
if (checkErrors.length > 0) {
|
||||
content.push(E('p', { class: 'mt-sm' }, _('%d check(s) failed:').format(checkErrors.length)));
|
||||
checkErrors.forEach((e) => content.push(E('p', { class: 'text-error' }, `${e.name}: ${e.error}`)));
|
||||
}
|
||||
const modal = new podmanUI.Modal(_('Check Complete'), content);
|
||||
modal.getButtons = () => [ this.getCloseButton() ];
|
||||
modal.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const updateContent = [
|
||||
E('p', {}, _('Select containers to update')),
|
||||
];
|
||||
|
||||
const checkboxes = {};
|
||||
containersWithUpdates.forEach((container) => {
|
||||
const skipReason = container.getUpdateSkipReason();
|
||||
const checkbox = new ui.Checkbox(0);
|
||||
const checkboxNode = checkbox.render();
|
||||
const checkboxInputNode = checkboxNode.querySelector('input[type="checkbox"]');
|
||||
const checkboxId = checkboxInputNode.id;
|
||||
|
||||
if (skipReason) {
|
||||
checkboxInputNode.disabled = true;
|
||||
} else {
|
||||
checkboxes[container.getID()] = { container, checkbox };
|
||||
}
|
||||
|
||||
const inner = skipReason
|
||||
? new podmanUI.Tooltip(checkboxNode, skipReason).render()
|
||||
: checkboxNode;
|
||||
|
||||
updateContent.push(E('div', { class: 'd-flex align-center checkbox-with-label mb-xs' }, [
|
||||
inner,
|
||||
E('label', { for: checkboxId }, container.getName()),
|
||||
]));
|
||||
});
|
||||
|
||||
const modal = new podmanUI.Modal(_('Apply Updates'), updateContent);
|
||||
modal.getButtons = () => [
|
||||
this.getCloseButton(),
|
||||
new podmanUI.ButtonNew(_('Update'), {
|
||||
click: async () => {
|
||||
const selectedUpdates = Object.values(checkboxes)
|
||||
.filter(({ checkbox }) => checkbox.isChecked())
|
||||
.map(({ container }) => container);
|
||||
|
||||
if (selectedUpdates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleUpdate(selectedUpdates);
|
||||
},
|
||||
type: 'positive',
|
||||
}).render()
|
||||
];
|
||||
modal.render();
|
||||
},
|
||||
|
||||
async handleUpdate(containers) {
|
||||
const failures = [];
|
||||
const oldImages = [];
|
||||
|
||||
const counterEl = E('strong', {}, '');
|
||||
const spinnerEl = E('div', { class: 'spinning' });
|
||||
const headerEl = E('div', { class: 'd-flex align-center mb-sm gap-5' }, [
|
||||
counterEl,
|
||||
spinnerEl,
|
||||
]);
|
||||
const log = new podmanUI.StreamLog();
|
||||
|
||||
const finalize = () => {
|
||||
ui.hideModal();
|
||||
if (failures.length === 0 && imageWarnings.length === 0) {
|
||||
podmanUI.alert(_('Containers updated successfully'), 'success', true);
|
||||
return;
|
||||
}
|
||||
const lines = [];
|
||||
if (failures.length > 0) {
|
||||
const succeeded = containers.length - failures.length;
|
||||
lines.push(E('p', {}, _('%d updated, %d failed:').format(succeeded, failures.length)));
|
||||
failures.forEach(f => lines.push(E('p', {}, `${f.name}: ${f.error}`)));
|
||||
} else {
|
||||
lines.push(E('p', {}, _('Containers updated successfully.')));
|
||||
}
|
||||
imageWarnings.forEach(w => lines.push(E('p', { class: 'mt-sm' }, w)));
|
||||
podmanUI.alert(lines, failures.length > 0 ? 'warning' : 'info');
|
||||
};
|
||||
|
||||
const closeBtn = new podmanUI.ButtonNew(_('Close'), {
|
||||
click: () => finalize(),
|
||||
type: 'negative',
|
||||
}).render();
|
||||
closeBtn.disabled = true;
|
||||
|
||||
const modal = new podmanUI.Modal(_('Apply Updates'), [ headerEl, log.render() ]);
|
||||
modal.getButtons = () => [ closeBtn ];
|
||||
modal.render();
|
||||
|
||||
let imageWarnings = [];
|
||||
|
||||
for (const [i, container] of containers.entries()) {
|
||||
counterEl.textContent = _('Update image: %d/%d - %s').format(i + 1, containers.length, container.getName());
|
||||
log.append('━━ ' + container.getName() + ' ━━\n');
|
||||
try {
|
||||
const inspected = Container.getSingleton(await container.inspect());
|
||||
const result = await inspected.updateImage((line) => log.append(line));
|
||||
if (result?.oldImage) oldImages.push({ image: result.oldImage });
|
||||
} catch (err) {
|
||||
const msg = err.message || String(err);
|
||||
log.append('✗ ' + msg + '\n');
|
||||
failures.push({ name: container.getName(), error: msg });
|
||||
}
|
||||
log.append('\n');
|
||||
}
|
||||
|
||||
if (oldImages.length > 0) {
|
||||
counterEl.textContent = _('Cleaning up old images...');
|
||||
log.append('━━ ' + _('Cleaning up old images...') + ' ━━\n');
|
||||
|
||||
const allContainers = await podmanRPC.containers.list('all=true').catch(() => []);
|
||||
const normalize = id => (id || '').replace(/^sha256:/, '').toLowerCase();
|
||||
const usedIds = new Set(allContainers.map(c => normalize(c.ImageID)));
|
||||
|
||||
const seen = new Set();
|
||||
for (const { image } of oldImages) {
|
||||
const id = normalize(image.getID());
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
if (usedIds.has(id)) {
|
||||
const warning = _('Old image still used by other containers');
|
||||
imageWarnings.push(warning);
|
||||
log.append('! ' + warning + '\n');
|
||||
} else {
|
||||
await image.remove();
|
||||
log.append('✓ ' + _('Removed old image %s').format(image.getID().substring(0, 12)) + '\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
counterEl.textContent = _('Done.');
|
||||
spinnerEl.classList.remove('spinning');
|
||||
closeBtn.disabled = false;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.ui as podmanUI';
|
||||
|
||||
return podmanUI.Modal.extend({
|
||||
title: _('Cleanup Unused Resources'),
|
||||
|
||||
getContent() {
|
||||
this.checkboxPruneAll = new ui.Checkbox(1, {
|
||||
hiddenname: 'prune-all-images'
|
||||
});
|
||||
this.checkboxPruneVolumes = new ui.Checkbox(0, {
|
||||
hiddenname: 'prune-volumes'
|
||||
});
|
||||
|
||||
const pruneAllNode = this.checkboxPruneAll.render();
|
||||
const pruneVolumesNode = this.checkboxPruneVolumes.render();
|
||||
const pruneAllId = pruneAllNode.querySelector('input[type="checkbox"]').id;
|
||||
const pruneVolumesId = pruneVolumesNode.querySelector('input[type="checkbox"]').id;
|
||||
|
||||
return E('div', {}, [
|
||||
E('p', {}, _('Select what to clean up:')),
|
||||
|
||||
E('div', {
|
||||
class: 'd-flex align-center checkbox-with-label',
|
||||
style: 'margin-bottom: 2px;'
|
||||
}, [
|
||||
pruneAllNode,
|
||||
E('label', {
|
||||
for: pruneAllId
|
||||
}, _('Remove all unused images (not just dangling)')),
|
||||
]),
|
||||
|
||||
E('div', {
|
||||
class: 'd-flex align-center checkbox-with-label'
|
||||
}, [
|
||||
pruneVolumesNode,
|
||||
E('label', {
|
||||
for: pruneVolumesId
|
||||
}, _('Remove unused volumes')),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
getButtons() {
|
||||
return [
|
||||
this.getCloseButton(),
|
||||
new podmanUI.ButtonNew(_('Clean Up Now'), {
|
||||
click: ui.createHandlerFn(this, 'handlePrune'),
|
||||
type: 'positive',
|
||||
}).render()
|
||||
];
|
||||
},
|
||||
|
||||
async handlePrune() {
|
||||
const allImages = this.checkboxPruneAll.getValue() === '1';
|
||||
const volumes = this.checkboxPruneVolumes.getValue() === '1';
|
||||
podmanUI.showSpinningModal(_('Clean Up Now'), _(
|
||||
'Removing unused resources, please wait...'));
|
||||
|
||||
const result = await podmanRPC.system.prune(allImages, volumes);
|
||||
let freedSpace = 0;
|
||||
const deletedItems = [];
|
||||
|
||||
const reportTypes = [{
|
||||
key: 'ContainerPruneReports',
|
||||
label: _('Containers')
|
||||
},
|
||||
{
|
||||
key: 'ImagePruneReports',
|
||||
label: _('Images')
|
||||
},
|
||||
{
|
||||
key: 'VolumePruneReports',
|
||||
label: _('Volumes')
|
||||
}
|
||||
];
|
||||
|
||||
reportTypes.forEach((type) => {
|
||||
const reports = result[type.key];
|
||||
if (reports && reports.length > 0) {
|
||||
reports.forEach((r) => {
|
||||
if (r.Size > 0) freedSpace += r.Size;
|
||||
});
|
||||
deletedItems.push('%d %s'.format(reports.length, type.label
|
||||
.toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
const modal = new podmanUI.Modal(_('Cleanup Complete'));
|
||||
modal.handleClose = null;
|
||||
modal.handleSubmit = () => {
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
};
|
||||
modal.content = [
|
||||
E('p', {}, _('Cleanup successful!')),
|
||||
];
|
||||
|
||||
if (deletedItems.length > 0) {
|
||||
modal.content.push(E('p', {
|
||||
class: 'mt-sm'
|
||||
}, _('Removed: %s').format(deletedItems.join(', '))));
|
||||
} else {
|
||||
modal.content.push(E('p', {
|
||||
class: 'mt-sm'
|
||||
}, _('No unused resources found')));
|
||||
}
|
||||
|
||||
modal.content.push(E('p', {
|
||||
class: 'mt-sm text-success text-bold'
|
||||
}, _('Space freed: %s').format(podmanUtil.format.bytes(freedSpace))));
|
||||
|
||||
modal.render();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
'require network';
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.form.network as PodmanFormNetwork';
|
||||
|
||||
/**
|
||||
* Manage podman networks
|
||||
*/
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'networks',
|
||||
|
||||
title: _('Networks'),
|
||||
titleSingle: _('Network'),
|
||||
|
||||
async load() {
|
||||
return podmanRPC.networks.list();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update list section
|
||||
*/
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'ID';
|
||||
this.section.createForm = PodmanFormNetwork;
|
||||
this.section.handleRemove = async () => this.removeNetworks();
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.LinkDummyValue, 'name', _('Name'));
|
||||
o.click = (_value, net) => this.section.handleInspect(net);
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Int', _('Int'));
|
||||
o.cfgdatavalue = (net) => this.getIntegrationIcon(net);
|
||||
o.width = '5%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'driver', _('Driver'));
|
||||
o.width = '10%';
|
||||
|
||||
const ipPairCell = (getV4, getV6) => (net) => E('div', {}, [
|
||||
E('span', {}, getV4(net) || '-'),
|
||||
E('br'),
|
||||
E('span', {}, getV6(net) || '-'),
|
||||
]);
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Subnet', _('Subnet'));
|
||||
o.cfgdatavalue = ipPairCell(n => n.getSubnetIP4(), n => n.getSubnetIP6());
|
||||
o.width = '20%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Gateway', _('Gateway'));
|
||||
o.cfgdatavalue = ipPairCell(n => n.getGatewayIP4(), n => n.getGatewayIP6());
|
||||
o.width = '20%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DateDummyValue, 'created', _('Created'));
|
||||
o.width = '20%';
|
||||
|
||||
await network.flushCache();
|
||||
},
|
||||
|
||||
async removeNetworks() {
|
||||
const selected = this.section.getSelectedData();
|
||||
if (selected.length === 0) {
|
||||
return this.section.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
const checkboxRemoveIntegration = new ui.Checkbox('1', { hiddenname: 'remove-integration' });
|
||||
const checkboxNode = checkboxRemoveIntegration.render();
|
||||
const checkboxId = checkboxNode.querySelector('input[type="checkbox"]').id;
|
||||
|
||||
this.confirm([
|
||||
E('p', {}, _('Are you sure to delete the records?')),
|
||||
E('div', { class: 'd-flex align-center checkbox-with-label' }, [
|
||||
checkboxNode,
|
||||
E('label', { for: checkboxId }, _('Remove OpenWrt integration')),
|
||||
]),
|
||||
], async () => {
|
||||
for (const [i, item] of selected.entries()) {
|
||||
this.loading(_('Deleting records: %s/%s').format(i + 1, selected.length));
|
||||
|
||||
await item.remove();
|
||||
if (checkboxRemoveIntegration.getValue() === '1') {
|
||||
await item.integrationRemove();
|
||||
}
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
this.section.handleRefresh();
|
||||
});
|
||||
},
|
||||
|
||||
async getIntegrationIcon(net) {
|
||||
const { hasInterface, hasDevice, hasDnsmasqExclusion } = await net.integrationCheck();
|
||||
|
||||
if (hasInterface && hasDevice && hasDnsmasqExclusion) {
|
||||
return new podmanUI.Tooltip('✅', _('OpenWrt integration exists\n\nClick to remove'), {
|
||||
click: () => {
|
||||
this.confirm([
|
||||
E('p', {}, _('Are you sure to remove integration?')),
|
||||
], async () => {
|
||||
await net.integrationRemove();
|
||||
this.section.handleRefresh();
|
||||
});
|
||||
},
|
||||
}).render();
|
||||
}
|
||||
|
||||
const tooltip = [];
|
||||
tooltip.push(_('OpenWrt integration incomplete'));
|
||||
tooltip.push('');
|
||||
tooltip.push(`${_('Interface')}: ${String(hasInterface)}`);
|
||||
tooltip.push(`${_('Device')}: ${String(hasDevice)}`);
|
||||
if (await net.hasDnsmasq()) {
|
||||
tooltip.push(`${_('Dnsmasq')}: ${String(hasDnsmasqExclusion)}`);
|
||||
}
|
||||
tooltip.push('');
|
||||
tooltip.push(_('Click icon to repair'));
|
||||
|
||||
return new podmanUI.Tooltip('❌', tooltip.join('\n'), {
|
||||
click: () =>
|
||||
net.integrationRepair().then(() => this.section.handleRefresh()),
|
||||
}).render();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
'require view.podman.modal.prune as PruneModal';
|
||||
'require view.podman.modal.autoUpdate as AutoUpdateModal';
|
||||
|
||||
/**
|
||||
* Podman Overview Dashboard View
|
||||
*/
|
||||
return podmanView.base.extend({
|
||||
async load() {
|
||||
return Promise.all([
|
||||
podmanRPC.system.version(),
|
||||
podmanRPC.system.info(),
|
||||
])
|
||||
},
|
||||
|
||||
async render([version, info]) {
|
||||
const sections = await Promise.all([
|
||||
this.createToolbar(),
|
||||
this.createInfoSection(version, info),
|
||||
this.createDebugSection(),
|
||||
this.createResourcesSection(),
|
||||
]);
|
||||
|
||||
this.loadAdditionalInformation();
|
||||
|
||||
return E('div', {}, sections.filter(Boolean));
|
||||
},
|
||||
|
||||
async loadAdditionalInformation() {
|
||||
const [containers, images, volumes, networks, pods] = await Promise.all([
|
||||
podmanRPC.containers.list('all=true'),
|
||||
podmanRPC.images.list(),
|
||||
podmanRPC.volumes.list(),
|
||||
podmanRPC.networks.list(),
|
||||
podmanRPC.pods.list()
|
||||
]);
|
||||
|
||||
const runningContainers = containers.filter((container) => container.isRunning()).length;
|
||||
const runningPods = pods.filter((p) => p.Status === 'Running').length;
|
||||
|
||||
if (this.resourceCardsEl) {
|
||||
dom.content(this.resourceCardsEl, [
|
||||
this.createResourceCards(containers, pods, images, networks, volumes,
|
||||
runningContainers, runningPods)
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
createToolbar() {
|
||||
return E('div', { class: 'overview-actions mb-sm' }, [
|
||||
new podmanUI.Button(
|
||||
_('Check for Updates'),
|
||||
ui.createHandlerFn(this, 'handleAutoUpdate'),
|
||||
'positive'
|
||||
).render(),
|
||||
new podmanUI.Button(
|
||||
_('Cleanup / Prune'),
|
||||
ui.createHandlerFn(this, 'handlePrune'),
|
||||
'remove'
|
||||
).render()
|
||||
]);
|
||||
},
|
||||
|
||||
createInfoSection(version, info) {
|
||||
const memTotal = podmanUtil.format.bytes(info.host?.memTotal);
|
||||
const memFree = podmanUtil.format.bytes(info.host?.memFree);
|
||||
|
||||
const table = new podmanUI.TableList()
|
||||
.addRow(_('Podman Version'), version.Version || _('Unknown'))
|
||||
.addRow(_('API Version'), version.ApiVersion || _('Unknown'))
|
||||
.addRow(_('CPU'), info.host?.cpus?.toString() || _('Unknown'))
|
||||
.addRow(_('Memory'), memFree + ' / ' + memTotal)
|
||||
.addRow(_('Socket Path'), info.host?.remoteSocket?.path || '/run/podman/podman.sock')
|
||||
.addRow(_('Graph Root'), info.store?.graphRoot || _('Unknown'))
|
||||
.addRow(_('Run Root'), info.store?.runRoot || _('Unknown'))
|
||||
.addRow(_('Registries'), this.getRegistries(info))
|
||||
;
|
||||
|
||||
return this.createSection(_('Information'), table.render());
|
||||
},
|
||||
|
||||
createDebugSection() {
|
||||
this.systemDiagnosticsEl = E('div', { class: 'system-diagnostics', style: 'margin-bottom: 18px;' }, [
|
||||
new podmanUI.Button(
|
||||
_('Load'),
|
||||
ui.createHandlerFn(this, 'handleLoadSystemDiagnostics'),
|
||||
'positive'
|
||||
).render(),
|
||||
]);
|
||||
|
||||
return this.createSection(_('System Diagnostics'), this.systemDiagnosticsEl);
|
||||
},
|
||||
|
||||
createResourcesSection() {
|
||||
this.resourceCardsEl = E('div', { class: 'resources-cards' }, [
|
||||
E('div', { class: 'loading-placeholder' }, [
|
||||
E('em', { class: 'spinning' }, _('Loading...')),
|
||||
]),
|
||||
]);
|
||||
|
||||
return this.createSection(_('Resources'), this.resourceCardsEl);
|
||||
},
|
||||
|
||||
createSection(headline, content) {
|
||||
return E('div', {}, [
|
||||
E('h3', {}, headline),
|
||||
content,
|
||||
]);
|
||||
},
|
||||
|
||||
getRegistries(info) {
|
||||
if (info.registries && info.registries.search) {
|
||||
return info.registries.search.join(', ');
|
||||
}
|
||||
return 'docker.io, registry.fedoraproject.org, registry.access.redhat.com';
|
||||
},
|
||||
|
||||
createResourceCards(containers, pods, images, networks, volumes, runningContainers,
|
||||
runningPods) {
|
||||
|
||||
return E('div', { class: 'overview-cards' }, [
|
||||
this.createCard('🐳', _('Containers'), 'containers', containers.length, runningContainers, 'admin/podman/containers'),
|
||||
this.createCard('🔗', _('Pods'), 'pods', pods.length, runningPods, 'admin/podman/pods'),
|
||||
this.createCard('💿', _('Images'), 'images', images.length, null, 'admin/podman/images'),
|
||||
this.createCard('🌐', _('Networks'), 'networks', networks.length, null, 'admin/podman/networks'),
|
||||
this.createCard('💾', _('Volumes'), 'volumes', volumes.length, null, 'admin/podman/volumes'),
|
||||
]);
|
||||
},
|
||||
|
||||
createCard(icon, title, slug, total, running, url) {
|
||||
const statsText = running !== null ? running + ' / ' + total : total.toString();
|
||||
|
||||
return E('a', {
|
||||
href: L.url(url),
|
||||
class: 'd-flex flex-column cursor-pointer align-center overview-card-link overview-card-' + slug,
|
||||
}, [
|
||||
E('div', { class: 'card-link-header d-flex align-center w-100' }, [
|
||||
E('span', { class: 'card-link-title text-bold' }, title),
|
||||
icon,
|
||||
]),
|
||||
E('div', { class: 'w-100' }, [
|
||||
E('div', { class: 'card-link-headline text-bold' }, statsText),
|
||||
running !== null
|
||||
? E('div', { class: 'card-link-text' }, _('running') + ' / ' + _('total'))
|
||||
: E('div', { class: 'card-link-text' }, _('total'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
async handleLoadSystemDiagnostics() {
|
||||
this.loading(_('Load System diagnostics'));
|
||||
|
||||
const debugData = await podmanRPC.system.debug();
|
||||
const statusIcons = { ok: '\u2713', warn: '\u26A0', error: '\u2717' };
|
||||
const statusColors = { ok: '#2ecc71', warn: '#e67e22', error: '#e74c3c' };
|
||||
|
||||
const table = new podmanUI.TableList();
|
||||
|
||||
(debugData.checks || []).forEach((check) => {
|
||||
const icon = statusIcons[check.status] || '?';
|
||||
const color = statusColors[check.status] || '#666';
|
||||
const detail = check.detail || '';
|
||||
const message = check.message ? ' \u2014 ' + check.message : '';
|
||||
|
||||
table.addRow(
|
||||
_(check.label),
|
||||
E('span', { style: 'color:' + color }, [
|
||||
E('strong', {}, icon + ' '),
|
||||
E('span', { class: 'cli-value' }, detail),
|
||||
message
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
dom.content(this.systemDiagnosticsEl, [table.render()]);
|
||||
ui.hideModal();
|
||||
},
|
||||
|
||||
handlePrune() {
|
||||
PruneModal.render();
|
||||
},
|
||||
|
||||
handleAutoUpdate() {
|
||||
this.loading(_('Find updateable containers'));
|
||||
|
||||
// @todo Use podman api filter
|
||||
return podmanRPC.containers.list('all=true').then((containers) => {
|
||||
AutoUpdateModal
|
||||
.setContainers(containers.filter((c) => c.getAutoUpdateLabel()))
|
||||
.render();
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
'require form';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.model.Container as Container';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.TableSelectSection as TableSelectSection';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'ps',
|
||||
pod: null,
|
||||
|
||||
async render(pod) {
|
||||
this.pod = pod;
|
||||
|
||||
const containers = [];
|
||||
|
||||
for (const podContainer of this.pod.getContainers()) {
|
||||
const containerInspect = await Container.getSingleton(podContainer).inspect();
|
||||
const container = Container.getSingleton(containerInspect);
|
||||
containers.push(container);
|
||||
}
|
||||
|
||||
this.map = new form.JSONMap({ containers }, '');
|
||||
|
||||
this.section = this.map.section(
|
||||
TableSelectSection.TableSelectSection,
|
||||
'containers',
|
||||
'',
|
||||
''
|
||||
);
|
||||
this.section.view = this;
|
||||
this.section.anonymous = true;
|
||||
this.section.footer = true;
|
||||
|
||||
this.section.selectKey = 'Id';
|
||||
// this.section.createForm = PodmanFormContainer;
|
||||
this.section.handleCreate = null;
|
||||
this.section.handleRemove = null;
|
||||
this.section.handleRefresh = null;
|
||||
|
||||
// this.section.handleRefreshCb = () => this.checkInitScripts();
|
||||
// this.section.handleRefreshCb = null;
|
||||
// this.section.toolbarExtraButtons = [
|
||||
// new podmanUI.Button('►', ui.createHandlerFn(this, 'handleStart')).render(),
|
||||
// new podmanUI.Button('◼', ui.createHandlerFn(this, 'handleStop')).render(),
|
||||
// new podmanUI.Button('↻', ui.createHandlerFn(this, 'handleRestart')).render(),
|
||||
// ];
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Names', _('Name'));
|
||||
o.cfgdatavalue = (container) => container.getName();
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
|
||||
o.cfgdatavalue = (container) => container.getDetailLink(podmanUtil.truncate(container.getID(), 10));
|
||||
o.width = '12%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Image', _('Image'));
|
||||
o.cfgdatavalue = (container) => container.getImageName();
|
||||
o.width = '25%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'ImageTag', _('Tag'));
|
||||
o.cfgdatavalue = (container) => container.getImageTag();
|
||||
o.cfgformatter = (imageTag) => podmanUtil.truncate(imageTag, 10);
|
||||
o.cfgtt = (imageTag) => imageTag.length > 10 ? imageTag : '';
|
||||
o.width = '11%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'State', _('Status'));
|
||||
o.cfgdatavalue = (container) => container.getStateBadge();
|
||||
o.width = '8%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'StartedAt', _('Started At'));
|
||||
o.cfgdatavalue = (container) => container.getStartedAt(true);
|
||||
o.width = '17%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'InitScript', _('Boot'));
|
||||
o.cfgvalue = () => E('span', { class: 'autostart-status autostart-' }, '...');
|
||||
o.width = '6%';
|
||||
|
||||
return this.map.render();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.utils as podmanUtil';
|
||||
'require podman.model.Container as Container';
|
||||
|
||||
const DASH = '-';
|
||||
|
||||
function joinOrDash(values) {
|
||||
return Array.isArray(values) && values.length > 0 ? values.join(', ') : DASH;
|
||||
}
|
||||
|
||||
function valueOrDash(value) {
|
||||
return (value === undefined || value === null || value === '') ? DASH : value;
|
||||
}
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'info',
|
||||
pod: null,
|
||||
|
||||
render(pod) {
|
||||
this.pod = pod;
|
||||
|
||||
const sections = [
|
||||
this.basicSection(),
|
||||
this.configSection(),
|
||||
this.resourcesSection(),
|
||||
this.networkSection(),
|
||||
];
|
||||
|
||||
const labels = this.labelsSection();
|
||||
if (labels) sections.push(labels);
|
||||
|
||||
return sections;
|
||||
},
|
||||
|
||||
basicSection() {
|
||||
const table = new podmanUI.TableList();
|
||||
|
||||
table
|
||||
.addRow(_('Name'), this.pod.getName())
|
||||
.addRow(_('ID'), (this.pod.getID() || '').substring(0, 64))
|
||||
.addRow(_('Status'), this.pod.getStatusBadge())
|
||||
.addRow(_('Created'), podmanUtil.format.date(this.pod.getCreatedAt()));
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Basic Information')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
configSection() {
|
||||
const table = new podmanUI.TableList();
|
||||
|
||||
const infraId = this.pod.getInfraId();
|
||||
const infraIdCell = infraId
|
||||
? E('a', { href: L.url('admin/podman/container', infraId) }, infraId.substring(0, 12))
|
||||
: DASH;
|
||||
|
||||
table
|
||||
.addRow(_('Hostname'), valueOrDash(this.pod.Hostname))
|
||||
.addRow(_('Cgroup parent'), valueOrDash(this.pod.CgroupParent))
|
||||
.addRow(_('Exit policy'), valueOrDash(this.pod.ExitPolicy))
|
||||
.addRow(_('Restart policy'), valueOrDash(this.pod.RestartPolicy))
|
||||
.addRow(_('Shared namespaces'), joinOrDash(this.pod.SharedNamespaces))
|
||||
.addRow(_('Infra container'), infraIdCell);
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Configuration')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
resourcesSection() {
|
||||
const table = new podmanUI.TableList();
|
||||
|
||||
const memLimit = this.pod.memory_limit;
|
||||
const memLimitDisplay = memLimit > 0 ? podmanUtil.format.bytes(memLimit) : _('Unlimited');
|
||||
|
||||
const cpuQuota = this.pod.cpu_quota;
|
||||
const cpuQuotaDisplay = (cpuQuota === undefined || cpuQuota < 0) ? _('Unlimited') : `${cpuQuota} µs`;
|
||||
|
||||
const cpuPeriod = this.pod.cpu_period;
|
||||
const cpuPeriodDisplay = cpuPeriod > 0 ? `${cpuPeriod} µs` : DASH;
|
||||
|
||||
table
|
||||
.addRow(_('CPU period'), cpuPeriodDisplay)
|
||||
.addRow(_('CPU quota'), cpuQuotaDisplay)
|
||||
.addRow(_('CPU shares'), valueOrDash(this.pod.cpu_shares))
|
||||
.addRow(_('CPU set'), valueOrDash(this.pod.cpuset_cpus))
|
||||
.addRow(_('Memory limit'), memLimitDisplay);
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Resources')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
networkSection() {
|
||||
const table = new podmanUI.TableList();
|
||||
const infra = this.pod.InfraConfig || {};
|
||||
|
||||
table
|
||||
.addRow(_('Networks'), joinOrDash(infra.Networks || this.pod.getNetworks()))
|
||||
.addRow(_('Static IPv4'), valueOrDash(infra.StaticIP))
|
||||
.addRow(_('Static MAC'), valueOrDash(infra.StaticMAC))
|
||||
.addRow(_('DNS servers'), joinOrDash(infra.DNSServer))
|
||||
.addRow(_('DNS search'), joinOrDash(infra.DNSSearch))
|
||||
.addRow(_('DNS options'), joinOrDash(infra.DNSOption))
|
||||
.addRow(_('Extra hosts'), joinOrDash(infra.HostAdd))
|
||||
.addRow(_('Host network'), infra.HostNetwork ? _('Yes') : _('No'));
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Network')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
|
||||
labelsSection() {
|
||||
const labels = this.pod.getLabels();
|
||||
const keys = Object.keys(labels || {});
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const table = new podmanUI.TableList();
|
||||
for (const key of keys) {
|
||||
table.addRow(key, labels[key]);
|
||||
}
|
||||
|
||||
return E('div', {}, [
|
||||
E('h4', {}, _('Labels')),
|
||||
table.render(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'ps',
|
||||
pod: null,
|
||||
|
||||
render(pod) {
|
||||
this.pod = pod;
|
||||
|
||||
if (!this.pod.isRunning()) {
|
||||
return this.warningContent(_('Pod is not running'));
|
||||
}
|
||||
|
||||
this.tableContent = E('div', { class: 'ps-table-content' }, []);
|
||||
|
||||
return this.renderTabContent('', [ this.tableContent ]);
|
||||
},
|
||||
|
||||
onTabActive() {
|
||||
if (!this.pod || !this.pod.isRunning() || this.processStream) return;
|
||||
|
||||
this.processStream = this.pod.streamTop((ps) => {
|
||||
this.updateProcessList(ps);
|
||||
});
|
||||
},
|
||||
|
||||
onTabInactive() {
|
||||
if (!this.processStream) return;
|
||||
|
||||
this.processStream.stop();
|
||||
this.processStream = null;
|
||||
},
|
||||
|
||||
updateProcessList(ps) {
|
||||
if (!ps || !ps.Titles || !ps.Processes || ps.Titles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const psTable = new podmanUI.Table();
|
||||
const columnWidth = `width: ${100 / ps.Titles.length}%;`;
|
||||
const timeIndex = ps.Titles.indexOf('ELAPSED');
|
||||
|
||||
ps.Titles.forEach((title) => {
|
||||
psTable.addHeader(_(title), { style: columnWidth });
|
||||
});
|
||||
|
||||
ps.Processes.forEach((process) => {
|
||||
psTable.addRow(process.map((detail, i) => ({
|
||||
inner: i === timeIndex ? this._formatElapsedTime(detail) : detail
|
||||
})));
|
||||
});
|
||||
|
||||
dom.content(this.tableContent, psTable.render());
|
||||
},
|
||||
|
||||
// @todo: duplicated code (containers-tab/processes.js)
|
||||
_formatElapsedTime(timeStr) {
|
||||
if (!timeStr) return '-';
|
||||
|
||||
const result = [];
|
||||
const pattern = /(\d+(?:\.\d+)?)([ydhms])/g;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(timeStr)) !== null) {
|
||||
let value = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 's' && value > 1000) {
|
||||
value = Math.floor(value / 1000000000);
|
||||
|
||||
if (value >= 60) {
|
||||
const mins = Math.floor(value / 60);
|
||||
const secs = value % 60;
|
||||
if (mins > 0) result.push(`${mins}m`);
|
||||
if (secs > 0) result.push(`${secs}s`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
value = Math.floor(value);
|
||||
}
|
||||
|
||||
if (unit === 's' || unit === 'm') {
|
||||
result.push(String(value).padStart(2, '0') + unit);
|
||||
} else {
|
||||
result.push(value + unit);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result.join('') : timeStr;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
'require dom';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.utils as podmanUtil';
|
||||
|
||||
return podmanView.tabContent.extend({
|
||||
tab: 'stats',
|
||||
pod: null,
|
||||
|
||||
render(pod) {
|
||||
this.pod = pod;
|
||||
|
||||
if (!this.pod.isRunning()) {
|
||||
return this.warningContent(_('Pod is not running'));
|
||||
}
|
||||
|
||||
this.containerNames = this.buildContainerNameMap();
|
||||
this.tableContent = E('div', { class: 'stats-table-content' }, []);
|
||||
|
||||
return this.renderTabContent('', [ this.tableContent ]);
|
||||
},
|
||||
|
||||
onTabActive() {
|
||||
if (!this.pod || !this.pod.isRunning() || this.statsStream) return;
|
||||
|
||||
this.statsStream = this.pod.streamStats((reports) => {
|
||||
this.updateStats(reports);
|
||||
});
|
||||
},
|
||||
|
||||
onTabInactive() {
|
||||
if (!this.statsStream) return;
|
||||
|
||||
this.statsStream.stop();
|
||||
this.statsStream = null;
|
||||
},
|
||||
|
||||
buildContainerNameMap() {
|
||||
const map = {};
|
||||
for (const c of (this.pod.getContainers() || [])) {
|
||||
if (c?.Id) map[c.Id] = c.Name;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
updateStats(reports) {
|
||||
if (!Array.isArray(reports)) return;
|
||||
|
||||
const table = new podmanUI.Table();
|
||||
const headers = [
|
||||
_('Container'),
|
||||
_('CID'),
|
||||
_('CPU %'),
|
||||
_('Memory'),
|
||||
_('Memory %'),
|
||||
_('Net I/O'),
|
||||
_('Block I/O'),
|
||||
_('PIDs'),
|
||||
];
|
||||
const columnWidth = `width: ${100 / headers.length}%;`;
|
||||
headers.forEach((h) => table.addHeader(h, { style: columnWidth }));
|
||||
|
||||
for (const r of reports) {
|
||||
const cid = r.CID || '';
|
||||
const name = this.containerNames[cid] || r.Name || '-';
|
||||
table.addRow([
|
||||
{ inner: name },
|
||||
{ inner: podmanUtil.truncate(cid, 12) },
|
||||
{ inner: r.CPU || '-' },
|
||||
{ inner: r.MemUsage || '-' },
|
||||
{ inner: r.Mem || '-' },
|
||||
{ inner: r.NetIO || '-' },
|
||||
{ inner: r.BlockIO || '-' },
|
||||
{ inner: r.PIDS || '-' },
|
||||
]);
|
||||
}
|
||||
|
||||
dom.content(this.tableContent, table.render());
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
'require dom';
|
||||
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.model.Pod as Pod';
|
||||
|
||||
'require view.podman.pod-tab.info as PodInfoTab';
|
||||
'require view.podman.pod-tab.containers as PodContainersTab';
|
||||
'require view.podman.pod-tab.stats as PodStatsTab';
|
||||
'require view.podman.pod-tab.processes as PodProcessesTab';
|
||||
|
||||
return podmanView.container.extend({
|
||||
pod: null,
|
||||
data: null,
|
||||
listUrl: L.url('admin/podman/pods'),
|
||||
|
||||
async load() {
|
||||
const path = L.location();
|
||||
const matches = path.match(/pod\/([a-f0-9]+)/i);
|
||||
|
||||
if (!matches || !matches[1]) {
|
||||
this.redirectToList();
|
||||
return;
|
||||
}
|
||||
|
||||
const podId = matches[1];
|
||||
const pod = Pod.getSingleton({ Id: podId });
|
||||
|
||||
const inspectData = await pod.inspect();
|
||||
this.data = inspectData;
|
||||
|
||||
return Pod.getSingleton(inspectData);
|
||||
},
|
||||
|
||||
async render(pod) {
|
||||
if (!pod) {
|
||||
this.redirectToList();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pod = pod;
|
||||
this.tabs
|
||||
.addTab('info', _('Info'))
|
||||
.addTab('containers', _('Containers'))
|
||||
.addTab('stats', _('Stats'))
|
||||
.addTab('ps', _('Processes'))
|
||||
.addTab('inspect', _('Inspect'));
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.renderInfoTab();
|
||||
this.renderContainersTab();
|
||||
this.renderStatsTab();
|
||||
this.renderProcessesTab();
|
||||
this.renderInspectTab();
|
||||
});
|
||||
|
||||
return this.super('render', []);
|
||||
},
|
||||
|
||||
createHeader() {
|
||||
return this.super('createHeader', [
|
||||
this.pod.getName(),
|
||||
this.pod.isRunning(),
|
||||
this.pod.isStopped(),
|
||||
this.pod.isPaused(),
|
||||
]);
|
||||
},
|
||||
|
||||
stopStreams() {
|
||||
this.getTabInstance('stats')?.onTabInactive();
|
||||
this.getTabInstance('ps')?.onTabInactive();
|
||||
},
|
||||
|
||||
async renderInfoTab() {
|
||||
const content = await PodInfoTab.render(this.pod);
|
||||
this.renderTab('info', content);
|
||||
},
|
||||
|
||||
async renderContainersTab() {
|
||||
const content = await PodContainersTab.render(this.pod);
|
||||
this.renderTab('containers', content);
|
||||
},
|
||||
|
||||
async renderStatsTab() {
|
||||
const content = await PodStatsTab.render(this.pod);
|
||||
this.renderTab('stats', content);
|
||||
},
|
||||
|
||||
async renderProcessesTab() {
|
||||
const content = await PodProcessesTab.render(this.pod);
|
||||
this.renderTab('ps', content);
|
||||
},
|
||||
|
||||
renderInspectTab() {
|
||||
this.renderTab('inspect', new podmanUI.JsonArea(this.data).render());
|
||||
},
|
||||
|
||||
async handleStart() {
|
||||
if (this.pod.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Start pod'));
|
||||
|
||||
if (this.pod.isPaused()) {
|
||||
this.pod.unpause().then(() => window.location.reload());
|
||||
return;
|
||||
}
|
||||
|
||||
this.pod.start().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleStop() {
|
||||
if (!this.pod.isRunning() && !this.pod.isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Stop pod'));
|
||||
this.stopStreams();
|
||||
this.pod.stop().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleRestart() {
|
||||
this.loading(_('Restart pod'));
|
||||
this.stopStreams();
|
||||
this.pod.restart().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handlePause() {
|
||||
if (!this.pod.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(_('Pause pod'));
|
||||
this.stopStreams();
|
||||
this.pod.pause().then(() => window.location.reload());
|
||||
},
|
||||
|
||||
async handleRemove() {
|
||||
this.confirm([
|
||||
E('p', {}, _('Are you sure to remove pod?')),
|
||||
], async () => {
|
||||
this.loading(_('Remove pod'));
|
||||
this.pod.remove().then(() => this.redirectToList());
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
:root {
|
||||
--podman-terminal-bg: #1a1a1a;
|
||||
--podman-terminal-fg: #0f0;
|
||||
--warn-background-color-dark: rgba(166, 141, 69, 0.2);
|
||||
}
|
||||
|
||||
.overview-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1.3125rem;
|
||||
}
|
||||
|
||||
div.cbi-tooltip-container {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
div.cbi-tooltip-container div.cbi-tooltip {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.overview-card-link,
|
||||
.overview-card-link:hover,
|
||||
.overview-card-link:focus {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
justify-content: space-between;
|
||||
min-height: 120px;
|
||||
padding: 1.3125rem;
|
||||
margin: 0 0 .625rem 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.overview-card-link .card-link-header {
|
||||
justify-content: space-between;
|
||||
margin-bottom: .625rem;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.overview-card-link .card-link-title {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.overview-card-link .card-link-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.overview-card-link .card-link-headline {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.overview-card-link .card-link-text {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: .3125rem;
|
||||
}
|
||||
|
||||
.overview-card-containers {
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.overview-card-containers .card-link-headline {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.overview-card-pods {
|
||||
border-left: 4px solid #2ecc71;
|
||||
}
|
||||
|
||||
.overview-card-pods .card-link-headline {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.overview-card-images {
|
||||
border-left: 4px solid #9b59b6;
|
||||
}
|
||||
|
||||
.overview-card-images .card-link-headline {
|
||||
color: #9b59b6;
|
||||
}
|
||||
|
||||
.overview-card-networks {
|
||||
border-left: 4px solid #e67e22;
|
||||
}
|
||||
|
||||
.overview-card-networks .card-link-headline {
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.overview-card-volumes {
|
||||
border-left: 4px solid #34495e;
|
||||
}
|
||||
|
||||
.overview-card-volumes .card-link-headline {
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.border-0 {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.align-start {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gap-5 {
|
||||
gap: .3125rem;
|
||||
}
|
||||
|
||||
.cbi-button-active {
|
||||
background: linear-gradient(lightgray, lightgray 25%, var(--background-color-low));
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.85em;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
border-radius: .3125rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.badge.running {
|
||||
background-color: var(--success-color-high);
|
||||
}
|
||||
|
||||
.badge.exited {
|
||||
background-color: var(--error-color-high);
|
||||
}
|
||||
|
||||
.badge.created {
|
||||
background-color: var(--background-color-low);
|
||||
}
|
||||
|
||||
.badge.paused {
|
||||
background-color: var(--warning-color-high, #d68a00);
|
||||
}
|
||||
|
||||
.badge.degraded {
|
||||
background-color: var(--warning-color-high, #d68a00);
|
||||
}
|
||||
|
||||
.badge.stopped,
|
||||
.badge.dead {
|
||||
background-color: var(--error-color-high);
|
||||
}
|
||||
|
||||
.badge.running,
|
||||
.badge.error,
|
||||
.badge.exited,
|
||||
.badge.paused,
|
||||
.badge.degraded,
|
||||
.badge.stopped,
|
||||
.badge.dead {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
border-bottom: 1px dotted;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal.loading-modal {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.cbi-checkbox + label {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success-color-high);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error-color-high);
|
||||
}
|
||||
|
||||
.mr-xs {
|
||||
margin-right: .3125rem;
|
||||
}
|
||||
|
||||
.ml-xs {
|
||||
margin-left: .3125rem;
|
||||
}
|
||||
|
||||
.ml-sm {
|
||||
margin-left: .625rem;
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: .625rem;
|
||||
}
|
||||
|
||||
.mb-xs {
|
||||
margin-bottom: .3125rem;
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: .625rem;
|
||||
}
|
||||
|
||||
.p-sm {
|
||||
padding: .625rem;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.gap-xs {
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: .625rem;
|
||||
}
|
||||
|
||||
.editable-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.table-list>tr>td:first-of-type {
|
||||
width: 33%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code-area,
|
||||
.json-area,
|
||||
.terminal-area {
|
||||
max-height: 600px;
|
||||
font-weight: 100;
|
||||
overflow: auto !important;
|
||||
white-space: pre !important;
|
||||
display: block;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.json-area .key {
|
||||
color: #660066;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.json-area .string {
|
||||
color: #007700;
|
||||
}
|
||||
|
||||
.json-area .boolean {
|
||||
color: #aa5500;
|
||||
}
|
||||
|
||||
.json-area .null {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
.json-area .number {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
.code-area.bash .comment {
|
||||
color: #888888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-area.bash .string {
|
||||
color: #007700;
|
||||
}
|
||||
|
||||
.code-area.bash .variable {
|
||||
color: #aa5500;
|
||||
}
|
||||
|
||||
.code-area.bash .keyword {
|
||||
color: #0000ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code-area.bash .number {
|
||||
color: #ff4500;
|
||||
}
|
||||
|
||||
.terminal-area {
|
||||
min-height: 100px;
|
||||
background: var(--podman-terminal-bg);
|
||||
color: var(--podman-terminal-fg);
|
||||
}
|
||||
|
||||
.autostart-enabled {
|
||||
color: var(--success-color-high);
|
||||
}
|
||||
|
||||
.autostart-none,
|
||||
.autostart-disabled {
|
||||
color: var(--text-color-medium);
|
||||
}
|
||||
|
||||
.autostart-missing {
|
||||
color: var(--warn-color-high);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.autostart-error {
|
||||
color: var(--error-color-high);
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.cbi-button-none {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.container-toolbar .d-flex {
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.checkbox-with-label .cbi-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container-icon-disconnect {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#log-viewer {
|
||||
height: 600px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#log-lines {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#log-since,
|
||||
#log-until {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
#log-lines input,
|
||||
#log-since input,
|
||||
#log-until input {
|
||||
width: inherit;
|
||||
}
|
||||
|
||||
.cbi-section-table-select {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th[data-widget="Podman.Form.SelectDummyValue"] {
|
||||
padding: .625rem .625rem 8px !important;
|
||||
}
|
||||
|
||||
th[data-widget="Podman.Form.SelectDummyValue"] .cbi-checkbox {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
td[data-widget="Podman.Form.SelectDummyValue"] {
|
||||
padding: .625rem .625rem 7px !important;
|
||||
}
|
||||
|
||||
td[data-widget="Podman.Form.SelectDummyValue"] .cbi-checkbox {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.podman-stream-log {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
background: #1e1e1e;
|
||||
color: #e8e8e8;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.podman-stream-log .log-line {
|
||||
white-space: pre;
|
||||
word-break: keep-all;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.utils as utils';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form.pod as PodmanFormPod';
|
||||
'require podman.model.Container as Container';
|
||||
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'pods',
|
||||
|
||||
title: _('Pods'),
|
||||
titleSingle: _('Pod'),
|
||||
|
||||
async load() {
|
||||
return podmanRPC.pods.list();
|
||||
},
|
||||
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'Id';
|
||||
this.section.createForm = PodmanFormPod;
|
||||
this.section.toolbarExtraButtons = [
|
||||
new podmanUI.Button('►', ui.createHandlerFn(this, 'handleStart')).render(),
|
||||
new podmanUI.Button('◼', ui.createHandlerFn(this, 'handleStop')).render(),
|
||||
new podmanUI.Button('↻', ui.createHandlerFn(this, 'handleRestart')).render(),
|
||||
new podmanUI.Button('❚❚', ui.createHandlerFn(this, 'handlePause')).render(),
|
||||
];
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Name', _('Name'));
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
|
||||
o.cfgdatavalue = (pod) => pod.getDetailLink(utils.truncate(pod.getID(), 16));
|
||||
o.width = '20%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Status', _('Status'));
|
||||
o.cfgdatavalue = (pod) => pod.getStatusBadge();
|
||||
o.width = '10%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Containers', _('Containers'));
|
||||
o.cfgdatavalue = (pod) => this.renderContainersCell(pod);
|
||||
o.width = '12%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Networks', _('Networks'));
|
||||
o.cfgdatavalue = (pod) => {
|
||||
const nets = pod.getNetworks();
|
||||
return nets.length > 0 ? nets.join(', ') : '-';
|
||||
};
|
||||
o.width = '15%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DateDummyValue, 'Created', _('Created'));
|
||||
o.width = '20%';
|
||||
},
|
||||
|
||||
renderContainersCell(pod) {
|
||||
const count = pod.getContainers().length;
|
||||
if (count === 0) {
|
||||
return E('span', {}, '0');
|
||||
}
|
||||
|
||||
return E('a', {
|
||||
href: '#',
|
||||
class: 'text-bold',
|
||||
click: (ev) => {
|
||||
ev.preventDefault();
|
||||
this.handleShowContainers(pod);
|
||||
}
|
||||
}, `${count} ▸`);
|
||||
},
|
||||
|
||||
handleShowContainers(pod) {
|
||||
const containers = pod.getContainers();
|
||||
|
||||
const headerRow = E('tr', {}, [
|
||||
E('th', {}, _('Name')),
|
||||
E('th', {}, _('ID')),
|
||||
E('th', {}, _('Status')),
|
||||
]);
|
||||
|
||||
const rows = containers.map((c) => {
|
||||
const container = Container.getSingleton(c);
|
||||
const idShort = utils.truncate(container.getID(), 10);
|
||||
const idCell = container.getDetailLink(idShort);
|
||||
|
||||
return E('tr', {}, [
|
||||
E('td', {}, container.getName()),
|
||||
E('td', {}, idCell),
|
||||
E('td', {}, container.getStateBadge()),
|
||||
]);
|
||||
});
|
||||
|
||||
// @todo: create table with helpers or show a simpler list
|
||||
const table = E('table', {
|
||||
class: 'table cbi-section-table'
|
||||
}, [
|
||||
E('thead', {}, headerRow),
|
||||
E('tbody', {}, rows.length > 0 ? rows : [
|
||||
E('tr', {}, E('td', {
|
||||
colspan: 3,
|
||||
class: 'text-center'
|
||||
}, _('No containers in this pod'))),
|
||||
]),
|
||||
]);
|
||||
|
||||
const title = _('Containers in pod %s').format(pod.getName());
|
||||
const modal = new podmanUI.Modal(title, [table]);
|
||||
modal.render();
|
||||
},
|
||||
|
||||
handleStart() {
|
||||
return this.runOnSelected(
|
||||
(pod) => pod.isPaused() ? pod.unpause() : pod.start(),
|
||||
_('Starting pod')
|
||||
);
|
||||
},
|
||||
|
||||
handleStop() {
|
||||
return this.runOnSelected((pod) => pod.stop(), _('Stopping pod'));
|
||||
},
|
||||
|
||||
handleRestart() {
|
||||
return this.runOnSelected((pod) => pod.restart(), _('Restarting pod'));
|
||||
},
|
||||
|
||||
handlePause() {
|
||||
return this.runOnSelected((pod) => pod.pause(), _('Pausing pod'));
|
||||
},
|
||||
|
||||
async runOnSelected(action, textLoad) {
|
||||
const section = this.getSection();
|
||||
const selected = section.getSelectedData();
|
||||
if (selected.length === 0) {
|
||||
return section.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const [i, pod] of selected.entries()) {
|
||||
this.loading(_('%s: %d/%d').format(textLoad, i + 1, selected.length));
|
||||
const result = await action(pod);
|
||||
if (result?.Errs?.length > 0) {
|
||||
failures.push({
|
||||
name: pod.getName(),
|
||||
errs: result.Errs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
|
||||
if (failures.length > 0) {
|
||||
const blocks = failures.map(({
|
||||
name,
|
||||
errs
|
||||
}) => E('div', {
|
||||
class: 'mb-sm'
|
||||
}, [
|
||||
E('strong', {}, name + ':'),
|
||||
E('ul', {
|
||||
class: 'm-0'
|
||||
}, errs.map((e) => E('li', {}, e))),
|
||||
]));
|
||||
podmanUI.alert(blocks, 'error');
|
||||
}
|
||||
|
||||
section.handleRefresh();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
'require form';
|
||||
|
||||
'require podman.utils as utils';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form.secret as PodmanFormSecret';
|
||||
|
||||
const SECRET_LABELS_TRUNCATE_LEN = 30;
|
||||
|
||||
/**
|
||||
* Manage podman secrets
|
||||
*/
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'secrets',
|
||||
|
||||
title: _('Secrets'),
|
||||
titleSingle: _('Secret'),
|
||||
|
||||
/**
|
||||
* Load secret data
|
||||
*/
|
||||
async load() {
|
||||
return podmanRPC.secrets.list();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update list section
|
||||
*/
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'ID';
|
||||
this.section.createForm = PodmanFormSecret;
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.LinkDummyValue, 'Name', _('Name'));
|
||||
o.cfgdatavalue = (secret) => secret.getName();
|
||||
o.click = (_value, secret) => this.section.handleInspect(secret, ['SecretData']);
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Driver', _('Driver'));
|
||||
o.cfgdatavalue = (secret) => secret.getDriver();
|
||||
o.width = '10%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Labels', _('Labels'));
|
||||
o.cfgdatavalue = (secret) => {
|
||||
const labels = secret.getLabels();
|
||||
const keys = Object.keys(labels);
|
||||
return keys.length ? keys.map((key) => `${key}=${labels[key]}`).join(', ') : '-';
|
||||
};
|
||||
o.cfgformatter = (labels) => utils.truncate(labels, SECRET_LABELS_TRUNCATE_LEN);
|
||||
o.cfgtt = (labels) => labels.length > SECRET_LABELS_TRUNCATE_LEN ? labels : '';
|
||||
o.width = '20%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DateDummyValue, 'CreatedAt', _('Created'));
|
||||
o.width = '20%';
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
'require ui';
|
||||
'require fs';
|
||||
|
||||
'require podman.utils as utils';
|
||||
'require podman.rpc as podmanRPC';
|
||||
'require podman.ui as podmanUI';
|
||||
'require podman.form as podmanForm';
|
||||
'require podman.view as podmanView';
|
||||
'require podman.form.volume as PodmanFormVolume';
|
||||
'require podman.form.volumeImport as PodmanFormVolumeImport';
|
||||
|
||||
const VOLUME_MOUNT_TRUNCATE_LEN = 30;
|
||||
|
||||
return podmanView.list.extend({
|
||||
sectionName: 'volumes',
|
||||
|
||||
title: _('Volumes'),
|
||||
titleSingle: _('Volume'),
|
||||
|
||||
async load() {
|
||||
return podmanRPC.volumes.list();
|
||||
},
|
||||
|
||||
async updateSection() {
|
||||
this.section.selectKey = 'Name';
|
||||
this.section.createForm = PodmanFormVolume;
|
||||
this.section.toolbarExtraButtons = [
|
||||
new podmanUI.ButtonNew(_('Import'), {
|
||||
click: ui.createHandlerFn(this, 'handleImport'),
|
||||
}).render(),
|
||||
new podmanUI.ButtonNew(_('Export'), {
|
||||
click: ui.createHandlerFn(this, 'handleExport'),
|
||||
}).render(),
|
||||
];
|
||||
|
||||
let o;
|
||||
|
||||
o = this.section.option(podmanForm.field.LinkDummyValue, 'VolumeName', _('Name'));
|
||||
o.cfgdatavalue = (volume) => utils.truncate(volume.getName(), 20);
|
||||
o.cfgtt = (_cfg, volume) => volume.getName().length > 20 ? volume.getName() : '';
|
||||
o.click = (_cfg, volume) => this.section.handleInspect(volume);
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Driver', _('Driver'));
|
||||
o.width = '10%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DummyValue, 'Mountpoint', _('Mountpoint'));
|
||||
o.cfgformatter = (mountpoint) => utils.truncate(mountpoint, VOLUME_MOUNT_TRUNCATE_LEN);
|
||||
o.cfgtt = (mountpoint) => mountpoint.length > VOLUME_MOUNT_TRUNCATE_LEN ? mountpoint : '';
|
||||
o.width = '35%';
|
||||
|
||||
o = this.section.option(podmanForm.field.DateDummyValue, 'CreatedAt', _('Created'));
|
||||
o.width = '20%';
|
||||
},
|
||||
|
||||
handleImport() {
|
||||
const fileInput = E('input', {
|
||||
type: 'file',
|
||||
accept: '.tar,.tar.gz,.tgz',
|
||||
class: 'hidden'
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async (ev) => {
|
||||
document.body.removeChild(fileInput);
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const importForm = new PodmanFormVolumeImport.init();
|
||||
const formContent = await importForm.render(file);
|
||||
const title = _('Import from archive: %s').format(file.name);
|
||||
const modal = new podmanUI.Modal(title, [ formContent ]);
|
||||
modal.getButtons = () => [
|
||||
modal.getCloseButton(),
|
||||
new podmanUI.ButtonNew(_('Import'), {
|
||||
click: async () => {
|
||||
await importForm.handleCreate();
|
||||
await this.section.handleRefresh();
|
||||
},
|
||||
type: 'positive',
|
||||
}).render(),
|
||||
];
|
||||
modal.render();
|
||||
});
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
},
|
||||
|
||||
async handleExport() {
|
||||
const selected = this.section.getSelectedData();
|
||||
|
||||
if (selected.length === 0) {
|
||||
return this.section.showNoneSelectedWarning();
|
||||
}
|
||||
|
||||
let errors = 0;
|
||||
for (const [index, volume] of selected.entries()) {
|
||||
this.loading(_('Exporting selected volumes: %d/%d').format(index + 1, selected.length));
|
||||
|
||||
const volumeName = volume.getName();
|
||||
try {
|
||||
const blob = await fs.exec_direct('/usr/libexec/podman-api', ['volume_export', volumeName], 'blob');
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = E('a', { href: url, download: `${volumeName}.tar` });
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
} catch (err) {
|
||||
podmanUI.alert(_('Error: %s').format(err.message), 'error');
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
|
||||
if (errors === 0)
|
||||
this.success(_('All volumes exported successfully'));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
@@ -0,0 +1,3 @@
|
||||
config luci_podman 'globals'
|
||||
option init_start_priority '100'
|
||||
option socket_path 'unix:///run/podman/podman.sock'
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Handle config: podman-opkg is the shipped default, podman is the live config
|
||||
if [ ! -f /etc/config/luci-podman ]; then
|
||||
mv /etc/config/luci-podman-opkg /etc/config/luci-podman
|
||||
else
|
||||
echo "Existing /etc/config/luci-podman preserved. New defaults at /etc/config/luci-podman-opkg"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
'use strict';
|
||||
|
||||
import { popen, writefile, unlink } from 'fs';
|
||||
import * as podman_socket from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { validate_int, validate_name, require_param } from 'luci.podman_validate'; // ucode-lsp disable
|
||||
|
||||
function die(msg) {
|
||||
warn(`${msg}\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Volume export/import here shells out to the local `podman` CLI, which can
|
||||
// only reach a Podman daemon running on the same host. When the user has
|
||||
// configured a remote TCP Podman, refuse upfront rather than failing weirdly.
|
||||
if (podman_socket.is_remote())
|
||||
die(`Volume operations are not supported with a remote Podman (${podman_socket.get_dest()}); the CLI helper requires a local daemon.`);
|
||||
|
||||
// --- Main ---
|
||||
|
||||
let action = ARGV[0];
|
||||
|
||||
if (action === 'volume_export') {
|
||||
let volumeName = ARGV[1];
|
||||
let err = require_param('volumeName', volumeName) || validate_name(volumeName);
|
||||
if (err) die(err);
|
||||
|
||||
// volume export outputs a tar stream - use podman CLI directly
|
||||
let escaped = replace(volumeName, /'/g, "'\\''");
|
||||
let p = popen(`/usr/bin/podman volume export '${escaped}'`, 'r');
|
||||
if (!p)
|
||||
die('Failed to run podman volume export');
|
||||
|
||||
let chunk;
|
||||
while ((chunk = p.read(65536)) != null && length(chunk) > 0)
|
||||
print(chunk);
|
||||
p.close();
|
||||
}
|
||||
else if (action === 'volume_import') {
|
||||
let volumeName = ARGV[1];
|
||||
let compressed = ARGV[2];
|
||||
let volumeData = ARGV[3];
|
||||
|
||||
let err = require_param('volumeName', volumeName) || validate_name(volumeName)
|
||||
|| require_param('volumeData', volumeData)
|
||||
|| require_param('compressed', compressed) || validate_int(compressed);
|
||||
if (err) die(err);
|
||||
|
||||
// Validate base64 content
|
||||
if (!match(volumeData, /^[A-Za-z0-9+\/]*={0,2}$/) || (length(volumeData) % 4) !== 0)
|
||||
die('Invalid Base64 content');
|
||||
|
||||
let decoded = b64dec(volumeData);
|
||||
if (decoded == null)
|
||||
die('Failed to decode import data');
|
||||
|
||||
// Decompress if needed
|
||||
if (compressed === '1') {
|
||||
let tp = popen('mktemp /tmp/podman_import_XXXXXX', 'r');
|
||||
let tmppath = tp ? trim(tp.read('all') || '') : '';
|
||||
if (tp) tp.close();
|
||||
if (!tmppath)
|
||||
die('Failed to create temp file');
|
||||
|
||||
writefile(tmppath, decoded);
|
||||
|
||||
let p = popen(`gunzip -c '${tmppath}'`, 'r');
|
||||
decoded = p ? p.read('all') : null;
|
||||
if (p) p.close();
|
||||
unlink(tmppath);
|
||||
|
||||
if (!decoded)
|
||||
die('Failed to decompress import data');
|
||||
}
|
||||
|
||||
// Ensure volume exists
|
||||
let escaped = replace(volumeName, /'/g, "'\\''");
|
||||
let p = popen(`/usr/bin/podman volume exists '${escaped}' 2>/dev/null; echo $?`, 'r');
|
||||
let rc = p ? trim(p.read('all') || '') : '';
|
||||
if (p) p.close();
|
||||
|
||||
if (rc !== '0') {
|
||||
p = popen(`/usr/bin/podman volume create '${escaped}' 2>/dev/null`, 'r');
|
||||
if (p) { p.read('all'); p.close(); }
|
||||
}
|
||||
|
||||
// Import via stdin pipe
|
||||
p = popen(`/usr/bin/podman volume import '${escaped}' -`, 'w');
|
||||
if (!p)
|
||||
die('Failed to start volume import');
|
||||
p.write(decoded);
|
||||
let exit_code = p.close();
|
||||
|
||||
if (exit_code) {
|
||||
// Cleanup on failure
|
||||
popen(`/usr/bin/podman volume rm '${escaped}' 2>/dev/null`, 'r')?.close();
|
||||
die('Import failed');
|
||||
}
|
||||
}
|
||||
else {
|
||||
die(`Unknown action: ${action}`);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"admin/podman": {
|
||||
"title": "Podman",
|
||||
"order": 60,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [
|
||||
"luci-app-podman"
|
||||
]
|
||||
}
|
||||
},
|
||||
"admin/podman/overview": {
|
||||
"title": "Overview",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/overview"
|
||||
}
|
||||
},
|
||||
"admin/podman/containers": {
|
||||
"title": "Containers",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/containers"
|
||||
}
|
||||
},
|
||||
"admin/podman/container/*": {
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/container"
|
||||
}
|
||||
},
|
||||
"admin/podman/pods": {
|
||||
"title": "Pods",
|
||||
"order": 25,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/pods"
|
||||
}
|
||||
},
|
||||
"admin/podman/pod/*": {
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/pod"
|
||||
}
|
||||
},
|
||||
"admin/podman/images": {
|
||||
"title": "Images",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/images"
|
||||
}
|
||||
},
|
||||
"admin/podman/networks": {
|
||||
"title": "Networks",
|
||||
"order": 40,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/networks"
|
||||
}
|
||||
},
|
||||
"admin/podman/volumes": {
|
||||
"title": "Volumes",
|
||||
"order": 50,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/volumes"
|
||||
}
|
||||
},
|
||||
"admin/podman/secrets": {
|
||||
"title": "Secrets",
|
||||
"order": 60,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "podman/secrets"
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/logs/*": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "container_logs"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/top/*": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "container_top"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/stats/*": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "container_stats"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/pull": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "image_pull"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/pod_top/*": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "pod_top"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
},
|
||||
"admin/podman/stream/pod_stats/*": {
|
||||
"action": {
|
||||
"type": "function",
|
||||
"module": "luci.controller.podman",
|
||||
"function": "pod_stats"
|
||||
},
|
||||
"auth": {
|
||||
"methods": [
|
||||
"cookie:sysauth_https",
|
||||
"cookie:sysauth_http"
|
||||
],
|
||||
"login": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
# Podman's restart policy handles runtime restarts to avoid conflicts
|
||||
|
||||
START={start_priority}
|
||||
STOP=20
|
||||
USE_PROCD=1
|
||||
|
||||
NAME={script_name}
|
||||
PROG=/usr/bin/podman
|
||||
|
||||
case "{name}" in
|
||||
*[!A-Za-z0-9._-]*|'')
|
||||
logger -t "${NAME}" "Invalid container name '{name}'; only [A-Za-z0-9._-] allowed"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
start_service() {
|
||||
# Register procd instance - socket wait happens inside command (non-blocking)
|
||||
procd_open_instance "${NAME}"
|
||||
procd_set_param command /bin/sh -c "
|
||||
# Wait for Podman socket with timeout (120s)
|
||||
max_wait=120
|
||||
count=0
|
||||
logger -t ${NAME} 'Waiting for Podman socket...'
|
||||
while [ \"\$count\" -lt \"\$max_wait\" ]; do
|
||||
[ -S /run/podman/podman.sock ] && break
|
||||
sleep 1
|
||||
count=\$((count + 1))
|
||||
done
|
||||
if [ ! -S /run/podman/podman.sock ]; then
|
||||
logger -t ${NAME} 'Timeout: Podman socket not available'
|
||||
exit 1
|
||||
fi
|
||||
logger -t ${NAME} \"Podman socket available after \${count}s\"
|
||||
|
||||
# Check container exists
|
||||
if ! $PROG container exists '{name}' 2>/dev/null; then
|
||||
logger -t ${NAME} 'Container {name} does not exist'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start container if not running
|
||||
if ! $PROG container inspect '{name}' --format '{{.State.Running}}' 2>/dev/null | grep -q true; then
|
||||
logger -t ${NAME} 'Starting container {name}'
|
||||
$PROG start '{name}' || exit 1
|
||||
else
|
||||
logger -t ${NAME} 'Container {name} already running'
|
||||
fi
|
||||
"
|
||||
# Bounded retries: if S99podman's tie-break puts it after this script,
|
||||
# the socket wait above may time out on a slow first boot. A capped
|
||||
# respawn gives extra 120s windows without looping forever.
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
logger -t ${NAME} "Stopping container {name}"
|
||||
$PROG stop "{name}" 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
import { open, unlink } from 'fs';
|
||||
import { urlencode, ENCODE_FULL } from 'lucihttp'; // ucode-lsp disable
|
||||
import * as podman_socket from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { API_BASE } from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { build_request, parse_status, read_headers } from 'luci.podman_http'; // ucode-lsp disable
|
||||
|
||||
const BLOCKSIZE = 4096;
|
||||
|
||||
const reference = ARGV[0];
|
||||
const logfile = ARGV[1];
|
||||
const pidfile = ARGV[2];
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
*/
|
||||
function write_error(msg) {
|
||||
let f = open(logfile, 'a');
|
||||
if (f) {
|
||||
f.write('{"error":"' + replace(replace(msg, /\\/g, '\\\\'), /"/g, '\\"') + '"}\n');
|
||||
f.flush();
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} code
|
||||
*/
|
||||
function cleanup(code) {
|
||||
unlink(pidfile);
|
||||
exit(code ?? 0);
|
||||
}
|
||||
|
||||
if (!reference || !logfile || !pidfile)
|
||||
exit(1);
|
||||
|
||||
// Write own PID to pidfile so the streaming endpoint can check if we are running
|
||||
let ps = open('/proc/self/stat', 'r');
|
||||
if (ps) {
|
||||
let pid = +(split(ps.read(200) || '0', ' ')[0]);
|
||||
ps.close();
|
||||
let pf = open(pidfile, 'w');
|
||||
if (pf) { pf.write(pid + '\n'); pf.close(); }
|
||||
}
|
||||
|
||||
// Connect to Podman socket
|
||||
let sock = podman_socket.connect();
|
||||
if (!sock) {
|
||||
write_error('Cannot connect to Podman socket');
|
||||
cleanup(1);
|
||||
}
|
||||
|
||||
let encoded = urlencode(reference, ENCODE_FULL);
|
||||
|
||||
sock.send(build_request(
|
||||
'POST',
|
||||
sprintf('%s/images/pull?reference=%s&quiet=false', API_BASE, encoded),
|
||||
null
|
||||
));
|
||||
|
||||
// Read HTTP response headers (blocking recv - no uloop needed in standalone process)
|
||||
let lf = null;
|
||||
|
||||
let hdrs = read_headers(sock, BLOCKSIZE);
|
||||
if (!hdrs) {
|
||||
write_error('Podman closed connection before responding');
|
||||
sock.close();
|
||||
cleanup(1);
|
||||
}
|
||||
|
||||
let code = parse_status(hdrs.header_buf) || 502;
|
||||
let body = hdrs.body_remainder;
|
||||
|
||||
if (code !== 200) {
|
||||
let parsed = null;
|
||||
try { parsed = json(body); } catch(e) {}
|
||||
let msg = (parsed?.message) || (parsed?.cause)
|
||||
|| replace(body, /\s+$/, '')
|
||||
|| sprintf('Podman error %d', code);
|
||||
write_error(msg);
|
||||
sock.close();
|
||||
cleanup(1);
|
||||
}
|
||||
|
||||
lf = open(logfile, 'a');
|
||||
if (!lf) { sock.close(); cleanup(1); }
|
||||
|
||||
// Write any body bytes that arrived together with headers
|
||||
if (length(body)) { lf.write(body); lf.flush(); }
|
||||
|
||||
// Stream response body to logfile - Podman sends NDJSON, we write it as-is
|
||||
while (true) {
|
||||
let chunk = sock.recv(BLOCKSIZE);
|
||||
if (!chunk) break; // EOF or error - Podman finished
|
||||
lf.write(chunk);
|
||||
lf.flush();
|
||||
}
|
||||
|
||||
lf.close();
|
||||
sock.close();
|
||||
cleanup(0);
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"luci-app-podman": {
|
||||
"description": "Grant access to Podman RPC methods",
|
||||
"read": {
|
||||
"cgi-io": [
|
||||
"exec"
|
||||
],
|
||||
"file": {
|
||||
"/usr/libexec/podman-api volume_export *": [
|
||||
"exec"
|
||||
]
|
||||
},
|
||||
"ubus": {
|
||||
"podman": [
|
||||
"containers_list",
|
||||
"container_inspect",
|
||||
"container_stats",
|
||||
"container_top",
|
||||
"container_healthcheck_run",
|
||||
"images_list",
|
||||
"image_inspect",
|
||||
"image_manifest_inspect",
|
||||
"networks_list",
|
||||
"network_inspect",
|
||||
"volumes_list",
|
||||
"volume_inspect",
|
||||
"pods_list",
|
||||
"pod_inspect",
|
||||
"secrets_list",
|
||||
"secret_inspect",
|
||||
"system_df",
|
||||
"version",
|
||||
"info",
|
||||
"init_script_status",
|
||||
"init_script_show",
|
||||
"system_debug"
|
||||
]
|
||||
},
|
||||
"uci": [
|
||||
"luci-podman",
|
||||
"firewall",
|
||||
"network",
|
||||
"dhcp"
|
||||
]
|
||||
},
|
||||
"write": {
|
||||
"cgi-io": [
|
||||
"exec",
|
||||
"upload"
|
||||
],
|
||||
"file": {
|
||||
"/tmp/podman-import": [
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"ubus": {
|
||||
"podman": [
|
||||
"container_start",
|
||||
"container_stop",
|
||||
"container_restart",
|
||||
"container_pause",
|
||||
"container_unpause",
|
||||
"container_remove",
|
||||
"container_create",
|
||||
"container_rename",
|
||||
"container_update",
|
||||
"container_healthcheck_run",
|
||||
"image_remove",
|
||||
"image_pull",
|
||||
"network_create",
|
||||
"network_remove",
|
||||
"network_connect",
|
||||
"network_disconnect",
|
||||
"volume_create",
|
||||
"volume_remove",
|
||||
"volume_import",
|
||||
"pod_start",
|
||||
"pod_stop",
|
||||
"pod_kill",
|
||||
"pod_restart",
|
||||
"pod_pause",
|
||||
"pod_unpause",
|
||||
"pod_remove",
|
||||
"pod_create",
|
||||
"secret_create",
|
||||
"secret_remove",
|
||||
"system_prune",
|
||||
"init_script_generate",
|
||||
"init_script_set_enabled",
|
||||
"init_script_remove"
|
||||
]
|
||||
},
|
||||
"uci": [
|
||||
"luci-podman",
|
||||
"firewall",
|
||||
"network",
|
||||
"dhcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
'use strict';
|
||||
|
||||
// --- Table of Contents ---
|
||||
//
|
||||
// Infrastructure (lines 30-225)
|
||||
// Configuration, validation, HTTP client, helpers
|
||||
//
|
||||
// RPC Methods (line 228+)
|
||||
// Containers: list inspect start stop restart remove stats create rename
|
||||
// update healthcheck top
|
||||
// Images: list inspect remove manifest_inspect pull
|
||||
// Networks: list inspect remove create connect disconnect
|
||||
// Volumes: list inspect remove create import
|
||||
// Pods: list inspect start stop restart pause unpause remove create stats
|
||||
// Secrets: list inspect create remove
|
||||
// System: df prune version info debug
|
||||
// Init Scripts: generate show status set_enabled remove
|
||||
// (exempt from socket check - operate on /etc/init.d/ only)
|
||||
//
|
||||
// Socket wrapper (end of file)
|
||||
|
||||
import { readfile, writefile, popen, stat, chmod, unlink, glob, access } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { urlencode, ENCODE_FULL } from 'lucihttp'; // ucode-lsp disable
|
||||
import { init_enabled, init_action } from 'luci.sys'; // ucode-lsp disable
|
||||
import * as podman_socket from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { API_BASE } from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import {
|
||||
validate_id, validate_name, validate_image_ref, validate_query_params,
|
||||
validate_restart_policy, require_param, BODY_KEYS
|
||||
} from 'luci.podman_validate'; // ucode-lsp disable
|
||||
import {
|
||||
build_request, parse_status, parse_content_length, read_headers
|
||||
} from 'luci.podman_http'; // ucode-lsp disable
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
const uci = cursor();
|
||||
const _prio = uci.get('luci-podman', 'globals', 'init_start_priority');
|
||||
// Capped at 99: procd sorts rc.d symlinks as plain strings, so a 3-digit
|
||||
// value like 100 sorts *before* 2-digit priorities (e.g. network at 19-20).
|
||||
const INIT_START_PRIORITY = (type(_prio) === 'string' && match(_prio, /^([0-9]|[1-9][0-9])$/)) ? _prio : '99';
|
||||
uci.unload('luci-podman');
|
||||
|
||||
// "podman" is a strict string prefix of this, so generated init scripts
|
||||
// always sort after the real /etc/init.d/podman at any tied START value.
|
||||
const INIT_SCRIPT_PREFIX = 'podman-container-';
|
||||
|
||||
// Validators come from luci.podman_validate (imported above).
|
||||
|
||||
// --- HTTP Client ---
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {string} path
|
||||
* @param {string} body
|
||||
* @param {bool} raw
|
||||
*/
|
||||
function podman_request(method, path, body, raw) {
|
||||
let sock = podman_socket.connect();
|
||||
if (!sock)
|
||||
return { error: 'Failed to connect to Podman socket' };
|
||||
|
||||
sock.send(build_request(method, path, body));
|
||||
|
||||
let hdrs = read_headers(sock);
|
||||
if (!hdrs) {
|
||||
sock.close();
|
||||
return { error: 'Empty response from Podman API' };
|
||||
}
|
||||
let header_buf = hdrs.header_buf;
|
||||
let body_remainder = hdrs.body_remainder;
|
||||
let chunk = '';
|
||||
|
||||
let status_code = parse_status(header_buf);
|
||||
|
||||
// For 204 No Content (success with no body - e.g., start/stop/restart)
|
||||
if (status_code === 204) {
|
||||
sock.close();
|
||||
if (raw) return { status: 204, body: '' };
|
||||
return {};
|
||||
}
|
||||
|
||||
let content_length = parse_content_length(header_buf);
|
||||
|
||||
// Read body
|
||||
let resp_body = `${body_remainder}`;
|
||||
if (content_length >= 0) {
|
||||
// Read exactly content_length bytes
|
||||
while (length(resp_body) < content_length) {
|
||||
chunk = sock.recv(65536);
|
||||
if (!chunk)
|
||||
break;
|
||||
resp_body += `${chunk}`;
|
||||
}
|
||||
} else {
|
||||
// No Content-Length - read until EOF
|
||||
while (true) {
|
||||
chunk = sock.recv(65536);
|
||||
if (!chunk)
|
||||
break;
|
||||
resp_body += `${chunk}`;
|
||||
}
|
||||
}
|
||||
|
||||
sock.close();
|
||||
|
||||
// Raw mode: return status + body without JSON parsing
|
||||
if (raw) {
|
||||
if (status_code >= 400)
|
||||
return { error: trim(resp_body || `HTTP ${status_code}`) };
|
||||
return { status: status_code, body: resp_body };
|
||||
}
|
||||
|
||||
// Try to parse JSON body
|
||||
if (resp_body !== '') {
|
||||
let parsed = null;
|
||||
try { parsed = json(resp_body); } catch(e) {}
|
||||
|
||||
if (parsed != null) {
|
||||
// Wrap arrays in { data: [...] } for frontend compatibility
|
||||
if (type(parsed) === 'array')
|
||||
return { data: parsed };
|
||||
return parsed;
|
||||
}
|
||||
// Non-JSON response (e.g., plain text error)
|
||||
if (status_code >= 400)
|
||||
return { error: trim(resp_body) };
|
||||
return { data: resp_body };
|
||||
}
|
||||
|
||||
if (status_code >= 400)
|
||||
return { error: `HTTP ${status_code}` };
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
function encode_id(id) {
|
||||
return urlencode(id, ENCODE_FULL);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
*/
|
||||
function build_bool_query(params) {
|
||||
let parts = [];
|
||||
for (let k in params) {
|
||||
if (params[k] === true || params[k] === 1)
|
||||
push(parts, `${k}=true`);
|
||||
}
|
||||
return length(parts) ? '?' + join('&', parts) : '';
|
||||
}
|
||||
|
||||
/** @param {any} data */
|
||||
function to_json_body(data) {
|
||||
return (type(data) === 'string') ? data : sprintf('%J', data);
|
||||
}
|
||||
|
||||
// --- Init Script Helpers ---
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function init_script_path(name) {
|
||||
return `/etc/init.d/${INIT_SCRIPT_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
// --- RPC Methods ---
|
||||
|
||||
const methods = {
|
||||
// ==================== Containers ====================
|
||||
|
||||
containers_list: {
|
||||
args: { query: '' },
|
||||
call: function(req) {
|
||||
let path = `${API_BASE}/containers/json`;
|
||||
if (req.args.query && req.args.query !== '') {
|
||||
let err = validate_query_params(req.args.query);
|
||||
if (err) return { error: err };
|
||||
path += `?${req.args.query}`;
|
||||
}
|
||||
return podman_request('GET', path);
|
||||
}
|
||||
},
|
||||
|
||||
container_inspect: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/containers/${encode_id(req.args.id)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
container_start: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/start`);
|
||||
}
|
||||
},
|
||||
|
||||
container_stop: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/stop`);
|
||||
}
|
||||
},
|
||||
|
||||
container_restart: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/restart`);
|
||||
}
|
||||
},
|
||||
|
||||
container_pause: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/pause`);
|
||||
}
|
||||
},
|
||||
|
||||
container_unpause: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/unpause`);
|
||||
}
|
||||
},
|
||||
|
||||
container_remove: {
|
||||
args: { id: '', force: false, depend: false },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
let query = build_bool_query({ force: req.args.force, depend: req.args.depend });
|
||||
return podman_request('DELETE', `${API_BASE}/containers/${encode_id(req.args.id)}${query}`);
|
||||
}
|
||||
},
|
||||
|
||||
container_stats: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/containers/${encode_id(req.args.id)}/stats?stream=false`);
|
||||
}
|
||||
},
|
||||
|
||||
container_create: {
|
||||
args: { data: {} },
|
||||
call: function(req) {
|
||||
let data = req.args.data;
|
||||
let err = require_param('data', data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(data);
|
||||
return podman_request('POST', `${API_BASE}/containers/create`, body);
|
||||
}
|
||||
},
|
||||
|
||||
container_rename: {
|
||||
args: { id: '', name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id)
|
||||
|| require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
let name_enc = encode_id(req.args.name);
|
||||
return podman_request('POST', `${API_BASE}/containers/${encode_id(req.args.id)}/rename?name=${name_enc}`);
|
||||
}
|
||||
},
|
||||
|
||||
container_update: {
|
||||
args: { id: '', data: {} },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id)
|
||||
|| require_param('data', req.args.data);
|
||||
if (err) return { error: err };
|
||||
|
||||
let data = req.args.data;
|
||||
if (type(data) === 'string')
|
||||
data = json(data);
|
||||
if (!data)
|
||||
return { error: 'Invalid JSON data' };
|
||||
|
||||
let id_enc = encode_id(req.args.id);
|
||||
|
||||
// Build query params for restart policy
|
||||
let query_parts = [];
|
||||
if (data.RestartPolicy) {
|
||||
let perr = validate_restart_policy(data.RestartPolicy);
|
||||
if (perr) return { error: perr };
|
||||
push(query_parts, `restartPolicy=${data.RestartPolicy}`);
|
||||
}
|
||||
if (data.RestartRetries != null)
|
||||
push(query_parts, `restartRetries=${+data.RestartRetries | 0}`);
|
||||
|
||||
let query = length(query_parts) ? '?' + join('&', query_parts) : '';
|
||||
|
||||
// Determine if body is needed (resource/health updates)
|
||||
let body_str = sprintf('%J', data);
|
||||
let has_body_fields = false;
|
||||
for (let k in data) {
|
||||
if (k in BODY_KEYS) { has_body_fields = true; break; }
|
||||
}
|
||||
|
||||
return podman_request('POST', `${API_BASE}/containers/${id_enc}/update${query}`,
|
||||
has_body_fields ? body_str : '{}');
|
||||
}
|
||||
},
|
||||
|
||||
container_healthcheck_run: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/containers/${encode_id(req.args.id)}/healthcheck`);
|
||||
}
|
||||
},
|
||||
|
||||
container_top: {
|
||||
args: { id: '', ps_args: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_id(req.args.id);
|
||||
if (err) return { error: err };
|
||||
let path = `${API_BASE}/containers/${encode_id(req.args.id)}/top`;
|
||||
if (req.args.ps_args && req.args.ps_args !== '')
|
||||
path += `?ps_args=${encode_id(req.args.ps_args)}`;
|
||||
return podman_request('GET', path);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Images ====================
|
||||
|
||||
images_list: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/images/json`);
|
||||
}
|
||||
},
|
||||
|
||||
image_inspect: {
|
||||
args: { id: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_image_ref(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/images/${encode_id(req.args.id)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
image_remove: {
|
||||
args: { id: '', force: false },
|
||||
call: function(req) {
|
||||
let err = require_param('id', req.args.id) || validate_image_ref(req.args.id);
|
||||
if (err) return { error: err };
|
||||
return podman_request('DELETE', `${API_BASE}/images/${encode_id(req.args.id)}${build_bool_query({ force: req.args.force })}`);
|
||||
}
|
||||
},
|
||||
|
||||
image_manifest_inspect: {
|
||||
args: { image: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('image', req.args.image) || validate_image_ref(req.args.image);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/manifests/${encode_id(req.args.image)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
image_pull: {
|
||||
args: { image: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('image', req.args.image) || validate_image_ref(req.args.image);
|
||||
if (err) return { error: err };
|
||||
|
||||
let image_enc = urlencode(req.args.image, ENCODE_FULL);
|
||||
let resp = podman_request('POST', `${API_BASE}/images/pull?reference=${image_enc}`, null, true);
|
||||
if (resp.error) return resp;
|
||||
|
||||
// Response is newline-delimited JSON: {"stream":"..."} lines
|
||||
// Last line contains {"images":[...],"id":"..."}
|
||||
let body = `${resp.body || ''}`;
|
||||
let output = '';
|
||||
let images = null;
|
||||
let id = null;
|
||||
|
||||
let lines = split(body, '\n');
|
||||
for (let i = 0; i < length(lines); i++) {
|
||||
let raw = lines[i];
|
||||
if (type(raw) !== 'string') continue;
|
||||
let line = trim(raw);
|
||||
if (!line) continue;
|
||||
let parsed = null;
|
||||
try { parsed = json(line); } catch(e) {}
|
||||
if (!parsed) continue;
|
||||
|
||||
if (parsed.stream)
|
||||
output += parsed.stream;
|
||||
if (parsed.images)
|
||||
images = parsed.images;
|
||||
if (parsed.id)
|
||||
id = parsed.id;
|
||||
if (parsed.error)
|
||||
return { error: parsed.error };
|
||||
}
|
||||
|
||||
return { output: output, images: images, id: id };
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Networks ====================
|
||||
|
||||
networks_list: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/networks/json`);
|
||||
}
|
||||
},
|
||||
|
||||
network_inspect: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/networks/${encode_id(req.args.name)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
network_remove: {
|
||||
args: { name: '', force: false },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('DELETE', `${API_BASE}/networks/${encode_id(req.args.name)}${build_bool_query({ force: req.args.force })}`);
|
||||
}
|
||||
},
|
||||
|
||||
network_create: {
|
||||
args: { data: {} },
|
||||
call: function(req) {
|
||||
let data = req.args.data;
|
||||
let err = require_param('data', data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(data);
|
||||
return podman_request('POST', `${API_BASE}/networks/create`, body);
|
||||
}
|
||||
},
|
||||
|
||||
network_connect: {
|
||||
args: { name: '', data: {} },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name)
|
||||
|| require_param('data', req.args.data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(req.args.data);
|
||||
return podman_request('POST', `${API_BASE}/networks/${encode_id(req.args.name)}/connect`, body);
|
||||
}
|
||||
},
|
||||
|
||||
network_disconnect: {
|
||||
args: { name: '', data: {} },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name)
|
||||
|| require_param('data', req.args.data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(req.args.data);
|
||||
return podman_request('POST', `${API_BASE}/networks/${encode_id(req.args.name)}/disconnect`, body);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Volumes ====================
|
||||
|
||||
volumes_list: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/volumes/json`);
|
||||
}
|
||||
},
|
||||
|
||||
volume_inspect: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/volumes/${encode_id(req.args.name)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
volume_remove: {
|
||||
args: { name: '', force: false },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('DELETE', `${API_BASE}/volumes/${encode_id(req.args.name)}${build_bool_query({ force: req.args.force })}`);
|
||||
}
|
||||
},
|
||||
|
||||
volume_create: {
|
||||
args: { data: {} },
|
||||
call: function(req) {
|
||||
let data = req.args.data;
|
||||
let err = require_param('data', data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(data);
|
||||
return podman_request('POST', `${API_BASE}/volumes/create`, body);
|
||||
}
|
||||
},
|
||||
|
||||
volume_import: {
|
||||
args: { name: '', compressed: false },
|
||||
call: function(req) {
|
||||
let name = req.args.name;
|
||||
let err = require_param('name', name) || validate_name(name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let filepath = '/tmp/podman-import';
|
||||
if (!stat(filepath))
|
||||
return { error: 'Upload file not found' };
|
||||
|
||||
let existing = podman_request('GET', `${API_BASE}/volumes/${encode_id(name)}/json`);
|
||||
if (!existing.Name) {
|
||||
let created = podman_request('POST', `${API_BASE}/volumes/create`,
|
||||
sprintf('%J', { Name: name }));
|
||||
if (created.error) return created;
|
||||
}
|
||||
|
||||
let cmd;
|
||||
if (req.args.compressed === true || req.args.compressed === 1)
|
||||
cmd = sprintf('gunzip -c "%s" | /usr/bin/podman volume import "%s" - 2>&1', filepath, name);
|
||||
else
|
||||
cmd = sprintf('/usr/bin/podman volume import "%s" "%s" 2>&1', name, filepath);
|
||||
|
||||
let p = popen(cmd, 'r');
|
||||
let output = p ? trim(p.read('all') || '') : '';
|
||||
let exit_code = p ? p.close() : 1;
|
||||
|
||||
unlink(filepath);
|
||||
|
||||
if (exit_code)
|
||||
return { error: output || 'Import failed' };
|
||||
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Pods ====================
|
||||
|
||||
pods_list: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/pods/json`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_inspect: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/pods/${encode_id(req.args.name)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_start: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/start`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_stop: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/stop`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_kill: {
|
||||
args: { name: '', signal: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
let sig = req.args.signal || 'SIGKILL';
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/kill?signal=${sig}`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_restart: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/restart`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_pause: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/pause`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_unpause: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('POST', `${API_BASE}/pods/${encode_id(req.args.name)}/unpause`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_remove: {
|
||||
args: { name: '', force: false },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('DELETE', `${API_BASE}/pods/${encode_id(req.args.name)}${build_bool_query({ force: req.args.force })}`);
|
||||
}
|
||||
},
|
||||
|
||||
pod_create: {
|
||||
args: { data: {} },
|
||||
call: function(req) {
|
||||
let data = req.args.data;
|
||||
let err = require_param('data', data);
|
||||
if (err) return { error: err };
|
||||
let body = to_json_body(data);
|
||||
return podman_request('POST', `${API_BASE}/pods/create`, body);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Secrets ====================
|
||||
|
||||
secrets_list: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/secrets/json`);
|
||||
}
|
||||
},
|
||||
|
||||
secret_inspect: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('GET', `${API_BASE}/secrets/${encode_id(req.args.name)}/json`);
|
||||
}
|
||||
},
|
||||
|
||||
secret_create: {
|
||||
args: { name: '', data: '', labels: {} },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name)
|
||||
|| require_param('data', req.args.data);
|
||||
if (err) return { error: err };
|
||||
let query = `?name=${encode_id(req.args.name)}`;
|
||||
let labels = req.args.labels;
|
||||
if (type(labels) === 'object' && length(labels) > 0)
|
||||
query += `&labels=${urlencode(sprintf('%J', labels), ENCODE_FULL)}`;
|
||||
return podman_request('POST', `${API_BASE}/secrets/create${query}`, `${req.args.data}`);
|
||||
}
|
||||
},
|
||||
|
||||
secret_remove: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
return podman_request('DELETE', `${API_BASE}/secrets/${encode_id(req.args.name)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== System ====================
|
||||
|
||||
system_df: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/system/df`);
|
||||
}
|
||||
},
|
||||
|
||||
system_prune: {
|
||||
args: { all: false, volumes: false },
|
||||
call: function(req) {
|
||||
let query = build_bool_query({ all: req.args.all, volumes: req.args.volumes });
|
||||
return podman_request('POST', `${API_BASE}/system/prune${query}`);
|
||||
}
|
||||
},
|
||||
|
||||
version: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/version`);
|
||||
}
|
||||
},
|
||||
|
||||
info: {
|
||||
args: {},
|
||||
call: function() {
|
||||
return podman_request('GET', `${API_BASE}/info`);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== System Debug ====================
|
||||
|
||||
system_debug: {
|
||||
args: {},
|
||||
call: function() {
|
||||
let checks = [];
|
||||
|
||||
// 1. Podman binary
|
||||
let podman_stat = stat('/usr/bin/podman');
|
||||
if (podman_stat) {
|
||||
let proc = popen('/usr/bin/podman --version 2>/dev/null', 'r');
|
||||
let ver = proc ? trim(proc.read('line') || '') : '';
|
||||
if (proc) proc.close();
|
||||
push(checks, { name: 'podman_binary', label: 'Podman Binary', status: 'ok', detail: '/usr/bin/podman', message: ver });
|
||||
} else {
|
||||
push(checks, { name: 'podman_binary', label: 'Podman Binary', status: 'error', detail: '/usr/bin/podman', message: 'Not found or not executable' });
|
||||
}
|
||||
|
||||
// 2. Podman socket reachable
|
||||
let dest = podman_socket.get_dest();
|
||||
let local_path = podman_socket.get_local_path();
|
||||
let socket_stat = null;
|
||||
if (!local_path) {
|
||||
push(checks, { name: 'podman_socket', label: 'Podman Socket', status: 'ok', detail: dest, message: 'Remote TCP (cannot stat)' });
|
||||
} else {
|
||||
socket_stat = stat(local_path);
|
||||
if (socket_stat && socket_stat.type === 'socket') {
|
||||
push(checks, { name: 'podman_socket', label: 'Podman Socket', status: 'ok', detail: dest, message: 'Socket file exists' });
|
||||
} else {
|
||||
push(checks, { name: 'podman_socket', label: 'Podman Socket', status: 'error', detail: dest, message: 'Socket not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Socket responsive
|
||||
if (podman_socket.is_remote() || (socket_stat && socket_stat.type === 'socket')) {
|
||||
let ping = podman_request('GET', `${API_BASE}/_ping`);
|
||||
if (ping && ping.data === 'OK') {
|
||||
push(checks, { name: 'socket_responsive', label: 'Socket Responsive', status: 'ok', detail: 'API responding', message: '' });
|
||||
} else {
|
||||
push(checks, { name: 'socket_responsive', label: 'Socket Responsive', status: 'warn', detail: 'API not responding', message: ping ? (ping.error || '') : '' });
|
||||
}
|
||||
} else {
|
||||
push(checks, { name: 'socket_responsive', label: 'Socket Responsive', status: 'error', detail: 'Skipped', message: 'Socket not available' });
|
||||
}
|
||||
|
||||
// 4. ucode-mod-socket
|
||||
push(checks, { name: 'ucode_socket', label: 'ucode-mod-socket', status: 'ok', detail: 'Built-in', message: 'Available (running ucode)' });
|
||||
|
||||
// 5. Init directory writable
|
||||
let init_stat = stat('/etc/init.d');
|
||||
if (init_stat && init_stat.type === 'directory') {
|
||||
// Test writability by checking we can create a temp file
|
||||
let test_path = '/etc/init.d/.podman_write_test';
|
||||
let ok = writefile(test_path, '');
|
||||
if (ok != null) {
|
||||
unlink(test_path);
|
||||
push(checks, { name: 'init_dir_writable', label: 'Init Directory', status: 'ok', detail: '/etc/init.d/', message: 'Writable' });
|
||||
} else {
|
||||
push(checks, { name: 'init_dir_writable', label: 'Init Directory', status: 'warn', detail: '/etc/init.d/', message: 'Not writable - init scripts cannot be created' });
|
||||
}
|
||||
} else {
|
||||
push(checks, { name: 'init_dir_writable', label: 'Init Directory', status: 'error', detail: '/etc/init.d/', message: 'Directory not found' });
|
||||
}
|
||||
|
||||
// 6. Startup template
|
||||
let template = '/usr/share/podman/procd-startup-template.sh';
|
||||
let tmpl_stat = stat(template);
|
||||
if (tmpl_stat) {
|
||||
let content = readfile(template);
|
||||
if (content != null) {
|
||||
push(checks, { name: 'startup_template', label: 'Startup Template', status: 'ok', detail: template, message: 'Readable' });
|
||||
} else {
|
||||
push(checks, { name: 'startup_template', label: 'Startup Template', status: 'warn', detail: template, message: 'Exists but not readable' });
|
||||
}
|
||||
} else {
|
||||
push(checks, { name: 'startup_template', label: 'Startup Template', status: 'warn', detail: template, message: 'Not found - init script generation will fail' });
|
||||
}
|
||||
|
||||
// 7. RPC plugin (self-check - if we're running, we exist)
|
||||
push(checks, { name: 'rpc_plugin', label: 'RPC Plugin', status: 'ok', detail: '/usr/share/rpcd/ucode/podman.uc', message: 'Running (ucode)' });
|
||||
|
||||
// 8. Podman API helper
|
||||
let api_helper = '/usr/libexec/podman-api';
|
||||
let helper_stat = stat(api_helper);
|
||||
if (access(api_helper, 'x')) {
|
||||
push(checks, { name: 'podman_api_helper', label: 'Podman API Helper', status: 'ok', detail: api_helper, message: 'Executable' });
|
||||
} else if (stat(api_helper)) {
|
||||
push(checks, { name: 'podman_api_helper', label: 'Podman API Helper', status: 'warn', detail: api_helper, message: 'Exists but not executable' });
|
||||
} else {
|
||||
push(checks, { name: 'podman_api_helper', label: 'Podman API Helper', status: 'warn', detail: api_helper, message: 'Not found - volume export/import will fail' });
|
||||
}
|
||||
|
||||
// 9. UCI config
|
||||
let uci_ctx = cursor();
|
||||
let socket_cfg = uci_ctx.get('luci-podman', 'globals', 'socket_path');
|
||||
let globals_ok = uci_ctx.get('luci-podman', 'globals');
|
||||
uci_ctx.unload('luci-podman');
|
||||
if (globals_ok) {
|
||||
push(checks, { name: 'uci_config', label: 'UCI Config', status: 'ok', detail: 'luci-podman.globals', message: `socket_path=${socket_cfg || 'default'}` });
|
||||
} else {
|
||||
push(checks, { name: 'uci_config', label: 'UCI Config', status: 'warn', detail: '/etc/config/luci-podman', message: 'Config not found or not loadable' });
|
||||
}
|
||||
|
||||
// 10. Containers config
|
||||
let containers_conf = '/etc/containers/containers.conf';
|
||||
let cc_stat = stat(containers_conf);
|
||||
if (cc_stat) {
|
||||
push(checks, { name: 'containers_conf', label: 'Containers Config', status: 'ok', detail: containers_conf, message: 'Readable' });
|
||||
} else {
|
||||
push(checks, { name: 'containers_conf', label: 'Containers Config', status: 'warn', detail: containers_conf, message: 'Not found - using Podman defaults' });
|
||||
}
|
||||
|
||||
// 11. Network config directory
|
||||
let net_dir = '/etc/containers/networks';
|
||||
let nd_stat = stat(net_dir);
|
||||
if (nd_stat && nd_stat.type === 'directory') {
|
||||
let count = length(glob('/etc/containers/networks/*.json') ?? []);
|
||||
push(checks, { name: 'network_config_dir', label: 'Network Config Dir', status: 'ok', detail: net_dir, message: `${count} network(s)` });
|
||||
} else {
|
||||
push(checks, { name: 'network_config_dir', label: 'Network Config Dir', status: 'warn', detail: net_dir, message: 'Not found' });
|
||||
}
|
||||
|
||||
return { checks: checks };
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Init Scripts ====================
|
||||
|
||||
init_script_generate: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let name = `${req.args.name}`;
|
||||
let start_priority = `${INIT_START_PRIORITY}`;
|
||||
let script_name = `${INIT_SCRIPT_PREFIX}${name}`;
|
||||
let script_path = init_script_path(name);
|
||||
|
||||
let template = readfile('/usr/share/podman/procd-startup-template.sh');
|
||||
if (!template)
|
||||
return { error: 'Failed to read startup template' };
|
||||
|
||||
let content = replace(template, /\{name\}/g, name);
|
||||
content = replace(content, /\{start_priority\}/g, start_priority);
|
||||
content = replace(content, /\{script_name\}/g, script_name);
|
||||
|
||||
let written = writefile(script_path, content);
|
||||
if (written == null)
|
||||
return { error: 'Failed to create init script' };
|
||||
|
||||
chmod(script_path, 0755);
|
||||
|
||||
return { success: true, path: script_path };
|
||||
}
|
||||
},
|
||||
|
||||
init_script_show: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let path = init_script_path(req.args.name);
|
||||
let content = readfile(path);
|
||||
if (content == null)
|
||||
return { error: 'Init script not found' };
|
||||
|
||||
return { content: content, path: path };
|
||||
}
|
||||
},
|
||||
|
||||
init_script_status: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let script_path = init_script_path(req.args.name);
|
||||
let exists = !!stat(script_path);
|
||||
let enabled = false;
|
||||
|
||||
if (exists)
|
||||
enabled = init_enabled(`${INIT_SCRIPT_PREFIX}${req.args.name}`);
|
||||
|
||||
return { exists: exists, enabled: enabled };
|
||||
}
|
||||
},
|
||||
|
||||
init_script_set_enabled: {
|
||||
args: { name: '', enabled: false },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let script_path = init_script_path(req.args.name);
|
||||
if (!stat(script_path))
|
||||
return { error: 'Init script not found. Generate it first.' };
|
||||
|
||||
let action = (req.args.enabled === true || req.args.enabled === 1) ? 'enable' : 'disable';
|
||||
let rc = init_action(`${INIT_SCRIPT_PREFIX}${req.args.name}`, action);
|
||||
if (rc)
|
||||
return { error: `Failed to ${action} service` };
|
||||
|
||||
return { success: true, enabled: (action === 'enable') };
|
||||
}
|
||||
},
|
||||
|
||||
init_script_remove: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let err = require_param('name', req.args.name) || validate_name(req.args.name);
|
||||
if (err) return { error: err };
|
||||
|
||||
let script_path = init_script_path(req.args.name);
|
||||
if (!stat(script_path))
|
||||
return { success: true, message: 'Init script does not exist' };
|
||||
|
||||
// Disable before removing
|
||||
init_action(`${INIT_SCRIPT_PREFIX}${req.args.name}`, 'disable');
|
||||
unlink(script_path);
|
||||
|
||||
if (stat(script_path))
|
||||
return { error: 'Failed to remove init script' };
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Socket check wrapper ---
|
||||
// Wrap all methods except system_debug with a socket availability check
|
||||
|
||||
const no_socket_check = {
|
||||
system_debug: true,
|
||||
init_script_generate: true,
|
||||
init_script_show: true,
|
||||
init_script_status: true,
|
||||
init_script_set_enabled: true,
|
||||
init_script_remove: true
|
||||
};
|
||||
|
||||
const _local_socket_path = podman_socket.get_local_path();
|
||||
|
||||
const wrapped_methods = {};
|
||||
for (let name in methods) {
|
||||
let method = methods[name];
|
||||
if (no_socket_check[name]) {
|
||||
wrapped_methods[name] = method;
|
||||
} else {
|
||||
wrapped_methods[name] = {
|
||||
args: method.args,
|
||||
call: function(req) {
|
||||
// Cheap pre-check for unix sockets only; TCP relies on actual connect failures.
|
||||
if (_local_socket_path) {
|
||||
let s = stat(_local_socket_path);
|
||||
if (!s || s.type !== 'socket')
|
||||
return { error: 'Podman socket not found or not accessible' };
|
||||
}
|
||||
return method.call(req);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { 'podman': wrapped_methods };
|
||||
@@ -0,0 +1,479 @@
|
||||
'use strict';
|
||||
|
||||
import { open, stat, unlink } from 'fs';
|
||||
import * as uloop from 'uloop';
|
||||
import * as struct from 'struct';
|
||||
import { cursor } from 'uci';
|
||||
import { connect as ubus_connect } from 'ubus';
|
||||
import * as podman_socket from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { API_BASE } from 'luci.podman_socket'; // ucode-lsp disable
|
||||
import { validate_id } from 'luci.podman_validate'; // ucode-lsp disable
|
||||
import { build_request, parse_status } from 'luci.podman_http'; // ucode-lsp disable
|
||||
|
||||
const BLOCKSIZE = 4096;
|
||||
|
||||
const FLUSH_SIZE = 1500;
|
||||
const PAD_STR = (() => {
|
||||
let s = ''; let i = 0;
|
||||
while (i < FLUSH_SIZE) { s += ' '; i++; }
|
||||
return s;
|
||||
})();
|
||||
|
||||
/** @param {string} data */
|
||||
function http_write_padded(data) {
|
||||
let n = length(data);
|
||||
if (n >= FLUSH_SIZE)
|
||||
return http.write(data);
|
||||
return http.write(data + substr(PAD_STR, 0, FLUSH_SIZE - n)); // ucode-lsp disable
|
||||
}
|
||||
|
||||
const _uci_timeouts = (() => {
|
||||
let c = cursor();
|
||||
let s = c.get('uhttpd', 'main', 'script_timeout');
|
||||
let n = c.get('uhttpd', 'main', 'network_timeout');
|
||||
c.unload('uhttpd');
|
||||
return { script: s ? +s : 60, network: n ? +n : 30 };
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* @param {int} code
|
||||
* @param {string} message
|
||||
*/
|
||||
function error_response(code, message) {
|
||||
if (http.eoh) return; // ucode-lsp disable
|
||||
http.status(code, message);
|
||||
http.header('Content-Type', 'text/plain');
|
||||
http.write(message + '\n');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} sid
|
||||
*/
|
||||
function session_timer(sid) {
|
||||
let uci = cursor();
|
||||
let script_timeout = +(uci.get('uhttpd', 'main', 'script_timeout') ?? 60);
|
||||
let sessiontime = +(uci.get('luci', 'sauth', 'sessiontime') ?? 3600);
|
||||
let timeout_ms = (script_timeout - 5) * 1000;
|
||||
|
||||
if (!sid)
|
||||
return { timeout_ms, is_final: false, on_expire: () => uloop.end() };
|
||||
|
||||
let uconn = ubus_connect();
|
||||
let now = time();
|
||||
|
||||
let sdat = uconn.call('session', 'get', { ubus_rpc_session: sid });
|
||||
let deadline = +(sdat?.values?.podman_stream_deadline ?? 0);
|
||||
let last_seen = +(sdat?.values?.podman_stream_last_seen ?? 0);
|
||||
|
||||
let is_fresh = !deadline || (now - last_seen > script_timeout);
|
||||
|
||||
if (is_fresh)
|
||||
deadline = now + sessiontime;
|
||||
|
||||
uconn.call('session', 'set', {
|
||||
ubus_rpc_session: sid,
|
||||
values: { podman_stream_deadline: deadline, podman_stream_last_seen: now }
|
||||
});
|
||||
|
||||
let remaining_ms = (deadline - now) * 1000;
|
||||
|
||||
if (remaining_ms <= 0) {
|
||||
uconn.call('session', 'destroy', { ubus_rpc_session: sid });
|
||||
return null;
|
||||
}
|
||||
|
||||
let is_final = remaining_ms <= timeout_ms;
|
||||
let fire_ms = is_final ? remaining_ms : timeout_ms;
|
||||
|
||||
return {
|
||||
timeout_ms: fire_ms,
|
||||
is_final,
|
||||
on_expire: () => {
|
||||
if (is_final)
|
||||
uconn.call('session', 'destroy', { ubus_rpc_session: sid });
|
||||
uloop.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} api_path
|
||||
* @param {function} on_data
|
||||
* @param {boolean} early_headers
|
||||
* @param {int} timer
|
||||
*/
|
||||
function stream_podman(api_path, on_data, early_headers, timer) {
|
||||
let sock = podman_socket.connect();
|
||||
if (!sock) {
|
||||
error_response(502, 'Cannot connect to Podman socket');
|
||||
return;
|
||||
}
|
||||
|
||||
sock.send(build_request('GET', api_path, null));
|
||||
|
||||
let t = _uci_timeouts;
|
||||
let ka_ms = (t.network - 5) * 1000;
|
||||
|
||||
uloop.init();
|
||||
|
||||
let response_started = early_headers;
|
||||
if (early_headers) {
|
||||
http.status(200, 'OK');
|
||||
http.header('Content-Type', 'text/plain');
|
||||
http.write_headers();
|
||||
http.write('\n');
|
||||
}
|
||||
|
||||
let buf = '';
|
||||
let podman_headers_done = false;
|
||||
|
||||
let handle = uloop.handle(sock, () => {
|
||||
let chunk = sock.recv(BLOCKSIZE);
|
||||
if (type(chunk) !== 'string') return; // EAGAIN - keep waiting
|
||||
|
||||
if (!podman_headers_done) {
|
||||
// Phase 1: consuming Podman's HTTP response headers
|
||||
if (!length(chunk)) {
|
||||
// EOF before Podman sent headers
|
||||
if (!response_started)
|
||||
error_response(502, 'Podman closed connection');
|
||||
uloop.end();
|
||||
return;
|
||||
}
|
||||
buf += chunk;
|
||||
if (type(buf) !== 'string') return;
|
||||
let sep = index(buf, '\r\n\r\n');
|
||||
if (type(sep) !== 'int' || sep < 0) return; // headers not complete yet
|
||||
|
||||
let code = parse_status(buf) || 502;
|
||||
if (code != 200) {
|
||||
if (!response_started)
|
||||
error_response(code, substr(buf, sep + 4) || sprintf('Podman error %d', code));
|
||||
uloop.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sep = sep + 4;
|
||||
let body_start = substr(buf, sep);
|
||||
buf = '';
|
||||
podman_headers_done = true;
|
||||
|
||||
if (!response_started) {
|
||||
// Stats: start our response now that Podman confirmed 200
|
||||
http.status(200, 'OK');
|
||||
http.header('Content-Type', 'application/x-ndjson');
|
||||
http.write_headers();
|
||||
http.write('\n');
|
||||
response_started = true;
|
||||
}
|
||||
|
||||
if (body_start && length(body_start)) {
|
||||
if (!on_data(body_start)) { uloop.end(); return; }
|
||||
}
|
||||
} else {
|
||||
// Phase 2: streaming data
|
||||
if (!length(chunk)) { uloop.end(); return; } // Podman closed stream
|
||||
if (!on_data(chunk)) uloop.end();
|
||||
}
|
||||
}, uloop.ULOOP_READ);
|
||||
|
||||
if (early_headers)
|
||||
uloop.timer(ka_ms, () => http_write_padded('\n'));
|
||||
|
||||
let effective_timeout = early_headers ? min(timer.timeout_ms, ka_ms * 2) : timer.timeout_ms;
|
||||
uloop.timer(effective_timeout, timer.on_expire);
|
||||
|
||||
uloop.run();
|
||||
|
||||
handle.delete();
|
||||
sock.close();
|
||||
|
||||
if (!response_started)
|
||||
error_response(504, 'Podman did not respond');
|
||||
}
|
||||
|
||||
return {
|
||||
container_logs: (id) => {
|
||||
if (validate_id(id)) { error_response(400, 'Invalid container ID'); return; }
|
||||
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let tail = http.formvalue('tail') || '100';
|
||||
let since = http.formvalue('since') || '';
|
||||
let until = http.formvalue('until') || '';
|
||||
let follow = http.formvalue('follow') !== 'false';
|
||||
|
||||
if (tail !== 'all' && !match(`${tail}`, /^[0-9]+$/)) {
|
||||
error_response(400, 'Invalid tail parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
if (since && !match(`${since}`, /^[0-9]+(\.[0-9]+)?$/)) {
|
||||
error_response(400, 'Invalid since parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
if (until && !match(`${until}`, /^[0-9]+(\.[0-9]+)?$/)) {
|
||||
error_response(400, 'Invalid until parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
let api_path = sprintf(
|
||||
'%s/containers/%s/logs?follow=%s&stdout=true&stderr=true×tamps=false',
|
||||
API_BASE, id, follow ? 'true' : 'false'
|
||||
);
|
||||
if (tail) api_path += sprintf('&tail=%s', tail);
|
||||
if (since) api_path += sprintf('&since=%s', since);
|
||||
if (until) api_path += sprintf('&until=%s', until);
|
||||
|
||||
let framebuf = '';
|
||||
|
||||
stream_podman(api_path, (chunk) => {
|
||||
framebuf += `${chunk}`;
|
||||
let out = '';
|
||||
while (length(framebuf) >= 8) {
|
||||
let hdr = struct.unpack('!BxxxI', substr(framebuf, 0, 8));
|
||||
if (!hdr) break;
|
||||
let stream_type = hdr[0];
|
||||
let payload_len = int(`${hdr[1]}`);
|
||||
if (length(framebuf) < 8 + payload_len) break; // wait for more data
|
||||
let payload = substr(framebuf, 8, payload_len);
|
||||
framebuf = substr(framebuf, 8 + payload_len);
|
||||
// Emit stdout (1) and stderr (2); skip others
|
||||
if (stream_type >= 1 && stream_type <= 2)
|
||||
out += payload;
|
||||
}
|
||||
if (length(out)) http_write_padded(out);
|
||||
return true;
|
||||
}, true, timer);
|
||||
},
|
||||
|
||||
container_top: (id) => {
|
||||
if (validate_id(id)) { error_response(400, 'Invalid container ID'); return; }
|
||||
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let delay = http.formvalue('delay') || '5';
|
||||
let ps_args = http.formvalue('ps_args') || '';
|
||||
|
||||
if (!match(`${delay}`, /^[0-9]+$/) || +delay < 2) {
|
||||
error_response(400, 'Invalid delay parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ps_args && !match(`${ps_args}`, /^[-a-zA-Z0-9_, ]+$/)) {
|
||||
error_response(400, 'Invalid ps_args parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
let api_path = sprintf('%s/containers/%s/top?stream=true&delay=%s', API_BASE, id, delay);
|
||||
|
||||
if (ps_args)
|
||||
api_path += sprintf('&ps_args=%s', replace(ps_args, / /g, '%20'));
|
||||
|
||||
stream_podman(api_path, (chunk) => {
|
||||
return http_write_padded(chunk);
|
||||
}, false, timer);
|
||||
},
|
||||
|
||||
container_stats: (id) => {
|
||||
if (validate_id(id)) { error_response(400, 'Invalid container ID'); return; }
|
||||
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let interval = `${http.formvalue('interval') || '3'}`;
|
||||
if (!match(interval, /^[0-9]+$/) || +interval < 1) {
|
||||
error_response(400, 'Invalid interval parameter');
|
||||
return;
|
||||
}
|
||||
let api_path = sprintf('%s/containers/stats?containers=%s&stream=true&interval=%s',
|
||||
API_BASE, id, interval);
|
||||
|
||||
stream_podman(api_path, (chunk) => {
|
||||
return http_write_padded(chunk);
|
||||
}, false, timer);
|
||||
},
|
||||
|
||||
pod_top: (name) => {
|
||||
if (validate_id(name)) { error_response(400, 'Invalid pod name or ID'); return; }
|
||||
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let delay = http.formvalue('delay') || '5';
|
||||
|
||||
if (!match(`${delay}`, /^[0-9]+$/) || +delay < 2) {
|
||||
error_response(400, 'Invalid delay parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
let api_path = sprintf('%s/pods/%s/top?stream=true&delay=%s', API_BASE, name, delay);
|
||||
|
||||
stream_podman(api_path, (chunk) => {
|
||||
return http_write_padded(chunk);
|
||||
}, false, timer);
|
||||
},
|
||||
|
||||
pod_stats: (name) => {
|
||||
if (validate_id(name)) { error_response(400, 'Invalid pod name or ID'); return; }
|
||||
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let api_path = sprintf('%s/pods/stats?namesOrIDs=%s&stream=true', API_BASE, name);
|
||||
|
||||
stream_podman(api_path, (chunk) => {
|
||||
return http_write_padded(chunk);
|
||||
}, false, timer);
|
||||
},
|
||||
|
||||
image_pull: () => {
|
||||
let timer = session_timer(ctx?.authsession); // ucode-lsp disable
|
||||
if (!timer) { error_response(403, 'Session expired'); return; }
|
||||
|
||||
let reference = `${http.formvalue('reference')}`;
|
||||
if (!reference || !match(reference, /^[a-zA-Z0-9._/:@-]+$/)) {
|
||||
error_response(400, 'Invalid or missing reference parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!match(reference, /[/:@]/)) {
|
||||
error_response(400, 'Reference must be a registry reference (e.g. name:tag), not a raw image ID');
|
||||
return;
|
||||
}
|
||||
|
||||
let sid = ctx?.authsession; // ucode-lsp disable
|
||||
let ref_id = replace(reference, /[/:@]/g, '-');
|
||||
let logfile = '/tmp/podman-pull-' + ref_id + '.ndjson';
|
||||
let pidfile = '/tmp/podman-pull-' + ref_id + '.pid';
|
||||
|
||||
// Check if a worker is already running for this reference
|
||||
let pid = 0;
|
||||
let pf = open(pidfile, 'r');
|
||||
if (pf) { pid = +(trim(pf.read(200) || '0')); pf.close(); }
|
||||
let is_running = pid > 0 && stat('/proc/' + pid);
|
||||
|
||||
// Read (and reuse) the ubus connection for session offset
|
||||
let uconn_rw = sid ? ubus_connect() : null;
|
||||
let offset = 0;
|
||||
if (uconn_rw) {
|
||||
let sdat = uconn_rw.call('session', 'get', { ubus_rpc_session: sid });
|
||||
offset = +(sdat?.values?.['pull_' + ref_id] || 0);
|
||||
}
|
||||
|
||||
// Check if the existing logfile has bytes we haven't sent yet
|
||||
// (worker may have just finished between two reconnects)
|
||||
let lstat = stat(logfile);
|
||||
let has_unread = lstat && offset < lstat.size;
|
||||
|
||||
let is_completed_pull = false;
|
||||
if (!is_running && lstat && lstat.size > 0) {
|
||||
let f = open(logfile, 'r');
|
||||
if (f) {
|
||||
let tail_start = lstat.size > 512 ? lstat.size - 512 : 0;
|
||||
if (tail_start > 0) f.seek(tail_start);
|
||||
let tail = f.read(512);
|
||||
f.close();
|
||||
is_completed_pull = !!(tail && (index(tail, '"images"') >= 0 || index(tail, '"error"') >= 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh start: worker not running AND (previous pull completed, OR no unread
|
||||
// progress from a prior session, OR stale offset=0 logfile from a closed tab).
|
||||
let is_fresh_start = !is_running && (is_completed_pull || !(has_unread && offset > 0));
|
||||
|
||||
if (is_fresh_start) {
|
||||
// Clean up any stale files and reset offset
|
||||
unlink(logfile);
|
||||
unlink(pidfile);
|
||||
offset = 0;
|
||||
if (uconn_rw) {
|
||||
let vals = {};
|
||||
vals['pull_' + ref_id] = 0;
|
||||
uconn_rw.call('session', 'set', { ubus_rpc_session: sid, values: vals });
|
||||
}
|
||||
// Safety: single quotes are used as shell delimiters below.
|
||||
// The regex on line 295 already rejects them; this is a belt-and-suspenders guard.
|
||||
if (index(reference, "'") >= 0 || index(logfile, "'") >= 0 || index(pidfile, "'") >= 0) {
|
||||
error_response(400, 'Invalid characters in reference');
|
||||
return;
|
||||
}
|
||||
system(sprintf(
|
||||
"timeout 600 /usr/bin/ucode /usr/share/podman/pull-worker.uc '%s' '%s' '%s' </dev/null >/dev/null 2>/dev/null &",
|
||||
reference, logfile, pidfile
|
||||
));
|
||||
}
|
||||
|
||||
http.status(200, 'OK');
|
||||
http.header('Content-Type', 'text/plain');
|
||||
http.write_headers();
|
||||
http.write('\n');
|
||||
|
||||
let t = _uci_timeouts;
|
||||
let ka_ms = (t.network - 5) * 1000;
|
||||
let last_saved = offset;
|
||||
|
||||
uloop.init();
|
||||
|
||||
let poll_fn;
|
||||
poll_fn = () => {
|
||||
// Read and forward any new bytes written to the logfile
|
||||
let lf = open(logfile, 'r');
|
||||
if (lf) {
|
||||
if (offset > 0) lf.seek(offset);
|
||||
let chunk = lf.read(8192);
|
||||
lf.close();
|
||||
if (chunk && length(chunk)) {
|
||||
if (!http_write_padded(chunk)) { uloop.end(); return; }
|
||||
offset += length(chunk);
|
||||
// Persist offset to session (rate-limited)
|
||||
if (uconn_rw && offset - last_saved >= 1024) {
|
||||
let vals = {};
|
||||
vals['pull_' + ref_id] = offset;
|
||||
uconn_rw.call('session', 'set', { ubus_rpc_session: sid, values: vals });
|
||||
last_saved = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If worker has finished and we've consumed all bytes: close cleanly
|
||||
let cpid = 0;
|
||||
let cpf = open(pidfile, 'r');
|
||||
if (cpf) { cpid = +(trim(cpf.read(200) || '0')); cpf.close(); }
|
||||
if (!cpid || !stat('/proc/' + cpid)) {
|
||||
let sf = stat(logfile);
|
||||
if (!sf || offset >= sf.size) {
|
||||
unlink(logfile);
|
||||
uloop.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uloop.timer(200, poll_fn);
|
||||
};
|
||||
uloop.timer(0, poll_fn);
|
||||
|
||||
// Self-rearming keepalive - prevents network_timeout during slow layer downloads
|
||||
let ka;
|
||||
ka = () => { http_write_padded('\n'); uloop.timer(ka_ms, ka); };
|
||||
uloop.timer(ka_ms, ka);
|
||||
|
||||
// Session expiry timer - same pattern as other streams
|
||||
uloop.timer(timer.timeout_ms, timer.on_expire);
|
||||
|
||||
uloop.run();
|
||||
|
||||
// Persist final offset before this script exits (next reconnect will resume here)
|
||||
if (uconn_rw && offset !== last_saved) {
|
||||
let vals = {};
|
||||
vals['pull_' + ref_id] = offset;
|
||||
uconn_rw.call('session', 'set', { ubus_rpc_session: sid, values: vals });
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 Christopher Söllinger
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
//
|
||||
// Small helpers for the wire-format HTTP/1.0 conversation the rpcd plugin,
|
||||
// the LuCI streaming controller and the pull-worker all have with the
|
||||
// Podman API socket. None of them need a full HTTP client — just request
|
||||
// construction and a couple of header parsers.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {string} method GET / POST / PUT / DELETE
|
||||
* @param {string} path Request-URI including any query string
|
||||
* @param {string|null} body JSON body (or null/empty for GET-style requests)
|
||||
* @returns {string} The complete request bytes
|
||||
*/
|
||||
export function build_request(method, path, body) {
|
||||
let req = sprintf('%s %s HTTP/1.0\r\nHost: localhost\r\n', method, path);
|
||||
if (body != null && body !== '') {
|
||||
let s = `${body}`;
|
||||
req += sprintf('Content-Type: application/json\r\nContent-Length: %d\r\n', length(s));
|
||||
req += '\r\n';
|
||||
req += s;
|
||||
return req;
|
||||
}
|
||||
return req + '\r\n';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract HTTP status code from the start of a response buffer.
|
||||
*
|
||||
* @param {string} buf Buffer that begins with the status line
|
||||
* @returns {number} Status code, or 0 if the line doesn't match
|
||||
*/
|
||||
export function parse_status(buf) {
|
||||
let m = match(buf, /^HTTP\/[0-9.]+ ([0-9]+)/);
|
||||
return m ? +m[1] : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a Content-Length value from a response header buffer.
|
||||
*
|
||||
* @param {string} buf Header buffer (status line + headers)
|
||||
* @returns {number} Bytes declared by Content-Length, or -1 if absent
|
||||
*/
|
||||
export function parse_content_length(buf) {
|
||||
let m = match(buf, /[Cc]ontent-[Ll]ength:\s*([0-9]+)/);
|
||||
return m ? +m[1] : -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Blocking read of HTTP response headers from a connected socket. Reads
|
||||
* until the \r\n\r\n separator is seen, then returns the header block
|
||||
* (without trailing CRLFCRLF) and any body bytes that arrived together
|
||||
* with the headers. Returns null if the peer closes before headers are
|
||||
* complete.
|
||||
*
|
||||
* @param {any} sock Connected socket
|
||||
* @param {number|null} blocksize recv chunk size (default 65536)
|
||||
* @returns {{header_buf:string, body_remainder:string}|null}
|
||||
*/
|
||||
export function read_headers(sock, blocksize) {
|
||||
let bs = blocksize || 65536;
|
||||
let buf = '';
|
||||
while (true) {
|
||||
let chunk = sock.recv(bs);
|
||||
if (!chunk) return null;
|
||||
buf += `${chunk}`;
|
||||
let sep = index(buf, '\r\n\r\n');
|
||||
if (type(sep) !== 'int' || sep < 0) continue;
|
||||
return {
|
||||
header_buf: substr(buf, 0, sep),
|
||||
body_remainder: substr(buf, sep + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Christopher Söllinger
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
//
|
||||
// Shared Podman socket helper for the rpcd plugin, the LuCI streaming
|
||||
// controller, the pull-worker and the podman-api CLI helper. Reads the
|
||||
// destination once at module load from UCI:
|
||||
// luci-podman.globals.socket_path
|
||||
// Accepted URI schemes:
|
||||
// unix:///path/to/socket AF_UNIX
|
||||
// tcp://host:port AF_INET
|
||||
// tcp6://[::1]:port AF_INET6
|
||||
// /path/to/socket bare path, treated as unix:///path
|
||||
// Fallback: unix:///run/podman/podman.sock.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import * as socket from 'socket';
|
||||
|
||||
const DEFAULT_DEST = 'unix:///run/podman/podman.sock';
|
||||
|
||||
/** Path prefix for all libpod REST API endpoints. */
|
||||
export const API_BASE = '/v5.0.0/libpod';
|
||||
|
||||
const _dest = (() => {
|
||||
let c = cursor();
|
||||
let v = c.get('luci-podman', 'globals', 'socket_path');
|
||||
c.unload('luci-podman');
|
||||
return v || DEFAULT_DEST;
|
||||
})();
|
||||
|
||||
const _parsed = (() => {
|
||||
if (substr(_dest, 0, 1) === '/')
|
||||
return { scheme: 'unix', addr: _dest };
|
||||
let m = match(_dest, /^([a-z0-9]+):\/\/(.+)$/i);
|
||||
if (!m)
|
||||
return null;
|
||||
return { scheme: lc(m[1]), addr: m[2] };
|
||||
})();
|
||||
|
||||
/** @returns {?Socket} connected socket, or null on failure */
|
||||
export function connect() {
|
||||
if (!_parsed)
|
||||
return null;
|
||||
|
||||
if (_parsed.scheme === 'unix') {
|
||||
let sock = socket.create(socket.AF_UNIX, socket.SOCK_STREAM);
|
||||
if (!sock) return null;
|
||||
if (!sock.connect(socket.sockaddr(_parsed.addr))) {
|
||||
sock.close();
|
||||
return null;
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
|
||||
if (_parsed.scheme === 'tcp' || _parsed.scheme === 'tcp6') {
|
||||
let family = _parsed.scheme === 'tcp6' ? socket.AF_INET6 : socket.AF_INET;
|
||||
let raw = _parsed.addr;
|
||||
let colon = rindex(raw, ':');
|
||||
if (colon < 0) return null;
|
||||
let host = substr(raw, 0, colon);
|
||||
let port = +substr(raw, colon + 1);
|
||||
host = replace(host, /^\[|\]$/g, '');
|
||||
if (!port || !host) return null;
|
||||
|
||||
let ai = socket.addrinfo(host, `${port}`, { family, socktype: socket.SOCK_STREAM });
|
||||
if (!ai || !length(ai)) return null;
|
||||
let sock = socket.create(family, socket.SOCK_STREAM);
|
||||
if (!sock) return null;
|
||||
if (!sock.connect(ai[0].addr)) {
|
||||
sock.close();
|
||||
return null;
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** @returns {boolean} true if destination is a TCP scheme */
|
||||
export function is_remote() {
|
||||
return _parsed && (_parsed.scheme === 'tcp' || _parsed.scheme === 'tcp6');
|
||||
};
|
||||
|
||||
/** @returns {string} the raw URI from UCI (or the default), for logging */
|
||||
export function get_dest() {
|
||||
return _dest;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {?string} filesystem path of the unix socket, or null for tcp/tcp6.
|
||||
* Useful for pre-flight stat() checks before connect().
|
||||
*/
|
||||
export function get_local_path() {
|
||||
if (!_parsed || _parsed.scheme !== 'unix') return null;
|
||||
return _parsed.addr;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 Christopher Söllinger
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
//
|
||||
// Shared parameter validators for the rpcd plugin, the LuCI streaming
|
||||
// controller and the podman-api CLI helper.
|
||||
//
|
||||
// Convention: every exported validator returns an error message **string**
|
||||
// on failure and `null` on success, so call sites can chain with || :
|
||||
//
|
||||
// let err = require_param('name', v) || validate_name(v);
|
||||
// if (err) return { error: err };
|
||||
|
||||
'use strict';
|
||||
|
||||
const VALID_RESTART_POLICIES = { 'no': true, 'always': true, 'on-failure': true, 'unless-stopped': true };
|
||||
|
||||
const CONTAINER_BODY_KEYS = {
|
||||
cpu: true, memory: true, blockIO: true, devices: true,
|
||||
hugepageLimits: true, network: true, pids: true, rdma: true, unified: true,
|
||||
BlkIOWeightDevice: true,
|
||||
DeviceReadBPs: true, DeviceReadIOPs: true,
|
||||
DeviceWriteBPs: true, DeviceWriteIOPs: true,
|
||||
Env: true, UnsetEnv: true,
|
||||
health_cmd: true, health_interval: true, health_log_destination: true,
|
||||
health_max_log_count: true, health_max_log_size: true,
|
||||
health_on_failure: true, health_retries: true, health_start_period: true,
|
||||
health_startup_cmd: true, health_startup_interval: true,
|
||||
health_startup_retries: true, health_startup_success: true,
|
||||
health_startup_timeout: true, health_timeout: true,
|
||||
no_healthcheck: true
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
export function validate_id(id) {
|
||||
if (!id || type(id) !== 'string' || !match(id, /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/))
|
||||
return 'Invalid id format';
|
||||
};
|
||||
|
||||
/** @param {string} name */
|
||||
export function validate_name(name) {
|
||||
if (!name || type(name) !== 'string' || !match(name, /^[a-zA-Z0-9_.-]+$/))
|
||||
return 'Invalid name format';
|
||||
};
|
||||
|
||||
/** @param {string} ref */
|
||||
export function validate_image_ref(ref) {
|
||||
if (!ref || type(ref) !== 'string' || !match(ref, /^[a-zA-Z0-9_.:\/@-]+$/))
|
||||
return 'Invalid image reference';
|
||||
};
|
||||
|
||||
/** @param {string} query */
|
||||
export function validate_query_params(query) {
|
||||
if (!query || type(query) !== 'string' || !match(query, /^[a-zA-Z0-9=&_.,-]+$/))
|
||||
return 'Invalid query parameters';
|
||||
};
|
||||
|
||||
/** @param {string} policy */
|
||||
export function validate_restart_policy(policy) {
|
||||
if (policy && !(policy in VALID_RESTART_POLICIES))
|
||||
return 'Invalid restart policy';
|
||||
};
|
||||
|
||||
/** @param {string} val */
|
||||
export function validate_int(val) {
|
||||
if (!val || type(val) !== 'string' || !match(val, /^[0-9]+$/))
|
||||
return 'Invalid number';
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {any} value
|
||||
*/
|
||||
export function require_param(name, value) {
|
||||
if (value == null || value === ''
|
||||
|| (type(value) === 'object' && length(keys(value)) === 0))
|
||||
return `Missing required parameter: ${name}`;
|
||||
};
|
||||
|
||||
export const BODY_KEYS = CONTAINER_BODY_KEYS;
|
||||