feat: logs modal + ESC close + /functions/:name/logs endpoint (v1.3.61)
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
|
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,11 @@ func main() {
|
|||||||
log.Fatalf("create dynamic client: %v", err)
|
log.Fatalf("create dynamic client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kube, err := kubernetes.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create kubernetes client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
authenticator := &auth.MultiAuthenticator{
|
authenticator := &auth.MultiAuthenticator{
|
||||||
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
||||||
Demo: &auth.DemoAuthenticator{},
|
Demo: &auth.DemoAuthenticator{},
|
||||||
@@ -44,6 +50,7 @@ func main() {
|
|||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
|
Kube: kube,
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
RouterURL: routerURL,
|
RouterURL: routerURL,
|
||||||
HTTPTimeout: httpTimeout,
|
HTTPTimeout: httpTimeout,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.60
|
image: naeel/fission-console:v1.3.61
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
|
|||||||
+4
-1
@@ -3,12 +3,15 @@ module fission-console
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
golang.org/x/net v0.47.0
|
||||||
|
k8s.io/api v0.34.1
|
||||||
k8s.io/apimachinery v0.34.1
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.1
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
@@ -16,6 +19,7 @@ require (
|
|||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
@@ -27,7 +31,6 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
||||||
@@ -332,6 +333,12 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /functions/:name/logs — логи подов функции
|
||||||
|
if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet {
|
||||||
|
s.handleGetFunctionLogs(w, r, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1613,3 +1620,57 @@ func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Requ
|
|||||||
"source_type": "archive",
|
"source_type": "archive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetFunctionLogs возвращает логи пода функции (последние 100 строк).
|
||||||
|
// Ищет под по лейблу functionName=<name> в namespace пользователя.
|
||||||
|
func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ns := s.userNS(r)
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if s.kube == nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "kubernetes client not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
labelSelector := "functionName=" + name
|
||||||
|
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: labelSelector,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "list pods: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pods.Items) == 0 {
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"logs": "(нет запущенных подов для функции " + name + ")",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLogs strings.Builder
|
||||||
|
tailLines := int64(100)
|
||||||
|
for _, pod := range pods.Items {
|
||||||
|
req := s.kube.CoreV1().Pods(ns).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||||
|
Container: "user-container",
|
||||||
|
TailLines: &tailLines,
|
||||||
|
})
|
||||||
|
rc, err := req.Stream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
allLogs.WriteString("[" + pod.Name + ": ошибка чтения логов: " + err.Error() + "]\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if allLogs.Len() > 0 {
|
||||||
|
allLogs.WriteString("\n--- " + pod.Name + " ---\n")
|
||||||
|
} else {
|
||||||
|
allLogs.WriteString("--- " + pod.Name + " ---\n")
|
||||||
|
}
|
||||||
|
allLogs.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"logs": allLogs.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
||||||
@@ -31,6 +32,7 @@ const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|||||||
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
dyn dynamic.Interface
|
dyn dynamic.Interface
|
||||||
|
kube kubernetes.Interface // typed client — только для логов подов
|
||||||
ns string // системный namespace (fallback, обычно "fission")
|
ns string // системный namespace (fallback, обычно "fission")
|
||||||
routerURL string
|
routerURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -62,6 +64,7 @@ type Server struct {
|
|||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Dyn dynamic.Interface
|
Dyn dynamic.Interface
|
||||||
|
Kube kubernetes.Interface
|
||||||
Namespace string
|
Namespace string
|
||||||
RouterURL string
|
RouterURL string
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
@@ -80,6 +83,7 @@ func NewServer(cfg Config) *Server {
|
|||||||
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
||||||
return &Server{
|
return &Server{
|
||||||
dyn: cfg.Dyn,
|
dyn: cfg.Dyn,
|
||||||
|
kube: cfg.Kube,
|
||||||
ns: cfg.Namespace,
|
ns: cfg.Namespace,
|
||||||
routerURL: cfg.RouterURL,
|
routerURL: cfg.RouterURL,
|
||||||
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
||||||
|
|||||||
+14
-2
@@ -102,7 +102,7 @@
|
|||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.60</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.61</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin:0;">
|
<div class="row" style="margin:0;">
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
@@ -384,6 +384,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="logs-modal" class="modal">
|
||||||
|
<div class="panel" style="width:700px; max-width:95vw;">
|
||||||
|
<h3 id="logs-title">Логи функции</h3>
|
||||||
|
<textarea id="logs-output" readonly
|
||||||
|
style="width:100%; height:340px; font-family:monospace; font-size:12px; background:var(--bg-alt); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; padding:10px; resize:vertical; white-space:pre;"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" onclick="closeLogs()">Закрыть</button>
|
||||||
|
<button class="btn ghost" onclick="refreshLogs()">Обновить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="help-modal" class="modal">
|
<div id="help-modal" class="modal">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Help</h3>
|
<h3>Help</h3>
|
||||||
@@ -462,7 +474,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.60</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.61</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async function reloadAll() {
|
|||||||
var actions = tfBadge +
|
var actions = tfBadge +
|
||||||
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
||||||
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
||||||
|
'<button class="btn ghost" onclick="openLogs(\'' + h(name) + '\')">Логи</button> ' +
|
||||||
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="mono">' + h(name) + '</td>' +
|
'<td class="mono">' + h(name) + '</td>' +
|
||||||
|
|||||||
+44
-1
@@ -46,7 +46,20 @@ function makeDraggable(modalId) {
|
|||||||
|
|
||||||
// Инициализация после загрузки DOM
|
// Инициализация после загрузки DOM
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal'].forEach(makeDraggable);
|
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal'].forEach(makeDraggable);
|
||||||
|
|
||||||
|
// ESC закрывает активное модальное окно
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
var modals = ['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal'];
|
||||||
|
for (var i = 0; i < modals.length; i++) {
|
||||||
|
var el = document.getElementById(modals[i]);
|
||||||
|
if (el && el.classList.contains('open')) {
|
||||||
|
el.classList.remove('open');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// showModalError — показывает ошибку внутри модалки.
|
// showModalError — показывает ошибку внутри модалки.
|
||||||
@@ -136,3 +149,33 @@ async function submitInvoke() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openLogs — открывает модалку с логами функции.
|
||||||
|
async function openLogs(name) {
|
||||||
|
S.currentLogs = name;
|
||||||
|
document.getElementById('logs-title').textContent = 'Логи: ' + name;
|
||||||
|
document.getElementById('logs-output').value = 'Загрузка...';
|
||||||
|
document.getElementById('logs-modal').classList.add('open');
|
||||||
|
await _fetchLogs(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogs() {
|
||||||
|
document.getElementById('logs-modal').classList.remove('open');
|
||||||
|
S.currentLogs = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
if (S.currentLogs) await _fetchLogs(S.currentLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchLogs(name) {
|
||||||
|
var out = document.getElementById('logs-output');
|
||||||
|
try {
|
||||||
|
var data = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/logs');
|
||||||
|
out.value = data.logs || '(нет логов)';
|
||||||
|
// прокручиваем вниз
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
out.value = 'Ошибка загрузки логов: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user