chatglance 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatglance/__init__.py +5 -0
- chatglance/cli.py +221 -0
- chatglance/glance_config.py +104 -0
- chatglance/projects.py +224 -0
- chatglance/runtime.py +122 -0
- chatglance/systemd.py +150 -0
- chatglance-0.0.1.dist-info/METADATA +123 -0
- chatglance-0.0.1.dist-info/RECORD +12 -0
- chatglance-0.0.1.dist-info/WHEEL +5 -0
- chatglance-0.0.1.dist-info/entry_points.txt +2 -0
- chatglance-0.0.1.dist-info/licenses/LICENSE +21 -0
- chatglance-0.0.1.dist-info/top_level.txt +1 -0
chatglance/__init__.py
ADDED
chatglance/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""CLI entrypoint for chatglance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from chatglance import __version__
|
|
11
|
+
from chatglance.glance_config import patch_disks_from_files, update_projects_page_from_files
|
|
12
|
+
from chatglance.projects import PAGE_NAME, build_projects_page, dump_yaml, load_inventory
|
|
13
|
+
from chatglance.runtime import maintain_config, runtime_path
|
|
14
|
+
from chatglance.systemd import render_all_units, write_units
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _purpose(command: click.Command) -> str:
|
|
18
|
+
text = command.short_help or inspect.getdoc(command.callback) or ""
|
|
19
|
+
return " ".join(text.strip().split()).rstrip(".")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parameter_piece(parameter: click.Parameter) -> str | None:
|
|
23
|
+
if getattr(parameter, "hidden", False) or parameter.name == "help":
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(parameter, click.Argument):
|
|
26
|
+
piece = parameter.name.upper().replace("_", "-")
|
|
27
|
+
if not parameter.required:
|
|
28
|
+
piece = f"[{piece}]"
|
|
29
|
+
if parameter.nargs == -1:
|
|
30
|
+
piece = f"{piece}..."
|
|
31
|
+
return piece
|
|
32
|
+
if not isinstance(parameter, click.Option):
|
|
33
|
+
return None
|
|
34
|
+
option_names = [name for name in (*parameter.opts, *parameter.secondary_opts) if name.startswith("--")]
|
|
35
|
+
if not option_names:
|
|
36
|
+
option_names = [name for name in (*parameter.opts, *parameter.secondary_opts) if name.startswith("-")]
|
|
37
|
+
if not option_names:
|
|
38
|
+
return None
|
|
39
|
+
if parameter.is_flag or parameter.flag_value is not None:
|
|
40
|
+
piece = "/".join(option_names)
|
|
41
|
+
else:
|
|
42
|
+
metavar = parameter.metavar or parameter.name.upper().replace("_", "-")
|
|
43
|
+
piece = f"{'/'.join(option_names)} {metavar}"
|
|
44
|
+
if not parameter.required:
|
|
45
|
+
piece = f"[{piece}]"
|
|
46
|
+
return piece
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _command_signature(name: str, command: click.Command) -> str:
|
|
50
|
+
pieces = [piece for piece in (_parameter_piece(parameter) for parameter in command.params) if piece]
|
|
51
|
+
return " ".join([name, *pieces])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_command_tree(command: click.Command, name: str, prefix: str, is_last: bool, lines: list[str]) -> None:
|
|
55
|
+
connector = "└── " if is_last else "├── "
|
|
56
|
+
line = f"{prefix}{connector}{_command_signature(name, command)}"
|
|
57
|
+
purpose = _purpose(command)
|
|
58
|
+
if purpose:
|
|
59
|
+
line = f"{line} # {purpose}"
|
|
60
|
+
lines.append(line)
|
|
61
|
+
if not isinstance(command, click.Group):
|
|
62
|
+
return
|
|
63
|
+
children = [(child_name, child) for child_name, child in command.commands.items() if not child.hidden]
|
|
64
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
65
|
+
for index, (child_name, child) in enumerate(children):
|
|
66
|
+
_render_command_tree(child, child_name, child_prefix, index == len(children) - 1, lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_cli_tree(root: click.Group) -> str:
|
|
70
|
+
children = [(name, command) for name, command in root.commands.items() if not command.hidden]
|
|
71
|
+
lines = [f"chatglance # {_purpose(root)}"]
|
|
72
|
+
root_options = [
|
|
73
|
+
("--help", "Show help for the current command."),
|
|
74
|
+
("--version", "Show package version."),
|
|
75
|
+
("--tree", "Print the registered CLI tree."),
|
|
76
|
+
]
|
|
77
|
+
for index, (option, purpose) in enumerate(root_options):
|
|
78
|
+
is_last = not children and index == len(root_options) - 1
|
|
79
|
+
lines.append(f"{'└──' if is_last else '├──'} {option} # {purpose}")
|
|
80
|
+
for index, (child_name, child) in enumerate(children):
|
|
81
|
+
_render_command_tree(child, child_name, "", index == len(children) - 1, lines)
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group(invoke_without_command=True, no_args_is_help=True)
|
|
86
|
+
@click.version_option(__version__, prog_name="chatglance")
|
|
87
|
+
@click.option("--tree", "show_tree", is_flag=True, is_eager=True, help="Print the registered CLI tree.")
|
|
88
|
+
@click.pass_context
|
|
89
|
+
def main(ctx: click.Context, show_tree: bool) -> None:
|
|
90
|
+
"""Generate and maintain ChatArch Glance dashboard config."""
|
|
91
|
+
|
|
92
|
+
if show_tree:
|
|
93
|
+
click.echo(_render_cli_tree(ctx.command))
|
|
94
|
+
ctx.exit()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.group()
|
|
98
|
+
def projects() -> None:
|
|
99
|
+
"""Generate Glance project dashboard pages."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@projects.command("render-page")
|
|
103
|
+
@click.option("--data", "data_path", type=click.Path(path_type=Path, dir_okay=False, exists=True), required=True, help="Inventory JSON generated from repository metadata.")
|
|
104
|
+
@click.option("--output", "output_path", type=click.Path(path_type=Path, dir_okay=False), required=True, help="YAML file to write the generated Glance page object to.")
|
|
105
|
+
@click.option("--page-name", default=PAGE_NAME, show_default=True, help="Generated Glance page name.")
|
|
106
|
+
def render_projects_page(data_path: Path, output_path: Path, page_name: str) -> None:
|
|
107
|
+
"""Render the `项目` page YAML from inventory JSON."""
|
|
108
|
+
|
|
109
|
+
data = load_inventory(data_path)
|
|
110
|
+
page = build_projects_page(data, page_name=page_name)
|
|
111
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
output_path.write_text(dump_yaml(page), encoding="utf-8")
|
|
113
|
+
click.echo(f"wrote {output_path}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@projects.command("update-config")
|
|
117
|
+
@click.option("--data", "data_path", type=click.Path(path_type=Path, dir_okay=False, exists=True), required=True, help="Inventory JSON generated from repository metadata.")
|
|
118
|
+
@click.option("--config", "config_path", type=click.Path(path_type=Path, dir_okay=False, exists=True), required=True, help="Existing Glance YAML config.")
|
|
119
|
+
@click.option("--output", "output_path", type=click.Path(path_type=Path, dir_okay=False), required=True, help="Output path for the updated Glance YAML config.")
|
|
120
|
+
@click.option("--page-name", default=PAGE_NAME, show_default=True, help="Generated Glance page name.")
|
|
121
|
+
def update_projects_config(data_path: Path, config_path: Path, output_path: Path, page_name: str) -> None:
|
|
122
|
+
"""Write a config copy with the generated project page replaced."""
|
|
123
|
+
|
|
124
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
update_projects_page_from_files(config_path, data_path, output_path, page_name=page_name)
|
|
126
|
+
click.echo(f"wrote {output_path}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@main.group()
|
|
130
|
+
def disks() -> None:
|
|
131
|
+
"""Patch Glance server-stats disk display."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@disks.command("root-only")
|
|
135
|
+
@click.option("--config", "config_path", type=click.Path(path_type=Path, dir_okay=False, exists=True), required=True, help="Existing Glance YAML config.")
|
|
136
|
+
@click.option("--output", "output_path", type=click.Path(path_type=Path, dir_okay=False), required=True, help="Output path for the patched Glance YAML config.")
|
|
137
|
+
@click.option("--mountpoint", default="/", show_default=True, help="Only mountpoint to display.")
|
|
138
|
+
@click.option("--name", "mount_name", default="根分区", show_default=True, help="Display name for the mountpoint.")
|
|
139
|
+
def root_only_disk(config_path: Path, output_path: Path, mountpoint: str, mount_name: str) -> None:
|
|
140
|
+
"""Write a config copy that hides default snap/loop mountpoints."""
|
|
141
|
+
|
|
142
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
patch_disks_from_files(config_path, output_path, mountpoint=mountpoint, name=mount_name)
|
|
144
|
+
click.echo(f"wrote {output_path}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@main.group()
|
|
148
|
+
def runtime() -> None:
|
|
149
|
+
"""Maintain a durable Glance service runtime."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@runtime.command("maintain")
|
|
153
|
+
@click.option("--runtime-home", type=click.Path(path_type=Path, file_okay=False), default=Path("~/.chatarch/glance"), show_default=True, help="Durable Glance service home.")
|
|
154
|
+
@click.option("--config", "config_path", type=click.Path(path_type=Path, dir_okay=False), default=Path("config/glance.yml"), show_default=True, help="Runtime-relative or absolute Glance YAML config.")
|
|
155
|
+
@click.option("--data", "data_path", type=click.Path(path_type=Path, dir_okay=False), default=Path("data/chatarch-projects.json"), show_default=True, help="Runtime-relative or absolute repository inventory JSON.")
|
|
156
|
+
@click.option("--backup-dir", type=click.Path(path_type=Path, file_okay=False), default=Path("config/backups"), show_default=True, help="Runtime-relative or absolute backup directory for in-place writes.")
|
|
157
|
+
@click.option("--page-name", default=PAGE_NAME, show_default=True, help="Generated Glance page name.")
|
|
158
|
+
@click.option("--validate/--no-validate", default=True, show_default=True, help="Validate the candidate config with the upstream Glance binary before replacing it.")
|
|
159
|
+
@click.option("--glance-bin", type=click.Path(path_type=Path, dir_okay=False), default=Path("bin/glance"), show_default=True, help="Runtime-relative or absolute Glance binary used for config validation.")
|
|
160
|
+
@click.option("--restart-service", default=None, help="systemd user service to restart only if the rendered config changed.")
|
|
161
|
+
def maintain_runtime(runtime_home: Path, config_path: Path, data_path: Path, backup_dir: Path, page_name: str, validate: bool, glance_bin: Path, restart_service: str | None) -> None:
|
|
162
|
+
"""Apply generated project content and root-only Disk config once."""
|
|
163
|
+
|
|
164
|
+
runtime_home = runtime_home.expanduser()
|
|
165
|
+
config = runtime_path(runtime_home, config_path)
|
|
166
|
+
data = runtime_path(runtime_home, data_path)
|
|
167
|
+
backups = runtime_path(runtime_home, backup_dir) if backup_dir else None
|
|
168
|
+
binary = runtime_path(runtime_home, glance_bin) if validate else None
|
|
169
|
+
result = maintain_config(
|
|
170
|
+
config_path=config,
|
|
171
|
+
data_path=data,
|
|
172
|
+
output_path=config,
|
|
173
|
+
backup_dir=backups,
|
|
174
|
+
validate_bin=binary,
|
|
175
|
+
page_name=page_name,
|
|
176
|
+
restart_service=restart_service,
|
|
177
|
+
)
|
|
178
|
+
click.echo(
|
|
179
|
+
" ".join(
|
|
180
|
+
[
|
|
181
|
+
f"output={result.output_path}",
|
|
182
|
+
f"changed={str(result.changed).lower()}",
|
|
183
|
+
f"validated={str(result.validated).lower()}",
|
|
184
|
+
f"restarted={str(result.restarted).lower()}",
|
|
185
|
+
f"backup={result.backup_path or '-'}",
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@runtime.command("render-systemd")
|
|
192
|
+
@click.option("--runtime-home", type=click.Path(path_type=Path, file_okay=False), default=Path("~/.chatarch/glance"), show_default=True, help="Durable Glance service home.")
|
|
193
|
+
@click.option("--chatglance-bin", type=click.Path(path_type=Path, dir_okay=False), required=True, help="Absolute path to the chatglance executable used by the maintenance oneshot.")
|
|
194
|
+
@click.option("--service-name", default="chatarch-glance.service", show_default=True, help="Main Glance user service name.")
|
|
195
|
+
@click.option("--maintenance-service-name", default="chatarch-glance-maintenance.service", show_default=True, help="Generated oneshot maintenance service name.")
|
|
196
|
+
@click.option("--timer-name", default="chatarch-glance-maintenance.timer", show_default=True, help="Generated maintenance timer name.")
|
|
197
|
+
@click.option("--interval", default="30min", show_default=True, help="systemd OnUnitActiveSec interval for the maintenance timer.")
|
|
198
|
+
@click.option("--output-dir", type=click.Path(path_type=Path, file_okay=False), help="Directory to write unit files. Omit to print them.")
|
|
199
|
+
def render_systemd(runtime_home: Path, chatglance_bin: Path, service_name: str, maintenance_service_name: str, timer_name: str, interval: str, output_dir: Path | None) -> None:
|
|
200
|
+
"""Render direct Glance service plus chatglance maintenance units."""
|
|
201
|
+
|
|
202
|
+
units = render_all_units(
|
|
203
|
+
runtime_home=runtime_home.expanduser(),
|
|
204
|
+
chatglance_bin=chatglance_bin.expanduser(),
|
|
205
|
+
service_name=service_name,
|
|
206
|
+
maintenance_service_name=maintenance_service_name,
|
|
207
|
+
timer_name=timer_name,
|
|
208
|
+
interval=interval,
|
|
209
|
+
)
|
|
210
|
+
if output_dir:
|
|
211
|
+
paths = write_units(output_dir.expanduser(), units)
|
|
212
|
+
for path in paths:
|
|
213
|
+
click.echo(f"wrote {path}")
|
|
214
|
+
return
|
|
215
|
+
click.echo(f"# {units.service_name}\n{units.service}")
|
|
216
|
+
click.echo(f"# {units.maintenance_service_name}\n{units.maintenance_service}")
|
|
217
|
+
click.echo(f"# {units.timer_name}\n{units.timer}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Safe Glance YAML config transformations for chatglance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
from .projects import LEGACY_PAGE_NAMES, PAGE_NAME, build_projects_page, dump_yaml, load_inventory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_yaml(path: str | Path) -> dict[str, Any]:
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
raise ValueError("Glance config must be a YAML mapping")
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_yaml(path: str | Path, data: dict[str, Any]) -> None:
|
|
22
|
+
Path(path).write_text(dump_yaml(data), encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def replace_projects_page(config: dict[str, Any], inventory: dict[str, Any], *, page_name: str = PAGE_NAME) -> dict[str, Any]:
|
|
26
|
+
"""Return a config copy where the generated projects page is replaced.
|
|
27
|
+
|
|
28
|
+
This function does not mutate the input mapping. It removes old generated page
|
|
29
|
+
names and then replaces/appends the current `page_name` page.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
updated = deepcopy(config)
|
|
33
|
+
pages = updated.setdefault("pages", [])
|
|
34
|
+
if not isinstance(pages, list):
|
|
35
|
+
raise ValueError("Glance config `pages` must be a list")
|
|
36
|
+
legacy_names = set(LEGACY_PAGE_NAMES) | {page_name}
|
|
37
|
+
pages[:] = [page for page in pages if not (isinstance(page, dict) and page.get("name") in legacy_names)]
|
|
38
|
+
pages.append(build_projects_page(inventory, page_name=page_name))
|
|
39
|
+
return updated
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iter_widgets(value: Any) -> Iterator[dict[str, Any]]:
|
|
43
|
+
"""Yield Glance widget mappings recursively from pages/columns/groups."""
|
|
44
|
+
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
if "type" in value:
|
|
47
|
+
yield value
|
|
48
|
+
for key in ("widgets", "columns"):
|
|
49
|
+
nested = value.get(key)
|
|
50
|
+
if isinstance(nested, list):
|
|
51
|
+
for item in nested:
|
|
52
|
+
yield from iter_widgets(item)
|
|
53
|
+
elif isinstance(value, list):
|
|
54
|
+
for item in value:
|
|
55
|
+
yield from iter_widgets(item)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def patch_server_stats_root_only(
|
|
59
|
+
config: dict[str, Any],
|
|
60
|
+
*,
|
|
61
|
+
mountpoint: str = "/",
|
|
62
|
+
name: str = "根分区",
|
|
63
|
+
hide_swap: bool = True,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Return a config copy where all server-stats widgets show only one mountpoint.
|
|
66
|
+
|
|
67
|
+
Glance's `mountpoints` entries name configured mountpoints, but without
|
|
68
|
+
`hide-mountpoints-by-default: true` Glance can still render all default OS
|
|
69
|
+
mountpoints, including snap/loop mounts. This helper sets both fields.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
updated = deepcopy(config)
|
|
73
|
+
for widget in iter_widgets(updated.get("pages", [])):
|
|
74
|
+
if widget.get("type") != "server-stats":
|
|
75
|
+
continue
|
|
76
|
+
widget["hide-mountpoints-by-default"] = True
|
|
77
|
+
servers = widget.get("servers")
|
|
78
|
+
if not isinstance(servers, list):
|
|
79
|
+
# A bare local server-stats widget can exist; keep it safe by adding
|
|
80
|
+
# a local server entry rather than leaving defaults visible.
|
|
81
|
+
servers = [{"type": "local", "name": "local"}]
|
|
82
|
+
widget["servers"] = servers
|
|
83
|
+
for server in servers:
|
|
84
|
+
if not isinstance(server, dict):
|
|
85
|
+
continue
|
|
86
|
+
server["hide-swap"] = bool(hide_swap)
|
|
87
|
+
server["hide-mountpoints-by-default"] = True
|
|
88
|
+
server["mountpoints"] = {mountpoint: {"name": name}}
|
|
89
|
+
return updated
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def update_projects_page_from_files(config_path: str | Path, data_path: str | Path, output_path: str | Path, *, page_name: str = PAGE_NAME) -> Path:
|
|
93
|
+
config = load_yaml(config_path)
|
|
94
|
+
inventory = load_inventory(data_path)
|
|
95
|
+
updated = replace_projects_page(config, inventory, page_name=page_name)
|
|
96
|
+
write_yaml(output_path, updated)
|
|
97
|
+
return Path(output_path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def patch_disks_from_files(config_path: str | Path, output_path: str | Path, *, mountpoint: str = "/", name: str = "根分区") -> Path:
|
|
101
|
+
config = load_yaml(config_path)
|
|
102
|
+
updated = patch_server_stats_root_only(config, mountpoint=mountpoint, name=name)
|
|
103
|
+
write_yaml(output_path, updated)
|
|
104
|
+
return Path(output_path)
|
chatglance/projects.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Build Glance project-dashboard pages from ChatArch repository inventory JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
PAGE_NAME = "项目"
|
|
11
|
+
LEGACY_PAGE_NAMES = {"Projects", "ChatArch Projects", "ChatArch Projects List"}
|
|
12
|
+
|
|
13
|
+
CATEGORY_LABELS = {
|
|
14
|
+
"python-package": "Python 包",
|
|
15
|
+
"node-package": "Node / npm 包",
|
|
16
|
+
"service/app": "服务 / 应用",
|
|
17
|
+
"docs/site": "文档 / 站点",
|
|
18
|
+
"template/early": "模板 / 早期包",
|
|
19
|
+
"other": "其他项目",
|
|
20
|
+
}
|
|
21
|
+
CATEGORY_ORDER = ["python-package", "node-package", "service/app", "docs/site", "template/early", "other"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_inventory(path: str | Path) -> dict[str, Any]:
|
|
25
|
+
"""Load a ChatArch repository inventory JSON file."""
|
|
26
|
+
|
|
27
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
raise ValueError("inventory JSON must be an object")
|
|
30
|
+
repos = data.get("repositories")
|
|
31
|
+
if not isinstance(repos, list):
|
|
32
|
+
raise ValueError("inventory JSON must contain a repositories list")
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def safe_date(value: Any) -> str:
|
|
37
|
+
text = str(value or "").strip()
|
|
38
|
+
if "T" in text:
|
|
39
|
+
return text.split("T", 1)[0]
|
|
40
|
+
return text or "—"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def text_value(value: Any, fallback: str = "—") -> str:
|
|
44
|
+
text = str(value or "").strip()
|
|
45
|
+
return text or fallback
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def html_text(value: Any, fallback: str = "—") -> str:
|
|
49
|
+
return html.escape(text_value(value, fallback), quote=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def version_display(version: dict[str, Any] | None) -> str:
|
|
53
|
+
if not isinstance(version, dict):
|
|
54
|
+
return "—"
|
|
55
|
+
value = text_value(version.get("value"), "")
|
|
56
|
+
if not value:
|
|
57
|
+
return "—"
|
|
58
|
+
source = text_value(version.get("source"), "")
|
|
59
|
+
return f"{value} · {source}" if source else value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def repositories(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
63
|
+
return [item for item in data.get("repositories", []) if isinstance(item, dict)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sorted_repos(data: dict[str, Any], sort_key: str, limit: int | None = None) -> list[dict[str, Any]]:
|
|
67
|
+
repos = repositories(data)
|
|
68
|
+
if sort_key == "recent":
|
|
69
|
+
rows = sorted(repos, key=lambda item: text_value(item.get("pushed_at") or item.get("updated_at"), ""), reverse=True)
|
|
70
|
+
elif sort_key == "triage":
|
|
71
|
+
rows = [item for item in repos if int(item.get("open_prs") or 0) > 0 or int(item.get("open_issues") or 0) > 0]
|
|
72
|
+
rows = sorted(
|
|
73
|
+
rows,
|
|
74
|
+
key=lambda item: (
|
|
75
|
+
int(item.get("open_prs") or 0),
|
|
76
|
+
int(item.get("open_issues") or 0),
|
|
77
|
+
text_value(item.get("pushed_at") or item.get("updated_at"), ""),
|
|
78
|
+
),
|
|
79
|
+
reverse=True,
|
|
80
|
+
)
|
|
81
|
+
elif sort_key == "category":
|
|
82
|
+
order = {name: index for index, name in enumerate(CATEGORY_ORDER)}
|
|
83
|
+
rows = sorted(repos, key=lambda item: (order.get(text_value(item.get("category"), "other"), 999), text_value(item.get("name"), "")))
|
|
84
|
+
else:
|
|
85
|
+
rows = sorted(repos, key=lambda item: text_value(item.get("name"), "").lower())
|
|
86
|
+
return rows[:limit] if limit else rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bookmark_link(item: dict[str, Any], *, url_kind: str = "repo") -> dict[str, str]:
|
|
90
|
+
url = text_value(item.get("html_url"), "https://github.com/ChatArch")
|
|
91
|
+
if url_kind == "docs":
|
|
92
|
+
docs = item.get("docs") if isinstance(item.get("docs"), list) else []
|
|
93
|
+
if docs and isinstance(docs[0], dict):
|
|
94
|
+
url = text_value(docs[0].get("url"), url)
|
|
95
|
+
desc_parts = [
|
|
96
|
+
f"PR {int(item.get('open_prs') or 0)}",
|
|
97
|
+
f"Issue {int(item.get('open_issues') or 0)}",
|
|
98
|
+
f"提交 {safe_date(item.get('pushed_at') or item.get('updated_at'))}",
|
|
99
|
+
]
|
|
100
|
+
version = version_display(item.get("version") if isinstance(item.get("version"), dict) else None)
|
|
101
|
+
if version != "—":
|
|
102
|
+
desc_parts.append(version)
|
|
103
|
+
return {
|
|
104
|
+
"title": text_value(item.get("name"), "unknown"),
|
|
105
|
+
"url": url,
|
|
106
|
+
"description": " · ".join(desc_parts),
|
|
107
|
+
"icon": "si:github",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def make_overview_groups(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
112
|
+
counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
|
|
113
|
+
triage_count = int(counts.get("with_open_prs") or 0) + int(counts.get("with_open_issues") or 0)
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
"title": "概览",
|
|
117
|
+
"links": [
|
|
118
|
+
{"title": "可见仓库", "url": "https://github.com/ChatArch", "description": str(counts.get("visible_repos", len(repositories(data)))), "icon": "si:github"},
|
|
119
|
+
{"title": "待处理 PR / Issue", "url": "https://github.com/ChatArch", "description": str(triage_count), "icon": "mdi:source-pull"},
|
|
120
|
+
{"title": "生成数据", "url": "https://github.com/ChatArch", "description": "本地生成快照 · 不含凭据", "icon": "mdi:code-json"},
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def make_sort_widget(data: dict[str, Any], title: str, sort_key: str) -> dict[str, Any]:
|
|
127
|
+
rows = sorted_repos(data, sort_key)
|
|
128
|
+
return {
|
|
129
|
+
"type": "bookmarks",
|
|
130
|
+
"title": title,
|
|
131
|
+
"groups": [{"title": f"{title} ({len(rows)})", "links": [bookmark_link(item) for item in rows]}],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def make_category_groups(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
136
|
+
groups: list[dict[str, Any]] = []
|
|
137
|
+
for category in CATEGORY_ORDER:
|
|
138
|
+
rows = [item for item in sorted_repos(data, "category") if text_value(item.get("category"), "other") == category]
|
|
139
|
+
if rows:
|
|
140
|
+
groups.append({"title": CATEGORY_LABELS.get(category, category), "links": [bookmark_link(item) for item in rows]})
|
|
141
|
+
remainder = [item for item in sorted_repos(data, "category") if text_value(item.get("category"), "other") not in CATEGORY_ORDER]
|
|
142
|
+
if remainder:
|
|
143
|
+
groups.append({"title": "其他项目", "links": [bookmark_link(item) for item in remainder]})
|
|
144
|
+
return groups
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def make_categories_widget(data: dict[str, Any]) -> dict[str, Any]:
|
|
148
|
+
return {"type": "bookmarks", "title": "分类", "collapse-after": 12, "groups": make_category_groups(data)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def make_table_widget(data: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
rows: list[str] = []
|
|
153
|
+
for item in sorted_repos(data, "name"):
|
|
154
|
+
commands = ((item.get("cli") or {}).get("commands") if isinstance(item.get("cli"), dict) else []) or []
|
|
155
|
+
cli = ", ".join(str(command) for command in commands[:3])
|
|
156
|
+
if len(commands) > 3:
|
|
157
|
+
cli += f" +{len(commands) - 3}"
|
|
158
|
+
docs = item.get("docs") if isinstance(item.get("docs"), list) else []
|
|
159
|
+
docs_url = docs[0].get("url") if docs and isinstance(docs[0], dict) else ""
|
|
160
|
+
docs_cell = f'<a href="{html_text(docs_url)}" target="_blank" rel="noreferrer">文档</a>' if docs_url else "—"
|
|
161
|
+
rows.append(
|
|
162
|
+
"<tr>"
|
|
163
|
+
f'<td><a href="{html_text(item.get("html_url"))}" target="_blank" rel="noreferrer">{html_text(item.get("name"))}</a></td>'
|
|
164
|
+
f'<td class="num">{int(item.get("open_prs") or 0)}</td>'
|
|
165
|
+
f'<td class="num">{int(item.get("open_issues") or 0)}</td>'
|
|
166
|
+
f'<td>{html_text(version_display(item.get("version") if isinstance(item.get("version"), dict) else None))}</td>'
|
|
167
|
+
f'<td>{html_text(CATEGORY_LABELS.get(text_value(item.get("category"), "other"), text_value(item.get("category"), "other")))}</td>'
|
|
168
|
+
f'<td>{html_text(cli)}</td>'
|
|
169
|
+
f'<td>{docs_cell}</td>'
|
|
170
|
+
f'<td>{html_text(safe_date(item.get("pushed_at") or item.get("updated_at")))}</td>'
|
|
171
|
+
"</tr>"
|
|
172
|
+
)
|
|
173
|
+
source = """
|
|
174
|
+
<style>
|
|
175
|
+
.projects-table-wrap { overflow-x: auto; }
|
|
176
|
+
.projects-table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
|
177
|
+
.projects-table th, .projects-table td { padding: 0.38rem 0.5rem; border-bottom: 1px solid var(--color-separator); vertical-align: top; }
|
|
178
|
+
.projects-table th { text-align: left; position: sticky; top: 0; background: var(--color-widget-background); z-index: 1; }
|
|
179
|
+
.projects-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
180
|
+
.projects-table a { color: inherit; text-decoration: none; }
|
|
181
|
+
.projects-table a:hover { text-decoration: underline; }
|
|
182
|
+
</style>
|
|
183
|
+
<div class="projects-table-wrap">
|
|
184
|
+
<table class="projects-table">
|
|
185
|
+
<thead><tr><th>仓库</th><th>PR</th><th>Issue</th><th>版本</th><th>类型</th><th>CLI</th><th>文档</th><th>最近提交</th></tr></thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
""" + "\n".join(rows) + """
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
"""
|
|
192
|
+
return {"type": "html", "title": "一览表", "source": source}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_projects_page(data: dict[str, Any], *, page_name: str = PAGE_NAME) -> dict[str, Any]:
|
|
196
|
+
"""Build the Glance page object for the ChatArch projects dashboard."""
|
|
197
|
+
|
|
198
|
+
page = {
|
|
199
|
+
"name": page_name,
|
|
200
|
+
"columns": [
|
|
201
|
+
{"size": "small", "widgets": [{"type": "bookmarks", "title": "概览", "groups": make_overview_groups(data)}]},
|
|
202
|
+
{
|
|
203
|
+
"size": "full",
|
|
204
|
+
"widgets": [
|
|
205
|
+
{
|
|
206
|
+
"type": "group",
|
|
207
|
+
"widgets": [
|
|
208
|
+
make_sort_widget(data, "最近提交", "recent"),
|
|
209
|
+
make_sort_widget(data, "待处理 PR / Issue", "triage"),
|
|
210
|
+
make_categories_widget(data),
|
|
211
|
+
make_table_widget(data),
|
|
212
|
+
],
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
return page
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def dump_yaml(value: Any) -> str:
|
|
222
|
+
import yaml
|
|
223
|
+
|
|
224
|
+
return yaml.safe_dump(value, sort_keys=False, allow_unicode=True, width=120)
|
chatglance/runtime.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Runtime maintenance helpers for a durable Glance service home."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .glance_config import load_yaml, patch_server_stats_root_only, replace_projects_page, write_yaml
|
|
13
|
+
from .projects import PAGE_NAME, load_inventory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MaintainResult:
|
|
18
|
+
"""Result of a runtime maintenance pass."""
|
|
19
|
+
|
|
20
|
+
output_path: Path
|
|
21
|
+
changed: bool
|
|
22
|
+
backup_path: Path | None
|
|
23
|
+
validated: bool
|
|
24
|
+
restarted: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_maintained_config(config: dict[str, Any], inventory: dict[str, Any], *, page_name: str = PAGE_NAME) -> dict[str, Any]:
|
|
28
|
+
"""Apply all chatglance-managed config transformations.
|
|
29
|
+
|
|
30
|
+
The runtime boundary is deliberately narrow: this updates generated dashboard
|
|
31
|
+
content and Glance `server-stats` disk presentation. It does not embed live
|
|
32
|
+
credentials, run the Glance server, or fetch repository metadata.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
updated = replace_projects_page(config, inventory, page_name=page_name)
|
|
36
|
+
updated = patch_server_stats_root_only(updated)
|
|
37
|
+
return updated
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_text_if_exists(path: Path) -> str | None:
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return None
|
|
43
|
+
return path.read_text(encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _timestamp() -> str:
|
|
47
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_glance_config(glance_bin: str | Path, config_path: str | Path) -> None:
|
|
51
|
+
"""Validate a config file with the upstream Glance binary."""
|
|
52
|
+
|
|
53
|
+
subprocess.run(
|
|
54
|
+
[str(glance_bin), "-config", str(config_path), "config:validate"],
|
|
55
|
+
check=True,
|
|
56
|
+
stdout=subprocess.PIPE,
|
|
57
|
+
stderr=subprocess.PIPE,
|
|
58
|
+
text=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def maintain_config(
|
|
63
|
+
*,
|
|
64
|
+
config_path: str | Path,
|
|
65
|
+
data_path: str | Path,
|
|
66
|
+
output_path: str | Path | None = None,
|
|
67
|
+
backup_dir: str | Path | None = None,
|
|
68
|
+
validate_bin: str | Path | None = None,
|
|
69
|
+
page_name: str = PAGE_NAME,
|
|
70
|
+
restart_service: str | None = None,
|
|
71
|
+
) -> MaintainResult:
|
|
72
|
+
"""Maintain a Glance config using a repository-inventory snapshot.
|
|
73
|
+
|
|
74
|
+
If `output_path` is the same as `config_path`, the write is in-place: a
|
|
75
|
+
backup is created when `backup_dir` is provided, the candidate is validated
|
|
76
|
+
before replacing the live file, and an optional systemd user service restart
|
|
77
|
+
runs only when the rendered config actually changed.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
source = Path(config_path)
|
|
81
|
+
output = Path(output_path) if output_path is not None else source
|
|
82
|
+
config = load_yaml(source)
|
|
83
|
+
inventory = load_inventory(data_path)
|
|
84
|
+
updated = build_maintained_config(config, inventory, page_name=page_name)
|
|
85
|
+
|
|
86
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
tmp = output.with_name(f".{output.name}.chatglance.tmp")
|
|
88
|
+
write_yaml(tmp, updated)
|
|
89
|
+
|
|
90
|
+
if validate_bin is not None:
|
|
91
|
+
validate_glance_config(validate_bin, tmp)
|
|
92
|
+
validated = validate_bin is not None
|
|
93
|
+
|
|
94
|
+
old_text = _read_text_if_exists(output)
|
|
95
|
+
new_text = tmp.read_text(encoding="utf-8")
|
|
96
|
+
changed = old_text != new_text
|
|
97
|
+
backup_path: Path | None = None
|
|
98
|
+
restarted = False
|
|
99
|
+
|
|
100
|
+
if changed:
|
|
101
|
+
if output.exists() and backup_dir is not None:
|
|
102
|
+
backup_root = Path(backup_dir)
|
|
103
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
backup_path = backup_root / f"{output.name}.{_timestamp()}.bak"
|
|
105
|
+
shutil.copy2(output, backup_path)
|
|
106
|
+
tmp.replace(output)
|
|
107
|
+
if restart_service:
|
|
108
|
+
subprocess.run(["systemctl", "--user", "restart", restart_service], check=True)
|
|
109
|
+
restarted = True
|
|
110
|
+
else:
|
|
111
|
+
tmp.unlink(missing_ok=True)
|
|
112
|
+
|
|
113
|
+
return MaintainResult(output_path=output, changed=changed, backup_path=backup_path, validated=validated, restarted=restarted)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def runtime_path(runtime_home: str | Path, relative_or_absolute: str | Path) -> Path:
|
|
117
|
+
"""Resolve a runtime-relative path without forcing callers into cwd tricks."""
|
|
118
|
+
|
|
119
|
+
value = Path(relative_or_absolute).expanduser()
|
|
120
|
+
if value.is_absolute():
|
|
121
|
+
return value
|
|
122
|
+
return Path(runtime_home).expanduser() / value
|
chatglance/systemd.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""systemd unit rendering for chatglance-managed Glance runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SystemdUnits:
|
|
11
|
+
"""Rendered systemd user units for a Glance runtime."""
|
|
12
|
+
|
|
13
|
+
service_name: str
|
|
14
|
+
service: str
|
|
15
|
+
maintenance_service_name: str
|
|
16
|
+
maintenance_service: str
|
|
17
|
+
timer_name: str
|
|
18
|
+
timer: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _path(value: str | Path) -> str:
|
|
22
|
+
return str(Path(value).expanduser())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_glance_service(
|
|
26
|
+
*,
|
|
27
|
+
runtime_home: str | Path,
|
|
28
|
+
glance_bin: str | Path = "bin/glance",
|
|
29
|
+
config_path: str | Path = "config/glance.yml",
|
|
30
|
+
description: str = "ChatArch Glance dashboard",
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Render the long-running Glance service unit.
|
|
33
|
+
|
|
34
|
+
This intentionally starts the upstream Glance binary directly. `chatglance`
|
|
35
|
+
is not a Python wrapper around the server process.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
home = Path(runtime_home).expanduser()
|
|
39
|
+
binary = Path(glance_bin)
|
|
40
|
+
if not binary.is_absolute():
|
|
41
|
+
binary = home / binary
|
|
42
|
+
config = Path(config_path)
|
|
43
|
+
if not config.is_absolute():
|
|
44
|
+
config = home / config
|
|
45
|
+
return f"""[Unit]
|
|
46
|
+
Description={description}
|
|
47
|
+
After=network-online.target
|
|
48
|
+
|
|
49
|
+
[Service]
|
|
50
|
+
Type=simple
|
|
51
|
+
WorkingDirectory={_path(home)}
|
|
52
|
+
ExecStart={_path(binary)} -config {_path(config)}
|
|
53
|
+
Restart=always
|
|
54
|
+
RestartSec=5
|
|
55
|
+
StandardOutput=append:{_path(home / 'logs/glance.stdout.log')}
|
|
56
|
+
StandardError=append:{_path(home / 'logs/glance.stderr.log')}
|
|
57
|
+
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=default.target
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_maintenance_service(
|
|
64
|
+
*,
|
|
65
|
+
runtime_home: str | Path,
|
|
66
|
+
chatglance_bin: str | Path,
|
|
67
|
+
restart_service: str = "chatarch-glance.service",
|
|
68
|
+
description: str = "Maintain ChatArch Glance generated config",
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Render a oneshot maintenance unit that applies generated content.
|
|
71
|
+
|
|
72
|
+
The unit calls `chatglance runtime maintain`, validates with the Glance
|
|
73
|
+
binary, and restarts the Glance user service only when the rendered config
|
|
74
|
+
changed.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
home = Path(runtime_home).expanduser()
|
|
78
|
+
return f"""[Unit]
|
|
79
|
+
Description={description}
|
|
80
|
+
Wants=network-online.target
|
|
81
|
+
After=network-online.target
|
|
82
|
+
|
|
83
|
+
[Service]
|
|
84
|
+
Type=oneshot
|
|
85
|
+
WorkingDirectory={_path(home)}
|
|
86
|
+
ExecStart={_path(chatglance_bin)} runtime maintain --runtime-home {_path(home)} --restart-service {restart_service}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_timer(
|
|
91
|
+
*,
|
|
92
|
+
timer_unit: str = "chatarch-glance-maintenance.service",
|
|
93
|
+
interval: str = "30min",
|
|
94
|
+
description: str = "Run ChatArch Glance maintenance periodically",
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Render a periodic systemd user timer for the maintenance oneshot."""
|
|
97
|
+
|
|
98
|
+
return f"""[Unit]
|
|
99
|
+
Description={description}
|
|
100
|
+
|
|
101
|
+
[Timer]
|
|
102
|
+
OnBootSec=5min
|
|
103
|
+
OnUnitActiveSec={interval}
|
|
104
|
+
Persistent=true
|
|
105
|
+
Unit={timer_unit}
|
|
106
|
+
|
|
107
|
+
[Install]
|
|
108
|
+
WantedBy=timers.target
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_all_units(
|
|
113
|
+
*,
|
|
114
|
+
runtime_home: str | Path,
|
|
115
|
+
chatglance_bin: str | Path,
|
|
116
|
+
service_name: str = "chatarch-glance.service",
|
|
117
|
+
maintenance_service_name: str = "chatarch-glance-maintenance.service",
|
|
118
|
+
timer_name: str = "chatarch-glance-maintenance.timer",
|
|
119
|
+
interval: str = "30min",
|
|
120
|
+
) -> SystemdUnits:
|
|
121
|
+
"""Render the recommended user-service topology for a Glance runtime."""
|
|
122
|
+
|
|
123
|
+
return SystemdUnits(
|
|
124
|
+
service_name=service_name,
|
|
125
|
+
service=render_glance_service(runtime_home=runtime_home),
|
|
126
|
+
maintenance_service_name=maintenance_service_name,
|
|
127
|
+
maintenance_service=render_maintenance_service(
|
|
128
|
+
runtime_home=runtime_home,
|
|
129
|
+
chatglance_bin=chatglance_bin,
|
|
130
|
+
restart_service=service_name,
|
|
131
|
+
),
|
|
132
|
+
timer_name=timer_name,
|
|
133
|
+
timer=render_timer(timer_unit=maintenance_service_name, interval=interval),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_units(output_dir: str | Path, units: SystemdUnits) -> list[Path]:
|
|
138
|
+
"""Write rendered units to an output directory."""
|
|
139
|
+
|
|
140
|
+
root = Path(output_dir)
|
|
141
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
paths = [
|
|
143
|
+
root / units.service_name,
|
|
144
|
+
root / units.maintenance_service_name,
|
|
145
|
+
root / units.timer_name,
|
|
146
|
+
]
|
|
147
|
+
contents = [units.service, units.maintenance_service, units.timer]
|
|
148
|
+
for path, content in zip(paths, contents, strict=True):
|
|
149
|
+
path.write_text(content, encoding="utf-8")
|
|
150
|
+
return paths
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chatglance
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: ChatArch Glance dashboard generation CLI.
|
|
5
|
+
Author-email: ChatArch <1073853456@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ChatArch/chatglance
|
|
8
|
+
Project-URL: Repository, https://github.com/ChatArch/chatglance
|
|
9
|
+
Keywords: chatglance,chatarch,cli
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: click>=8.0
|
|
16
|
+
Requires-Dist: PyYAML<7.0,>=6.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: twine; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
<a href="https://pypi.python.org/pypi/chatglance">
|
|
25
|
+
<img src="https://img.shields.io/pypi/v/chatglance.svg" alt="PyPI version" />
|
|
26
|
+
</a>
|
|
27
|
+
<a href="https://github.com/ChatArch/chatglance/actions/workflows/ci.yml">
|
|
28
|
+
<img src="https://github.com/ChatArch/chatglance/actions/workflows/ci.yml/badge.svg" alt="Tests" />
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div align="center">
|
|
33
|
+
|
|
34
|
+
[English](README.en.md) | [简体中文](README.md)
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
# chatglance
|
|
38
|
+
|
|
39
|
+
`chatglance` 是 ChatArch/WZHECNU Glance dashboard 的 Python 生成与运维 CLI。
|
|
40
|
+
|
|
41
|
+
它不是 NPM 项目,也不是重新实现 Glance 后端:上游 Glance 仍然是 Go 单二进制 dashboard server;`chatglance` 负责把 ChatArch 项目清单、Glance YAML 页面、inline HTML 表格和安全运维补丁组织成可复用的 Python 包/CLI。
|
|
42
|
+
|
|
43
|
+
## 当前能力
|
|
44
|
+
|
|
45
|
+
- 从 repository inventory JSON 生成 Glance `项目` page。
|
|
46
|
+
- 当前 page tabs 固定为:`最近提交`、`待处理 PR / Issue`、`分类`、`一览表`。
|
|
47
|
+
- `待处理 PR / Issue` 只显示 PR/Issue 非 0 的仓库,并按 `(PR, Issue, 最近提交)` 降序。
|
|
48
|
+
- 生成 config 副本时清理 legacy generated pages:`Projects`、`ChatArch Projects`、`ChatArch Projects List`。
|
|
49
|
+
- 为 Glance `server-stats` 写入 root-only Disk 配置:`hide-mountpoints-by-default: true` + `mountpoints: /`,避免 snap/loop mount 出现在 Disk popover。
|
|
50
|
+
- 维护 durable runtime:一次性 `runtime maintain` 可原子更新 live config、备份、校验,并在内容变化时可选重启 systemd user service。
|
|
51
|
+
- 渲染 systemd user units:主服务仍直接启动 upstream Glance Go binary;维护任务是独立 oneshot/timer,不是 Python wrapper。
|
|
52
|
+
|
|
53
|
+
## 快速开始
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
chatglance --help
|
|
58
|
+
chatglance --tree
|
|
59
|
+
chatglance --version
|
|
60
|
+
python -m pytest -q
|
|
61
|
+
python -m build
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI 示例
|
|
65
|
+
|
|
66
|
+
只生成 `项目` page YAML:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
chatglance projects render-page \
|
|
70
|
+
--data /path/to/chatarch-projects.json \
|
|
71
|
+
--output playground/projects-page.yml
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
把生成页写入一个 Glance config 副本:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
chatglance projects update-config \
|
|
78
|
+
--data /path/to/chatarch-projects.json \
|
|
79
|
+
--config /path/to/glance.yml \
|
|
80
|
+
--output playground/glance.with-projects.yml
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
把 `server-stats` Disk 改成只显示 root 分区,写入 config 副本:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
chatglance disks root-only \
|
|
87
|
+
--config /path/to/glance.yml \
|
|
88
|
+
--output playground/glance.root-disk.yml
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
维护一个 durable Glance runtime(默认 `~/.chatarch/glance`):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
chatglance runtime maintain \
|
|
95
|
+
--runtime-home ~/.chatarch/glance \
|
|
96
|
+
--restart-service chatarch-glance.service
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
生成推荐 systemd user units:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
chatglance runtime render-systemd \
|
|
103
|
+
--runtime-home ~/.chatarch/glance \
|
|
104
|
+
--chatglance-bin ~/.chatarch/venv/bin/chatglance \
|
|
105
|
+
--output-dir playground/systemd
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 运行态边界
|
|
109
|
+
|
|
110
|
+
推荐拓扑是 **systemd 直接运行 Glance,chatglance 只做维护**:
|
|
111
|
+
|
|
112
|
+
- 主服务:`chatarch-glance.service` 直接执行 `~/.chatarch/glance/bin/glance -config ~/.chatarch/glance/config/glance.yml`。
|
|
113
|
+
- 内容数据:repository inventory JSON、缓存和生成快照放在 `~/.chatarch/glance/data/` 或 `~/.chatarch/glance/cache/`。
|
|
114
|
+
- live config:`~/.chatarch/glance/config/glance.yml`;更新前写备份到 `~/.chatarch/glance/config/backups/`。
|
|
115
|
+
- 维护:`chatglance runtime maintain` 是 oneshot,可由 `chatarch-glance-maintenance.timer` 周期触发。
|
|
116
|
+
- 不建议加 Python 长驻 wrapper:wrapper 会把 server 生命周期和内容生成耦合,反而不利于 systemd 重启、日志、健康检查和回滚。
|
|
117
|
+
|
|
118
|
+
## 安全边界
|
|
119
|
+
|
|
120
|
+
- CLI 默认写 output file,不默认覆盖 live `glance.yml`。
|
|
121
|
+
- 不保存或输出 Glance auth、password hash、GitHub token、proxy credential。
|
|
122
|
+
- live runtime、logs、backups、全量实时 JSON 快照默认不进源码仓库。
|
|
123
|
+
- 后续如需动态表格、搜索、中英文切换,再考虑增加小型静态前端层;当前 Python CLI 是基础层。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
chatglance/__init__.py,sha256=ND9tvpnp2d32-4YjM30dyTYeFnxRE3rt7lESCJcF6O4,76
|
|
2
|
+
chatglance/cli.py,sha256=68aohCN4yrbFsTnO4NaDUChM9MXUkyGwacXNGFNbQiY,11610
|
|
3
|
+
chatglance/glance_config.py,sha256=VxE93rw9oRT8wm752SuXnP93B0OBWxLdPWkwpGgq3RI,4024
|
|
4
|
+
chatglance/projects.py,sha256=9iVHTOpe90ylDOQhehQvrlFShDO5-yPODzsrWXUJBOY,9496
|
|
5
|
+
chatglance/runtime.py,sha256=dghz7YoIdRCshBL9emvMWlJgQALQXsfxaESQwVoFEWQ,4154
|
|
6
|
+
chatglance/systemd.py,sha256=L46G9VrjOxtalSpK9RNfBl6kmGKTLif7Mx2oqVC7jrA,4207
|
|
7
|
+
chatglance-0.0.1.dist-info/licenses/LICENSE,sha256=G-fJLQ4pkXXISE3qeN8J5Cw5VRoNongA0hbsHA9TRe8,1065
|
|
8
|
+
chatglance-0.0.1.dist-info/METADATA,sha256=fe0jp5dHuH1No4n1fBwjQMkya8DEZqnp2TQFXGPRCsU,4732
|
|
9
|
+
chatglance-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
chatglance-0.0.1.dist-info/entry_points.txt,sha256=ywnllmIM7VX8HIypCdYBYxK4wlEMtf06hbplkeNabfs,51
|
|
11
|
+
chatglance-0.0.1.dist-info/top_level.txt,sha256=R41gAv-uBU31IM5_5o5aNW79TCd3f4Eli_c1boRA6bw,11
|
|
12
|
+
chatglance-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChatArch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chatglance
|