diff --git a/services/funcs/index.html b/services/funcs/index.html
index 02584ba..3aaab6f 100644
--- a/services/funcs/index.html
+++ b/services/funcs/index.html
@@ -176,6 +176,18 @@
color: #f85149;
}
+ .badge-kind-service {
+ background: #0d2538;
+ border: 1px solid #1158a7;
+ color: #58a6ff;
+ }
+
+ .badge-kind-function {
+ background: #1a2538;
+ border: 1px solid #30363d;
+ color: #8b949e;
+ }
+
.fn-url {
font-size: 0.76rem;
color: #58a6ff;
@@ -328,7 +340,10 @@
var byFn = data.triggersByFn || {};
document.getElementById('ns-label').textContent = ns;
- document.getElementById('summary').textContent = 'Функций: ' + fns.length;
+ var svcsCount = fns.filter(function(f){ return f.kind === 'service'; }).length;
+ var fnsCount = fns.filter(function(f){ return f.kind !== 'service'; }).length;
+ document.getElementById('summary').textContent =
+ 'Функций: ' + fnsCount + ' | Сервисов: ' + svcsCount + ' | Всего: ' + fns.length;
var list = document.getElementById('fn-list');
if (fns.length === 0) {
@@ -348,21 +363,40 @@
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
- $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase);
+ $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
+ if (fn.kind) {
+ var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
+ $txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
+ }
+
+ // sless_service имеет прямой URL из статуса деплоя
+ if (fn.url) {
+ var a = document.createElement('a');
+ a.className = 'fn-url';
+ a.href = fn.url;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ a.textContent = fn.url;
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
+ hdr.appendChild(a);
+ }
for (var t = 0; t < triggers.length; t++) {
var tr = triggers[t];
if (tr.type === 'http') {
- var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
- if (url) {
- var a = document.createElement('a');
- a.className = 'fn-url';
- a.href = url;
- a.target = '_blank';
- a.rel = 'noopener noreferrer';
- a.textContent = url;
- a.addEventListener('click', function (e) { e.stopPropagation(); });
- hdr.appendChild(a);
+ // Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
+ if (!fn.url) {
+ var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
+ if (url) {
+ var a = document.createElement('a');
+ a.className = 'fn-url';
+ a.href = url;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ a.textContent = url;
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
+ hdr.appendChild(a);
+ }
}
hdr.appendChild(makeTriggerBtn(tr, ns));
} else if (tr.type === 'cron') {
diff --git a/services/funcs/main.go b/services/funcs/main.go
index bc33ca3..1af680d 100644
--- a/services/funcs/main.go
+++ b/services/funcs/main.go
@@ -1,4 +1,4 @@
-// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
+// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
@@ -39,7 +39,9 @@ import (
//go:embed index.html
var htmlPageTemplate string
-// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
+// fnResponse — унифицированная запись функции для листинга.
+// Kind="function" — job-style (запускается per-request).
+// Kind="service" — always-on Deployment (всегда готов к запросам, имеет прямой URL).
type fnResponse struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
@@ -47,6 +49,19 @@ type fnResponse struct {
Message string `json:"message"`
CreatedAt string `json:"created_at"`
LastBuiltAt string `json:"last_built_at"`
+ Kind string `json:"kind"` // "function" | "service"
+ URL string `json:"url,omitempty"` // прямой URL для sless_service
+}
+
+// svcResponse — ответ /v1/namespaces/{ns}/services (подмножество полей оператора).
+type svcResponse struct {
+ Name string `json:"name"`
+ Runtime string `json:"runtime"`
+ Phase string `json:"phase"`
+ Message string `json:"message"`
+ CreatedAt string `json:"created_at"`
+ LastBuiltAt string `json:"last_built_at"`
+ URL string `json:"url"`
}
// trResponse — ответ /v1/namespaces/{ns}/triggers
@@ -182,13 +197,35 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
}
-// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
+// fetchAndRender загружает functions + services из оператора и отдаёт ответ.
+// Оба типа объединяются в единый список — пользователь видит их как «функции».
func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
return
}
+ for i := range fns {
+ fns[i].Kind = "function"
+ }
+
+ // sless_service — always-on Deployment + URL. Объединяем с функциями.
+ svcs, svcErr := apiGet[[]svcResponse](operatorURL+"/v1/namespaces/"+ns+"/services", authHeader)
+ if svcErr == nil {
+ for _, svc := range svcs {
+ fns = append(fns, fnResponse{
+ Name: svc.Name,
+ Runtime: svc.Runtime,
+ Phase: svc.Phase,
+ Message: svc.Message,
+ CreatedAt: svc.CreatedAt,
+ LastBuiltAt: svc.LastBuiltAt,
+ Kind: "service",
+ URL: svc.URL,
+ })
+ }
+ }
+
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
@@ -279,7 +316,10 @@ func servePlainText(w http.ResponseWriter, ns, externalURL string, fns []fnRespo
} else {
sb.WriteString(" active: нет\n")
}
- if len(it.httpT) > 0 {
+ // Для sless_service — прямой URL из статуса деплоя
+ if fn.URL != "" {
+ sb.WriteString(fmt.Sprintf(" url: %s\n", fn.URL))
+ } else if len(it.httpT) > 0 {
url := it.httpT[0].URL
if externalURL != "" {
url = externalURL + "/fn/" + ns + "/" + fn.Name
@@ -415,6 +455,13 @@ func isValidK8sName(s string) bool {
}
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
+ if fn.Kind == "service" {
+ active := "неактивна"
+ if fn.Phase == "Ready" {
+ active = "активна"
+ }
+ return fmt.Sprintf("always-on сервис (%s) — %s, %s", fn.Runtime, fn.Phase, active)
+ }
if len(httpT) > 0 {
active := "активна"
if !httpT[0].Active {