feat(streamlit-launcher): Add on-demand startup with idle shutdown
New package secubox-app-streamlit-launcher: - Lazy loading: apps start only when accessed - Idle shutdown: stop apps after configurable timeout (default 30min) - Memory management: force-stop low-priority apps when memory low - Priority system: higher priority = keep running longer - Always-on mode for critical apps - Procd daemon with respawn CLI: streamlit-launcherctl - daemon: run background manager - status/list: show app states and idle times - start/stop: manual app control - priority: set app priority (1-100) - check/check-memory: manual checks Updated slforge with launcher integration: - slforge launcher status/priority/always-on commands - Access tracking on app start - README documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb0584f44b
commit
d9bcf1c09b
@ -52,6 +52,11 @@ slforge hide <app> # Remove public access
|
|||||||
slforge publish <app> # Add to mesh catalog
|
slforge publish <app> # Add to mesh catalog
|
||||||
slforge unpublish <app> # Remove from mesh
|
slforge unpublish <app> # Remove from mesh
|
||||||
|
|
||||||
|
# Launcher Integration (on-demand startup)
|
||||||
|
slforge launcher status # Show launcher status
|
||||||
|
slforge launcher priority <app> <n> # Set app priority (1-100)
|
||||||
|
slforge launcher always-on <app> # Mark as always-on
|
||||||
|
|
||||||
# Templates
|
# Templates
|
||||||
slforge templates # List available templates
|
slforge templates # List available templates
|
||||||
```
|
```
|
||||||
@ -182,3 +187,44 @@ Published apps create a manifest at `/usr/share/secubox/plugins/catalog/`:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## On-Demand Launcher
|
||||||
|
|
||||||
|
Install `secubox-app-streamlit-launcher` for resource optimization:
|
||||||
|
|
||||||
|
- **Lazy Loading** - Apps start only when first accessed
|
||||||
|
- **Idle Shutdown** - Stop apps after configurable timeout (default: 30 min)
|
||||||
|
- **Memory Management** - Force-stop low-priority apps when memory is low
|
||||||
|
- **Priority System** - Keep critical apps running longer
|
||||||
|
|
||||||
|
### Launcher Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check launcher status
|
||||||
|
slforge launcher status
|
||||||
|
|
||||||
|
# Set app priority (higher = keep running longer, max 100)
|
||||||
|
slforge launcher priority myapp 75
|
||||||
|
|
||||||
|
# Mark app as always-on (never auto-stopped)
|
||||||
|
slforge launcher always-on dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priority Levels
|
||||||
|
|
||||||
|
| Priority | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| 100 + always_on | Never auto-stopped |
|
||||||
|
| 80-99 | Stopped last during memory pressure |
|
||||||
|
| 50 (default) | Normal priority |
|
||||||
|
| 1-49 | Stopped first during memory pressure |
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. User accesses `https://app.example.com`
|
||||||
|
2. If app is stopped, launcher starts it on-demand
|
||||||
|
3. App access time is tracked
|
||||||
|
4. After idle timeout, app is automatically stopped
|
||||||
|
5. Memory pressure triggers low-priority app shutdown
|
||||||
|
|
||||||
|
See `secubox-app-streamlit-launcher` README for full configuration.
|
||||||
|
|||||||
@ -32,6 +32,24 @@ log_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|||||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
log_err() { echo -e "${RED}[ERROR]${NC} $1"; }
|
log_err() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# Launcher integration
|
||||||
|
LAUNCHER_TRACKING_DIR="/tmp/streamlit-access"
|
||||||
|
|
||||||
|
# Track app access (for launcher idle detection)
|
||||||
|
track_access() {
|
||||||
|
local name="$1"
|
||||||
|
mkdir -p "$LAUNCHER_TRACKING_DIR"
|
||||||
|
touch "$LAUNCHER_TRACKING_DIR/$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if launcher is managing this app
|
||||||
|
is_launcher_managed() {
|
||||||
|
local enabled
|
||||||
|
config_load streamlit-launcher 2>/dev/null || return 1
|
||||||
|
config_get enabled global enabled '0'
|
||||||
|
[ "$enabled" = "1" ]
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Streamlit Forge - App Publishing Platform
|
Streamlit Forge - App Publishing Platform
|
||||||
@ -77,6 +95,11 @@ Mesh AppStore:
|
|||||||
catalog Browse mesh catalog
|
catalog Browse mesh catalog
|
||||||
install <app@node> Install from mesh
|
install <app@node> Install from mesh
|
||||||
|
|
||||||
|
Launcher Integration:
|
||||||
|
launcher status Show launcher status
|
||||||
|
launcher priority <app> <n> Set app priority (higher=keep longer)
|
||||||
|
launcher always-on <app> Mark app as always-on (never auto-stop)
|
||||||
|
|
||||||
Templates:
|
Templates:
|
||||||
templates List available templates
|
templates List available templates
|
||||||
|
|
||||||
@ -664,6 +687,8 @@ STARTSCRIPT
|
|||||||
if netstat -tln 2>/dev/null | grep -q ":$port "; then
|
if netstat -tln 2>/dev/null | grep -q ":$port "; then
|
||||||
[ "$quiet" = "0" ] && log_ok "Started $name"
|
[ "$quiet" = "0" ] && log_ok "Started $name"
|
||||||
[ "$quiet" = "0" ] && log_info "URL: http://192.168.255.1:$port"
|
[ "$quiet" = "0" ] && log_info "URL: http://192.168.255.1:$port"
|
||||||
|
# Track access for launcher idle detection
|
||||||
|
track_access "$name"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -940,6 +965,56 @@ cmd_unpublish() {
|
|||||||
log_ok "Unpublished $name from mesh catalog"
|
log_ok "Unpublished $name from mesh catalog"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Launcher integration commands
|
||||||
|
cmd_launcher() {
|
||||||
|
local action="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
status)
|
||||||
|
if command -v streamlit-launcherctl >/dev/null 2>&1; then
|
||||||
|
streamlit-launcherctl status
|
||||||
|
else
|
||||||
|
log_warn "Launcher not installed"
|
||||||
|
log_info "Install with: opkg install secubox-app-streamlit-launcher"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
priority)
|
||||||
|
local app="$1"
|
||||||
|
local priority="$2"
|
||||||
|
[ -z "$app" ] || [ -z "$priority" ] && {
|
||||||
|
log_err "Usage: slforge launcher priority <app> <priority>"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if command -v streamlit-launcherctl >/dev/null 2>&1; then
|
||||||
|
streamlit-launcherctl priority "$app" "$priority"
|
||||||
|
else
|
||||||
|
log_err "Launcher not installed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
always-on)
|
||||||
|
local app="$1"
|
||||||
|
[ -z "$app" ] && {
|
||||||
|
log_err "Usage: slforge launcher always-on <app>"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if command -v streamlit-launcherctl >/dev/null 2>&1; then
|
||||||
|
streamlit-launcherctl priority "$app" 100 1
|
||||||
|
log_ok "App $app marked as always-on"
|
||||||
|
else
|
||||||
|
log_err "Launcher not installed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_err "Unknown launcher action: $action"
|
||||||
|
echo "Usage: slforge launcher <status|priority|always-on>"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Main command router
|
# Main command router
|
||||||
case "$1" in
|
case "$1" in
|
||||||
create) shift; cmd_create "$@" ;;
|
create) shift; cmd_create "$@" ;;
|
||||||
@ -961,6 +1036,7 @@ case "$1" in
|
|||||||
publish) shift; cmd_publish "$@" ;;
|
publish) shift; cmd_publish "$@" ;;
|
||||||
unpublish) shift; cmd_unpublish "$@" ;;
|
unpublish) shift; cmd_unpublish "$@" ;;
|
||||||
templates) cmd_templates ;;
|
templates) cmd_templates ;;
|
||||||
|
launcher) shift; cmd_launcher "$@" ;;
|
||||||
help|--help|-h|"")
|
help|--help|-h|"")
|
||||||
usage ;;
|
usage ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
48
package/secubox/secubox-app-streamlit-launcher/Makefile
Normal file
48
package/secubox/secubox-app-streamlit-launcher/Makefile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
|
PKG_NAME:=secubox-app-streamlit-launcher
|
||||||
|
PKG_VERSION:=1.0.0
|
||||||
|
PKG_RELEASE:=1
|
||||||
|
PKG_MAINTAINER:=SecuBox Team
|
||||||
|
PKG_LICENSE:=MIT
|
||||||
|
|
||||||
|
include $(INCLUDE_DIR)/package.mk
|
||||||
|
|
||||||
|
define Package/secubox-app-streamlit-launcher
|
||||||
|
SECTION:=secubox
|
||||||
|
CATEGORY:=SecuBox
|
||||||
|
TITLE:=Streamlit On-Demand Launcher
|
||||||
|
DEPENDS:=+secubox-app-streamlit-forge
|
||||||
|
PKGARCH:=all
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Package/secubox-app-streamlit-launcher/description
|
||||||
|
On-demand Streamlit app launcher with idle shutdown.
|
||||||
|
Starts apps only when accessed, stops after idle timeout.
|
||||||
|
Monitors memory and can force-stop low-priority apps.
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Build/Compile
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Package/secubox-app-streamlit-launcher/install
|
||||||
|
$(INSTALL_DIR) $(1)/etc/config
|
||||||
|
$(INSTALL_CONF) ./files/etc/config/streamlit-launcher $(1)/etc/config/
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/etc/init.d
|
||||||
|
$(INSTALL_BIN) ./files/etc/init.d/streamlit-launcher $(1)/etc/init.d/
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/usr/sbin
|
||||||
|
$(INSTALL_BIN) ./files/usr/sbin/streamlit-launcherctl $(1)/usr/sbin/
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/usr/share/streamlit-launcher
|
||||||
|
$(INSTALL_DATA) ./files/usr/share/streamlit-launcher/loading.html $(1)/usr/share/streamlit-launcher/
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Package/secubox-app-streamlit-launcher/postinst
|
||||||
|
#!/bin/sh
|
||||||
|
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/streamlit-launcher enable
|
||||||
|
exit 0
|
||||||
|
endef
|
||||||
|
|
||||||
|
$(eval $(call BuildPackage,secubox-app-streamlit-launcher))
|
||||||
191
package/secubox/secubox-app-streamlit-launcher/README.md
Normal file
191
package/secubox/secubox-app-streamlit-launcher/README.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
# SecuBox Streamlit Launcher
|
||||||
|
|
||||||
|
On-demand Streamlit app launcher with idle shutdown and memory management.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Streamlit Launcher optimizes resource usage on constrained devices by:
|
||||||
|
|
||||||
|
- **Starting apps on-demand** when first accessed (lazy loading)
|
||||||
|
- **Stopping idle apps** after configurable timeout (default: 30 min)
|
||||||
|
- **Managing memory pressure** by stopping low-priority apps when memory is low
|
||||||
|
- **Priority system** to keep critical apps running longer
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ HAProxy │────▶│ mitmproxy │────▶│ Streamlit │
|
||||||
|
│ (vhost) │ │ (WAF+track) │ │ Launcher Daemon │
|
||||||
|
└─────────────┘ └──────────────┘ └────────┬────────┘
|
||||||
|
│ │
|
||||||
|
Track access Start/Stop
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌─────▼─────┐
|
||||||
|
│ /tmp/access │ │ slforge │
|
||||||
|
│ (touch) │ │ start/ │
|
||||||
|
└─────────────┘ │ stop │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opkg install secubox-app-streamlit-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show status
|
||||||
|
streamlit-launcherctl status
|
||||||
|
|
||||||
|
# List all apps with details
|
||||||
|
streamlit-launcherctl list
|
||||||
|
|
||||||
|
# Manually start/stop an app
|
||||||
|
streamlit-launcherctl start <app>
|
||||||
|
streamlit-launcherctl stop <app>
|
||||||
|
|
||||||
|
# Set app priority (higher = keep running longer)
|
||||||
|
streamlit-launcherctl priority <app> <value>
|
||||||
|
|
||||||
|
# Set always-on (never auto-stop)
|
||||||
|
streamlit-launcherctl priority <app> 100 1
|
||||||
|
|
||||||
|
# Run idle check manually
|
||||||
|
streamlit-launcherctl check
|
||||||
|
|
||||||
|
# Run memory pressure check
|
||||||
|
streamlit-launcherctl check-memory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `/etc/config/streamlit-launcher`:
|
||||||
|
|
||||||
|
```
|
||||||
|
config global 'global'
|
||||||
|
# Enable the launcher daemon
|
||||||
|
option enabled '1'
|
||||||
|
|
||||||
|
# Enable on-demand startup (vs always-running)
|
||||||
|
option on_demand '1'
|
||||||
|
|
||||||
|
# Minutes of inactivity before stopping an app
|
||||||
|
option idle_timeout '30'
|
||||||
|
|
||||||
|
# Seconds between idle checks
|
||||||
|
option check_interval '60'
|
||||||
|
|
||||||
|
# Minimum free memory (MB) before force-stopping apps
|
||||||
|
option memory_threshold '100'
|
||||||
|
|
||||||
|
# Max seconds to wait for app startup
|
||||||
|
option startup_timeout '30'
|
||||||
|
|
||||||
|
# App priorities (higher = keep running longer)
|
||||||
|
config priority 'control'
|
||||||
|
option app 'control'
|
||||||
|
option value '100'
|
||||||
|
option always_on '1'
|
||||||
|
|
||||||
|
config priority 'ytdownload'
|
||||||
|
option app 'ytdownload'
|
||||||
|
option value '30'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority System
|
||||||
|
|
||||||
|
| Priority | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| 100 + always_on | Never auto-stopped |
|
||||||
|
| 80-99 | Stopped last during memory pressure |
|
||||||
|
| 50 (default) | Normal priority |
|
||||||
|
| 1-49 | Stopped first during memory pressure |
|
||||||
|
|
||||||
|
## Integration with slforge
|
||||||
|
|
||||||
|
The launcher works alongside `slforge` (Streamlit Forge):
|
||||||
|
|
||||||
|
- `slforge` manages app configuration, creation, and basic start/stop
|
||||||
|
- `streamlit-launcherctl` adds on-demand and idle management
|
||||||
|
|
||||||
|
When on-demand is enabled:
|
||||||
|
1. User accesses `https://app.example.com`
|
||||||
|
2. HAProxy routes to mitmproxy
|
||||||
|
3. If app is down, mitmproxy can trigger startup via hook
|
||||||
|
4. Launcher starts app and waits for ready
|
||||||
|
5. Request is served
|
||||||
|
6. Access is tracked
|
||||||
|
7. After idle timeout, app is stopped
|
||||||
|
|
||||||
|
## Access Tracking
|
||||||
|
|
||||||
|
The launcher tracks app access via touch files in `/tmp/streamlit-access/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Track access (reset idle timer)
|
||||||
|
streamlit-launcherctl track <app>
|
||||||
|
|
||||||
|
# Or directly
|
||||||
|
touch /tmp/streamlit-access/<app>
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be triggered by:
|
||||||
|
- mitmproxy request hook
|
||||||
|
- HAProxy health check script
|
||||||
|
- Cron job parsing access logs
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
When free memory drops below threshold:
|
||||||
|
|
||||||
|
1. Apps are sorted by priority (lowest first)
|
||||||
|
2. Low-priority apps are stopped one by one
|
||||||
|
3. Stops when memory recovers above threshold
|
||||||
|
4. Always-on apps are never stopped
|
||||||
|
|
||||||
|
## Service Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable/start the daemon
|
||||||
|
/etc/init.d/streamlit-launcher enable
|
||||||
|
/etc/init.d/streamlit-launcher start
|
||||||
|
|
||||||
|
# Check daemon status
|
||||||
|
/etc/init.d/streamlit-launcher status
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
logread -e streamlit-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/usr/sbin/streamlit-launcherctl` | CLI tool |
|
||||||
|
| `/etc/config/streamlit-launcher` | UCI configuration |
|
||||||
|
| `/etc/init.d/streamlit-launcher` | Procd init script |
|
||||||
|
| `/tmp/streamlit-access/` | Access tracking files |
|
||||||
|
| `/usr/share/streamlit-launcher/loading.html` | Loading page template |
|
||||||
|
|
||||||
|
## Example: Optimize for Low Memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set aggressive timeout (10 min)
|
||||||
|
uci set streamlit-launcher.global.idle_timeout='10'
|
||||||
|
|
||||||
|
# Lower memory threshold (trigger cleanup at 150MB free)
|
||||||
|
uci set streamlit-launcher.global.memory_threshold='150'
|
||||||
|
|
||||||
|
# Make dashboard always-on
|
||||||
|
streamlit-launcherctl priority dashboard 100 1
|
||||||
|
|
||||||
|
# Lower priority for heavy apps
|
||||||
|
streamlit-launcherctl priority jupyter 20
|
||||||
|
streamlit-launcherctl priority analytics 30
|
||||||
|
|
||||||
|
uci commit streamlit-launcher
|
||||||
|
/etc/init.d/streamlit-launcher restart
|
||||||
|
```
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
config global 'global'
|
||||||
|
option enabled '1'
|
||||||
|
# Idle timeout in minutes before stopping an app
|
||||||
|
option idle_timeout '30'
|
||||||
|
# Check interval in seconds
|
||||||
|
option check_interval '60'
|
||||||
|
# Minimum free memory (MB) before force-stopping low-priority apps
|
||||||
|
option memory_threshold '100'
|
||||||
|
# Enable on-demand startup (vs always-running)
|
||||||
|
option on_demand '1'
|
||||||
|
# Startup timeout in seconds (max wait for app to be ready)
|
||||||
|
option startup_timeout '30'
|
||||||
|
# Directory for access tracking files
|
||||||
|
option tracking_dir '/tmp/streamlit-access'
|
||||||
|
# Log level: debug, info, warn, error
|
||||||
|
option log_level 'info'
|
||||||
|
|
||||||
|
# App priority (higher = keep running longer)
|
||||||
|
# Apps not listed default to priority 50
|
||||||
|
config priority 'default'
|
||||||
|
option app '*'
|
||||||
|
option value '50'
|
||||||
|
|
||||||
|
# Example: keep control dashboard always running
|
||||||
|
#config priority 'control'
|
||||||
|
# option app 'control'
|
||||||
|
# option value '100'
|
||||||
|
# option always_on '1'
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh /etc/rc.common
|
||||||
|
|
||||||
|
START=95
|
||||||
|
STOP=10
|
||||||
|
USE_PROCD=1
|
||||||
|
|
||||||
|
PROG=/usr/sbin/streamlit-launcherctl
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
local enabled
|
||||||
|
config_load streamlit-launcher
|
||||||
|
config_get enabled global enabled '0'
|
||||||
|
|
||||||
|
[ "$enabled" = "1" ] || return 0
|
||||||
|
|
||||||
|
procd_open_instance
|
||||||
|
procd_set_param command "$PROG" daemon
|
||||||
|
procd_set_param respawn 3600 5 5
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
|
|
||||||
|
service_triggers() {
|
||||||
|
procd_add_reload_trigger "streamlit-launcher"
|
||||||
|
}
|
||||||
|
|
||||||
|
reload_service() {
|
||||||
|
stop
|
||||||
|
start
|
||||||
|
}
|
||||||
@ -0,0 +1,494 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Streamlit On-Demand Launcher Controller
|
||||||
|
# Manages lazy-loading of Streamlit apps with idle shutdown
|
||||||
|
|
||||||
|
. /lib/functions.sh
|
||||||
|
|
||||||
|
TRACKING_DIR="/tmp/streamlit-access"
|
||||||
|
STARTUP_DIR="/tmp/streamlit-startup"
|
||||||
|
LOG_TAG="streamlit-launcher"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
load_config() {
|
||||||
|
config_load streamlit-launcher
|
||||||
|
config_get ENABLED global enabled '1'
|
||||||
|
config_get IDLE_TIMEOUT global idle_timeout '30'
|
||||||
|
config_get CHECK_INTERVAL global check_interval '60'
|
||||||
|
config_get MEMORY_THRESHOLD global memory_threshold '100'
|
||||||
|
config_get ON_DEMAND global on_demand '1'
|
||||||
|
config_get STARTUP_TIMEOUT global startup_timeout '30'
|
||||||
|
config_get TRACKING_DIR global tracking_dir '/tmp/streamlit-access'
|
||||||
|
config_get LOG_LEVEL global log_level 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
case "$LOG_LEVEL" in
|
||||||
|
debug) ;;
|
||||||
|
info) [ "$level" = "debug" ] && return ;;
|
||||||
|
warn) [ "$level" = "debug" -o "$level" = "info" ] && return ;;
|
||||||
|
error) [ "$level" != "error" ] && return ;;
|
||||||
|
esac
|
||||||
|
logger -t "$LOG_TAG" -p "daemon.$level" "$*"
|
||||||
|
[ -t 1 ] && echo "[$level] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get app priority (higher = more important)
|
||||||
|
get_priority() {
|
||||||
|
local app="$1"
|
||||||
|
local priority=50
|
||||||
|
local always_on=0
|
||||||
|
|
||||||
|
get_app_priority() {
|
||||||
|
local section="$1"
|
||||||
|
local cfg_app cfg_value cfg_always
|
||||||
|
config_get cfg_app "$section" app
|
||||||
|
config_get cfg_value "$section" value '50'
|
||||||
|
config_get cfg_always "$section" always_on '0'
|
||||||
|
|
||||||
|
if [ "$cfg_app" = "$app" ] || [ "$cfg_app" = "*" ]; then
|
||||||
|
priority="$cfg_value"
|
||||||
|
always_on="$cfg_always"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
config_foreach get_app_priority priority
|
||||||
|
echo "$priority $always_on"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track app access (called by mitmproxy hook or cron)
|
||||||
|
track_access() {
|
||||||
|
local app="$1"
|
||||||
|
mkdir -p "$TRACKING_DIR"
|
||||||
|
touch "$TRACKING_DIR/$app"
|
||||||
|
log debug "Tracked access for $app"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get last access time (seconds since epoch)
|
||||||
|
get_last_access() {
|
||||||
|
local app="$1"
|
||||||
|
local file="$TRACKING_DIR/$app"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
stat -c %Y "$file" 2>/dev/null || echo 0
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get idle time in minutes
|
||||||
|
get_idle_minutes() {
|
||||||
|
local app="$1"
|
||||||
|
local last_access=$(get_last_access "$app")
|
||||||
|
local now=$(date +%s)
|
||||||
|
if [ "$last_access" -gt 0 ]; then
|
||||||
|
echo $(( (now - last_access) / 60 ))
|
||||||
|
else
|
||||||
|
echo 9999
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if app is running
|
||||||
|
is_running() {
|
||||||
|
local app="$1"
|
||||||
|
local port
|
||||||
|
port=$(uci -q get streamlit-forge."$app".port)
|
||||||
|
[ -z "$port" ] && return 1
|
||||||
|
netstat -tln 2>/dev/null | grep -q ":$port " && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get free memory in MB
|
||||||
|
get_free_memory() {
|
||||||
|
awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start an app (on-demand)
|
||||||
|
start_app() {
|
||||||
|
local app="$1"
|
||||||
|
local wait="${2:-0}"
|
||||||
|
|
||||||
|
if is_running "$app"; then
|
||||||
|
log debug "App $app already running"
|
||||||
|
track_access "$app"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log info "Starting app $app on-demand"
|
||||||
|
mkdir -p "$STARTUP_DIR"
|
||||||
|
touch "$STARTUP_DIR/$app.starting"
|
||||||
|
|
||||||
|
# Start via slforge
|
||||||
|
slforge start "$app" >/dev/null 2>&1
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ "$wait" = "1" ] && [ $rc -eq 0 ]; then
|
||||||
|
# Wait for app to be ready
|
||||||
|
local port=$(uci -q get streamlit-forge."$app".port)
|
||||||
|
local timeout="$STARTUP_TIMEOUT"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
while [ $elapsed -lt $timeout ]; do
|
||||||
|
if netstat -tln 2>/dev/null | grep -q ":$port "; then
|
||||||
|
log info "App $app ready on port $port (${elapsed}s)"
|
||||||
|
rm -f "$STARTUP_DIR/$app.starting"
|
||||||
|
track_access "$app"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log error "App $app failed to start within ${timeout}s"
|
||||||
|
rm -f "$STARTUP_DIR/$app.starting"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$STARTUP_DIR/$app.starting"
|
||||||
|
[ $rc -eq 0 ] && track_access "$app"
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop an app
|
||||||
|
stop_app() {
|
||||||
|
local app="$1"
|
||||||
|
local reason="${2:-idle}"
|
||||||
|
|
||||||
|
if ! is_running "$app"; then
|
||||||
|
log debug "App $app not running"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log info "Stopping app $app (reason: $reason)"
|
||||||
|
slforge stop "$app" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check idle apps and stop them
|
||||||
|
check_idle() {
|
||||||
|
load_config
|
||||||
|
[ "$ENABLED" = "1" ] || return 0
|
||||||
|
|
||||||
|
local app idle priority always_on
|
||||||
|
|
||||||
|
# Get list of apps from streamlit-forge
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
|
||||||
|
if ! is_running "$app"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get priority info
|
||||||
|
read priority always_on <<EOF
|
||||||
|
$(get_priority "$app")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Skip always-on apps
|
||||||
|
if [ "$always_on" = "1" ]; then
|
||||||
|
log debug "App $app is always-on, skipping"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
idle=$(get_idle_minutes "$app")
|
||||||
|
log debug "App $app: idle=${idle}m, timeout=${IDLE_TIMEOUT}m, priority=$priority"
|
||||||
|
|
||||||
|
if [ "$idle" -ge "$IDLE_TIMEOUT" ]; then
|
||||||
|
stop_app "$app" "idle ${idle}m > ${IDLE_TIMEOUT}m"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check memory pressure and stop low-priority apps
|
||||||
|
check_memory() {
|
||||||
|
load_config
|
||||||
|
[ "$ENABLED" = "1" ] || return 0
|
||||||
|
|
||||||
|
local free_mb=$(get_free_memory)
|
||||||
|
if [ "$free_mb" -ge "$MEMORY_THRESHOLD" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log warn "Low memory: ${free_mb}MB < ${MEMORY_THRESHOLD}MB threshold"
|
||||||
|
|
||||||
|
# Build list of running apps with priorities
|
||||||
|
local apps_by_priority=""
|
||||||
|
local app priority always_on
|
||||||
|
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
is_running "$app" || continue
|
||||||
|
|
||||||
|
read priority always_on <<EOF
|
||||||
|
$(get_priority "$app")
|
||||||
|
EOF
|
||||||
|
[ "$always_on" = "1" ] && continue
|
||||||
|
|
||||||
|
apps_by_priority="$apps_by_priority
|
||||||
|
$priority $app"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sort by priority (lowest first) and stop until memory is OK
|
||||||
|
echo "$apps_by_priority" | sort -n | while read priority app; do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
|
||||||
|
stop_app "$app" "memory_pressure"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
free_mb=$(get_free_memory)
|
||||||
|
if [ "$free_mb" -ge "$MEMORY_THRESHOLD" ]; then
|
||||||
|
log info "Memory recovered: ${free_mb}MB"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Request app startup (called by external trigger)
|
||||||
|
request_start() {
|
||||||
|
local app="$1"
|
||||||
|
load_config
|
||||||
|
|
||||||
|
if [ "$ON_DEMAND" != "1" ]; then
|
||||||
|
log debug "On-demand disabled, ignoring start request for $app"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_app "$app" 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main daemon loop
|
||||||
|
daemon() {
|
||||||
|
load_config
|
||||||
|
log info "Streamlit Launcher daemon starting (idle=${IDLE_TIMEOUT}m, interval=${CHECK_INTERVAL}s)"
|
||||||
|
|
||||||
|
mkdir -p "$TRACKING_DIR"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
check_idle
|
||||||
|
check_memory
|
||||||
|
sleep "$CHECK_INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
status() {
|
||||||
|
load_config
|
||||||
|
echo "Streamlit Launcher Status"
|
||||||
|
echo "========================="
|
||||||
|
echo ""
|
||||||
|
printf "%-20s %s\n" "Enabled:" "$ENABLED"
|
||||||
|
printf "%-20s %s\n" "On-Demand:" "$ON_DEMAND"
|
||||||
|
printf "%-20s %sm\n" "Idle Timeout:" "$IDLE_TIMEOUT"
|
||||||
|
printf "%-20s %ss\n" "Check Interval:" "$CHECK_INTERVAL"
|
||||||
|
printf "%-20s %sMB\n" "Memory Threshold:" "$MEMORY_THRESHOLD"
|
||||||
|
printf "%-20s %sMB\n" "Free Memory:" "$(get_free_memory)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Apps:"
|
||||||
|
echo "-----"
|
||||||
|
printf "%-20s %-10s %-10s %-10s %-8s\n" "NAME" "STATUS" "IDLE" "PRIORITY" "ALWAYS"
|
||||||
|
|
||||||
|
local app priority always_on idle_mins status_str
|
||||||
|
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
|
||||||
|
read priority always_on <<EOF
|
||||||
|
$(get_priority "$app")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if is_running "$app"; then
|
||||||
|
status_str="${GREEN}running${NC}"
|
||||||
|
idle_mins="$(get_idle_minutes "$app")m"
|
||||||
|
else
|
||||||
|
status_str="${RED}stopped${NC}"
|
||||||
|
idle_mins="-"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$always_on" = "1" ] && always_str="yes" || always_str="no"
|
||||||
|
|
||||||
|
printf "%-20s ${status_str}%-10s %-10s %-10s %-8s\n" "$app" "" "$idle_mins" "$priority" "$always_str"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# List apps with detailed info
|
||||||
|
list() {
|
||||||
|
load_config
|
||||||
|
local app port domain enabled idle_mins status_str priority always_on
|
||||||
|
|
||||||
|
printf "%-18s %-8s %-6s %-25s %-8s %-6s\n" "APP" "STATUS" "IDLE" "DOMAIN" "PRIORITY" "ALWAYS"
|
||||||
|
echo "--------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
|
||||||
|
port=$(uci -q get streamlit-forge."$app".port)
|
||||||
|
domain=$(uci -q get streamlit-forge."$app".domain)
|
||||||
|
enabled=$(uci -q get streamlit-forge."$app".enabled)
|
||||||
|
|
||||||
|
read priority always_on <<EOF
|
||||||
|
$(get_priority "$app")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if is_running "$app"; then
|
||||||
|
status_str="running"
|
||||||
|
idle_mins="$(get_idle_minutes "$app")m"
|
||||||
|
else
|
||||||
|
status_str="stopped"
|
||||||
|
idle_mins="-"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$always_on" = "1" ] && always_str="yes" || always_str="-"
|
||||||
|
[ -z "$domain" ] && domain="-"
|
||||||
|
|
||||||
|
printf "%-18s %-8s %-6s %-25s %-8s %-6s\n" "$app" "$status_str" "$idle_mins" "$domain" "$priority" "$always_str"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set app priority
|
||||||
|
set_priority() {
|
||||||
|
local app="$1"
|
||||||
|
local priority="$2"
|
||||||
|
local always_on="${3:-0}"
|
||||||
|
|
||||||
|
[ -z "$app" ] || [ -z "$priority" ] && {
|
||||||
|
echo "Usage: $0 priority <app> <priority> [always_on]"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if app exists
|
||||||
|
uci -q get streamlit-forge."$app" >/dev/null || {
|
||||||
|
echo "Error: App '$app' not found"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find or create priority section
|
||||||
|
local section=""
|
||||||
|
find_section() {
|
||||||
|
local s="$1"
|
||||||
|
local cfg_app
|
||||||
|
config_get cfg_app "$s" app
|
||||||
|
[ "$cfg_app" = "$app" ] && section="$s"
|
||||||
|
}
|
||||||
|
config_load streamlit-launcher
|
||||||
|
config_foreach find_section priority
|
||||||
|
|
||||||
|
if [ -z "$section" ]; then
|
||||||
|
section=$(uci add streamlit-launcher priority)
|
||||||
|
uci set streamlit-launcher."$section".app="$app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
uci set streamlit-launcher."$section".value="$priority"
|
||||||
|
uci set streamlit-launcher."$section".always_on="$always_on"
|
||||||
|
uci commit streamlit-launcher
|
||||||
|
|
||||||
|
echo "Set priority for $app: $priority (always_on: $always_on)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop all managed apps
|
||||||
|
stop_all() {
|
||||||
|
log info "Stopping all managed apps"
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
is_running "$app" && stop_app "$app" "stop_all"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start all enabled apps (for non-on-demand mode)
|
||||||
|
start_all() {
|
||||||
|
log info "Starting all enabled apps"
|
||||||
|
for app in $(uci -q show streamlit-forge 2>/dev/null | grep '=app$' | cut -d. -f2 | cut -d= -f1); do
|
||||||
|
[ -z "$app" ] && continue
|
||||||
|
local enabled=$(uci -q get streamlit-forge."$app".enabled)
|
||||||
|
[ "$enabled" = "1" ] && start_app "$app" 0
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Streamlit On-Demand Launcher
|
||||||
|
|
||||||
|
Usage: $0 <command> [options]
|
||||||
|
|
||||||
|
COMMANDS:
|
||||||
|
daemon Run the launcher daemon (called by init.d)
|
||||||
|
status Show launcher status and app states
|
||||||
|
list List all apps with details
|
||||||
|
start <app> Start an app on-demand
|
||||||
|
stop <app> Stop an app
|
||||||
|
stop-all Stop all managed apps
|
||||||
|
start-all Start all enabled apps
|
||||||
|
track <app> Track access for an app (resets idle timer)
|
||||||
|
priority <app> <n> Set app priority (higher = keep running longer)
|
||||||
|
Add '1' as third arg for always-on
|
||||||
|
check Run idle check once
|
||||||
|
check-memory Run memory pressure check once
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
$0 status
|
||||||
|
$0 start ytdownload
|
||||||
|
$0 priority control 100 1 # Always keep 'control' running
|
||||||
|
$0 priority ytdownload 30 # Lower priority, stop sooner
|
||||||
|
|
||||||
|
CONFIG: /etc/config/streamlit-launcher
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
case "$1" in
|
||||||
|
daemon)
|
||||||
|
daemon
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
list
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
[ -z "$2" ] && { echo "Usage: $0 start <app>"; exit 1; }
|
||||||
|
load_config
|
||||||
|
start_app "$2" 1
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
[ -z "$2" ] && { echo "Usage: $0 stop <app>"; exit 1; }
|
||||||
|
load_config
|
||||||
|
stop_app "$2" "manual"
|
||||||
|
;;
|
||||||
|
stop-all)
|
||||||
|
load_config
|
||||||
|
stop_all
|
||||||
|
;;
|
||||||
|
start-all)
|
||||||
|
load_config
|
||||||
|
start_all
|
||||||
|
;;
|
||||||
|
track)
|
||||||
|
[ -z "$2" ] && { echo "Usage: $0 track <app>"; exit 1; }
|
||||||
|
load_config
|
||||||
|
track_access "$2"
|
||||||
|
;;
|
||||||
|
priority)
|
||||||
|
set_priority "$2" "$3" "$4"
|
||||||
|
;;
|
||||||
|
check)
|
||||||
|
check_idle
|
||||||
|
;;
|
||||||
|
check-memory)
|
||||||
|
check_memory
|
||||||
|
;;
|
||||||
|
request)
|
||||||
|
[ -z "$2" ] && { echo "Usage: $0 request <app>"; exit 1; }
|
||||||
|
request_start "$2"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="refresh" content="3">
|
||||||
|
<title>Starting App...</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0e17;
|
||||||
|
--card: #161e2e;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--green: #00C853;
|
||||||
|
--blue: #2979FF;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 4px solid var(--card);
|
||||||
|
border-top-color: var(--green);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
width: 200px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 20px auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--green), var(--blue));
|
||||||
|
animation: progress 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes progress {
|
||||||
|
0% { width: 0%; }
|
||||||
|
50% { width: 70%; }
|
||||||
|
100% { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h1>Starting Application</h1>
|
||||||
|
<p>The app is being launched on-demand...</p>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">This page will refresh automatically</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user