v0.27.0 — user-configurable app subdomains
Users can now customize the subdomain for each app during deployment instead of using a fixed value. The deploy page shows an editable text input with the default pre-filled and the base domain as a suffix. New "subdomain" deploy field type with DNS-safe format validation, reserved name blocklist, and uniqueness check across deployed stacks. Locked after deploy — changing requires Remove + Redeploy. Backward compatible: InjectMissingFields() auto-fills SUBDOMAIN from .felhom.yml defaults for existing deployed apps on next sync/restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -202,6 +202,21 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data["StorageLabels"] = storageLabels
|
||||
|
||||
// Build effective subdomain lookup (stored env > metadata fallback)
|
||||
subdomains := make(map[string]string)
|
||||
for _, stack := range s.stackMgr.GetStacks() {
|
||||
if stack.Deployed {
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||||
subdomains[stack.Name] = sd
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
subdomains[stack.Name] = stack.Meta.Subdomain
|
||||
}
|
||||
data["Subdomains"] = subdomains
|
||||
|
||||
s.executeTemplate(w, r, "stacks", data)
|
||||
}
|
||||
|
||||
@@ -259,12 +274,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
if alreadyDeployed && appCfg != nil {
|
||||
for _, f := range meta.AutoGeneratedFields() {
|
||||
if val, ok := appCfg.Env[f.EnvVar]; ok {
|
||||
// For domain fields show the full URL (subdomain.base_domain) as displayed on app cards.
|
||||
if f.Type == "domain" && meta.Subdomain != "" {
|
||||
autoFieldValues[f.EnvVar] = meta.Subdomain + "." + val
|
||||
} else {
|
||||
autoFieldValues[f.EnvVar] = val
|
||||
}
|
||||
autoFieldValues[f.EnvVar] = val
|
||||
}
|
||||
}
|
||||
} else if !alreadyDeployed {
|
||||
@@ -275,6 +285,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
}
|
||||
}
|
||||
data["AutoFieldValues"] = autoFieldValues
|
||||
// For deployed apps, pass stored field values so subdomain and other user fields show current values
|
||||
if alreadyDeployed && appCfg != nil {
|
||||
data["DeployedFieldValues"] = appCfg.Env
|
||||
}
|
||||
// Storage paths with free space info for deploy dropdown
|
||||
var deployPaths []DeployStoragePath
|
||||
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
||||
@@ -406,6 +420,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective subdomain (stored env > metadata fallback)
|
||||
effectiveSubdomain := found.Meta.Subdomain
|
||||
if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" {
|
||||
effectiveSubdomain = sd
|
||||
}
|
||||
|
||||
data := s.baseData("stacks", found.Meta.DisplayName)
|
||||
data["Stack"] = found
|
||||
data["Meta"] = found.Meta
|
||||
@@ -414,6 +434,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["CurrentValues"] = currentValues
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||||
|
||||
s.executeTemplate(w, r, "app_info", data)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{if .Stack.Deployed}}
|
||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
|
||||
{{if .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>{{end}}
|
||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Stack.Orphaned}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
||||
|
||||
@@ -270,7 +270,22 @@
|
||||
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
|
||||
</label>
|
||||
|
||||
{{if eq .Type "select"}}
|
||||
{{if eq .Type "subdomain"}}
|
||||
<div class="subdomain-input-group">
|
||||
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||
class="form-control subdomain-input"
|
||||
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
|
||||
placeholder="aldomain"
|
||||
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
|
||||
required
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
|
||||
<span class="subdomain-suffix">.{{$.Domain}}</span>
|
||||
</div>
|
||||
{{if not $.AlreadyDeployed}}
|
||||
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
|
||||
{{end}}
|
||||
{{else if eq .Type "select"}}
|
||||
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||
{{range .Options}}
|
||||
@@ -510,6 +525,17 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side validation: check subdomain format
|
||||
const subdomainField = e.target.querySelector('.subdomain-input');
|
||||
if (subdomainField && !subdomainField.disabled) {
|
||||
const sd = subdomainField.value.trim().toLowerCase();
|
||||
if (!sd || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sd)) {
|
||||
alert('Az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel.');
|
||||
subdomainField.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side validation: check all required fields
|
||||
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
|
||||
for (const rf of requiredFields) {
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||
<div>
|
||||
<h3>{{.Meta.DisplayName}}</h3>
|
||||
{{if .Meta.Subdomain}}
|
||||
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
|
||||
{{.Meta.Subdomain}}.{{$.Domain}} ↗
|
||||
{{$subdomain := index $.Subdomains .Name}}
|
||||
{{if $subdomain}}
|
||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
||||
{{$subdomain}}.{{$.Domain}} ↗
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -574,6 +574,13 @@ select.form-control { appearance: auto; }
|
||||
select.form-control option { background: var(--bg-secondary); color: var(--text-primary); }
|
||||
.input-with-button { display: flex; gap: .5rem; }
|
||||
.input-with-button .form-control { flex: 1; }
|
||||
.subdomain-input-group { display: flex; align-items: center; }
|
||||
.subdomain-input-group .subdomain-input { max-width: 14rem; border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none; }
|
||||
.subdomain-input-group .subdomain-suffix {
|
||||
padding: .55rem .75rem; background: var(--bg-primary); border: 1px solid var(--border-color);
|
||||
border-left: none; border-radius: 0 8px 8px 0; color: var(--text-secondary);
|
||||
font-family: var(--font-mono, monospace); font-size: .9rem; white-space: nowrap;
|
||||
}
|
||||
.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
|
||||
.required { color: var(--red); }
|
||||
.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; }
|
||||
|
||||
Reference in New Issue
Block a user