mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 23:28:23 -04:00
* feat(nodes): report CPU telemetry Assisted-by: Codex:gpt-6 * feat(nodes): add fleet view utilities Assisted-by: Codex:gpt-6 * feat(nodes): add fleet operations dashboard Replace the panel roster with aggregate capacity gauges, fleet filtering and selection, bounded bulk actions, and an on-demand node inspector. Extend node details and distributed-mode documentation with CPU and models-disk telemetry. Assisted-by: Codex:gpt-6 * fix(nodes): harden fleet lifecycle actions Assisted-by: Codex:gpt-6 * fix(nodes): restore compact fleet composition Keep fleet health, capacity, and attention in one compact overview at ordinary desktop widths. The inspector now overlays the roster until the workbench can preserve a useful table beside it. Assisted-by: Codex:gpt-6 * feat(nodes): add accessible running models workbench Assisted-by: Codex:gpt-6 * fix(nodes): correct model view ARIA links Keep each tab panel available for its controlling tab while native hidden state removes inactive content from accessibility navigation. Model controls now expose only supported state and valid inspector relationships. Assisted-by: Codex:gpt-6 * fix(nodes): align lifecycle and capacity states Pending nodes now expose approval wherever node actions appear, while other lifecycle controls follow the server transition rules. Capacity totals exclude incomplete readings so missing availability remains unknown. Assisted-by: Codex:gpt-6 * fix(nodes): restore approved dashboard composition Assisted-by: Codex:gpt-6 * fix(nodes): integrate operate navigation Assisted-by: Codex:gpt-6 * fix(nodes): restore low density fleet view Assisted-by: Codex:gpt-6 * fix(nodes): preserve complete operate menu Assisted-by: Codex:gpt-6 * fix(nodes): preserve inspector workspace height Assisted-by: Codex:gpt-6 * fix(nodes): restore standard operate navigation Assisted-by: Codex:gpt-6 * feat(ui): add collapsible console rail Assisted-by: Codex:gpt-6 * feat(nodes): stop models from fleet view Assisted-by: Codex:gpt-6 * fix(nodes): make inspector a full height drawer Assisted-by: Codex:gpt-6 * fix(model): stop mixed local and remote placements Assisted-by: Codex:gpt-6 * fix(ui): announce action menu navigation Assisted-by: Codex:gpt-6 * fix(nodes): keep inspector within viewport Assisted-by: Codex:gpt-6 * fix(ui): preserve focus across model actions Assisted-by: Codex:gpt-6 --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
170 lines
5.7 KiB
Go
170 lines
5.7 KiB
Go
package model_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
process "github.com/mudler/go-processmanager"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakeRemoteUnloader records the models it was asked to unload so the specs can
|
|
// assert the remote path was actually taken (not merely that no error
|
|
// surfaced). It mirrors the real adapter's contract: unloading is idempotent
|
|
// and reports nil even when nothing was loaded, so presence is a separate
|
|
// question.
|
|
type fakeRemoteUnloader struct {
|
|
called []string
|
|
unloadErr error
|
|
|
|
present bool
|
|
presenceErr error
|
|
asked []string
|
|
}
|
|
|
|
func (f *fakeRemoteUnloader) UnloadRemoteModel(modelName string) error {
|
|
f.called = append(f.called, modelName)
|
|
return f.unloadErr
|
|
}
|
|
|
|
func (f *fakeRemoteUnloader) HasRemoteModel(_ context.Context, modelName string) (bool, error) {
|
|
f.asked = append(f.asked, modelName)
|
|
return f.present, f.presenceErr
|
|
}
|
|
|
|
// unloaderWithoutPresence is a RemoteModelUnloader that does NOT implement
|
|
// RemoteModelPresenceChecker, pinning the compatibility path for third-party
|
|
// implementations of the older interface.
|
|
type unloaderWithoutPresence struct{ called []string }
|
|
|
|
func (u *unloaderWithoutPresence) UnloadRemoteModel(modelName string) error {
|
|
u.called = append(u.called, modelName)
|
|
return nil
|
|
}
|
|
|
|
// In distributed mode the authoritative record of "is this model loaded" is
|
|
// the shared node registry, not this replica's in-memory store. A frontend
|
|
// replica that never served the model itself (load balancer picked another
|
|
// replica, or this one restarted) has no local entry, so ShutdownModel
|
|
// short-circuited on a local-store miss and reported "model not found" for a
|
|
// model that was demonstrably running on a worker — while the remote unload
|
|
// path it documents was never reached.
|
|
var _ = Describe("ShutdownModel in distributed mode", func() {
|
|
var (
|
|
modelLoader *model.ModelLoader
|
|
unloader *fakeRemoteUnloader
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
systemState, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
modelLoader = model.NewModelLoader(systemState)
|
|
unloader = &fakeRemoteUnloader{}
|
|
})
|
|
|
|
It("delegates to the remote unloader when the model is not in the local store", func() {
|
|
unloader.present = true
|
|
modelLoader.SetRemoteUnloader(unloader)
|
|
|
|
err := modelLoader.ShutdownModel("longcat-video-avatar-1.5")
|
|
|
|
Expect(unloader.called).To(ConsistOf("longcat-video-avatar-1.5"),
|
|
"a model absent locally may still be loaded on a worker; the remote unloader must be consulted")
|
|
Expect(err).ToNot(HaveOccurred(),
|
|
"stopping a model that is running on a worker must succeed, not report 'model not found'")
|
|
})
|
|
|
|
DescribeTable("stops mixed local and remote placements",
|
|
func(remoteErr error) {
|
|
unloader.unloadErr = remoteErr
|
|
modelLoader.SetRemoteUnloader(unloader)
|
|
|
|
localProcess := process.New(
|
|
process.WithTemporaryStateDir(),
|
|
process.WithName("/bin/sleep"),
|
|
process.WithArgs("300"),
|
|
)
|
|
Expect(localProcess.Run()).To(Succeed())
|
|
DeferCleanup(func() {
|
|
if localProcess.IsAlive() {
|
|
_ = localProcess.Stop()
|
|
}
|
|
})
|
|
|
|
_, err := modelLoader.LoadModel("mixed", "mixed", func(_, _, _ string) (*model.Model, error) {
|
|
return model.NewModel("mixed", "local", localProcess), nil
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
var hookCalls int
|
|
modelLoader.OnModelUnload(func(modelName string) {
|
|
Expect(modelName).To(Equal("mixed"))
|
|
hookCalls++
|
|
})
|
|
|
|
err = modelLoader.ShutdownModelForce("mixed")
|
|
|
|
Expect(localProcess.IsAlive()).To(BeFalse())
|
|
Expect(modelLoader.ListLoadedModels()).To(BeEmpty())
|
|
Expect(unloader.called).To(ConsistOf("mixed"))
|
|
Expect(hookCalls).To(Equal(1))
|
|
if remoteErr == nil {
|
|
Expect(err).NotTo(HaveOccurred())
|
|
return
|
|
}
|
|
Expect(errors.Is(err, remoteErr)).To(BeTrue())
|
|
},
|
|
Entry("when every placement stops", nil),
|
|
Entry("when local stop succeeds but a remote placement fails", errors.New("worker unreachable")),
|
|
)
|
|
|
|
It("reports not-found only after the registry confirms no node has it", func() {
|
|
unloader.present = false
|
|
modelLoader.SetRemoteUnloader(unloader)
|
|
|
|
err := modelLoader.ShutdownModel("never-loaded")
|
|
|
|
Expect(unloader.asked).To(ConsistOf("never-loaded"),
|
|
"the registry must be consulted before declaring a model not found")
|
|
Expect(err).To(MatchError(model.ErrModelNotFound),
|
|
"absent locally AND cluster-wide is the only case that may report not-found")
|
|
Expect(unloader.called).To(BeEmpty(),
|
|
"nothing to unload — no point publishing a stop for a model no node holds")
|
|
})
|
|
|
|
It("does not claim not-found when the registry lookup fails", func() {
|
|
// An unreachable registry is not evidence of absence. Reporting 404
|
|
// here would tell an operator the model is gone on the strength of a
|
|
// failed lookup.
|
|
unloader.presenceErr = errors.New("registry unavailable")
|
|
modelLoader.SetRemoteUnloader(unloader)
|
|
|
|
err := modelLoader.ShutdownModel("maybe-loaded")
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err).ToNot(MatchError(model.ErrModelNotFound))
|
|
})
|
|
|
|
It("still unloads via an unloader that cannot answer presence", func() {
|
|
// Older RemoteModelUnloader implementations have no presence check.
|
|
// They must keep working: attempt the unload rather than refusing it.
|
|
legacy := &unloaderWithoutPresence{}
|
|
modelLoader.SetRemoteUnloader(legacy)
|
|
|
|
err := modelLoader.ShutdownModel("some-model")
|
|
|
|
Expect(legacy.called).To(ConsistOf("some-model"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("still reports not-found when no remote unloader is configured", func() {
|
|
// Single-node behavior must be unchanged.
|
|
err := modelLoader.ShutdownModel("never-loaded")
|
|
Expect(err).To(MatchError(model.ErrModelNotFound))
|
|
})
|
|
})
|